diff --git a/docs/cli-reference.md b/docs/cli-reference.md index b8ac4fa..1de680b 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -419,7 +419,8 @@ is sent only over `https`; plain `http` is refused except for `localhost`, collection install replaces its own lock. Installing into a lock of another scope is a `usage_error` (pass `--lock other.json` or `--no-lock`). - **update** re-resolves installed sources (all, or those named) and applies the - difference, then refreshes the lock file when its scope was updated. + difference, then refreshes the lock file when its scope was updated. With + nothing installed it prints `Nothing to update.` (`--json`: empty `outcomes`). - **uninstall** drops a kit, a collection, or one or more packages; each explicit package is its own source (`packages:@scope/name`), whether it was installed alone, with others, or by `sync --locked` from a packages lock. diff --git a/packages/cli/src/commands/install-update.test.ts b/packages/cli/src/commands/install-update.test.ts new file mode 100644 index 0000000..a485e2b --- /dev/null +++ b/packages/cli/src/commands/install-update.test.ts @@ -0,0 +1,41 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { InstallLock } from '@skillx/contracts'; +import type { ApplyOutcome } from '../lib/install/install-operations.js'; +import { printUpdated } from './install.js'; + +const outcome: ApplyOutcome = { + lock: {} as InstallLock, + lockDigest: 'sha256:lock', + items: [], + planDigest: 'sha256:plan', + warnings: [], + applied: [], + recovered: { rolledBack: [], rolledForward: [] }, +}; + +let out: string[]; + +beforeEach(() => { + out = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => void out.push(args.join(' '))); +}); + +afterEach(() => vi.restoreAllMocks()); + +describe('printUpdated', () => { + it('says so when nothing is installed', () => { + printUpdated({ outcomes: [], lockPath: null }); + expect(out.join('\n')).toContain('Nothing to update.'); + }); + + it('prints each applied source and no empty-state line', () => { + printUpdated({ outcomes: [outcome, outcome], lockPath: '/tmp/skillx.lock.json' }); + const text = out.join('\n'); + expect(text).not.toContain('Nothing to update.'); + expect(text.match(/āœ“ done/g)).toHaveLength(2); + // The lock line follows only the last source. + expect(text.match(/lock written to/g)).toHaveLength(1); + expect(text.trimEnd().endsWith('āœ“ done')).toBe(true); + expect(text.lastIndexOf('lock written to /tmp/skillx.lock.json')).toBeGreaterThan(text.indexOf('āœ“ done')); + }); +}); diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index 75499eb..c59f8ea 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -38,6 +38,12 @@ function printApplied(outcome: ApplyOutcome, lockPath: string | null): void { console.log(chalk.green('\nāœ“ done')); } +/** Human mode for `update`: each applied source, or one line when nothing is installed. */ +export function printUpdated(data: { outcomes: ApplyOutcome[]; lockPath: string | null }): void { + if (data.outcomes.length === 0) console.log(chalk.dim('Nothing to update.')); + data.outcomes.forEach((o, i) => printApplied(o, i === data.outcomes.length - 1 ? data.lockPath : null)); +} + export const installCommand = withCommon(new Command('install')) .description('Install a kit revision, a collection or packages into a runtime skills directory') .argument('', 'kit:@handle/kit[#revisionId] | collection:[#n] | @publisher/package[@version]...') @@ -80,7 +86,7 @@ export const updateCommand = withCommon(new Command('update')) } return { outcomes, lockPath }; }, - (data) => data.outcomes.forEach((o, i) => printApplied(o, i === data.outcomes.length - 1 ? data.lockPath : null)), + printUpdated, ), );