From 0d0bbc7b9300448399da4ff390c9be557f381082 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9fano=20Zanata?= <221676+szanata@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:22:41 +0100 Subject: [PATCH 1/4] feat: Removing /output wrapper project from CLI package.json template --- sdk/cli/src/templates/project/package.json.template | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sdk/cli/src/templates/project/package.json.template b/sdk/cli/src/templates/project/package.json.template index e7e81716..2e171210 100644 --- a/sdk/cli/src/templates/project/package.json.template +++ b/sdk/cli/src/templates/project/package.json.template @@ -14,7 +14,12 @@ "output:dev": "output dev" }, "dependencies": { - "@outputai/output": "{{frameworkVersion}}" + "@outputai/core": "{{frameworkVersion}}", + "@outputai/cli": "{{frameworkVersion}}", + "@outputai/http": "{{frameworkVersion}}", + "@outputai/llm": "{{frameworkVersion}}", + "@outputai/credentials": "{{frameworkVersion}}", + "@outputai/evals": "{{frameworkVersion}}" }, "devDependencies": { "@types/node": "24.5.2", From abd1aa6c660c8e343ebc7cb2f813f981b3501941 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9fano=20Zanata?= <221676+szanata@users.noreply.github.com> Date: Tue, 9 Jun 2026 23:36:13 +0100 Subject: [PATCH 2/4] Removing docs reference and updating cli update command --- CLAUDE.md | 1 - RELEASING.md | 1 - sdk/cli/src/commands/update.spec.ts | 48 +- sdk/cli/src/commands/update.ts | 69 ++- .../src/services/npm_update_service.spec.ts | 82 +++- sdk/cli/src/services/npm_update_service.ts | 83 +++- sdk/framework/CHANGELOG.md | 449 ------------------ sdk/framework/README.md | 12 - sdk/framework/package.json | 29 -- 9 files changed, 259 insertions(+), 515 deletions(-) delete mode 100644 sdk/framework/CHANGELOG.md delete mode 100644 sdk/framework/README.md delete mode 100644 sdk/framework/package.json diff --git a/CLAUDE.md b/CLAUDE.md index 4e7b9680..4fc140cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,6 @@ Output.ai is an AI framework for building reliable production-ready LLM workflow - HTTP: [sdk/http/README.md](sdk/http/README.md) - Credentials: [sdk/credentials/README.md](sdk/credentials/README.md) - Evals: [sdk/evals/README.md](sdk/evals/README.md) - - Framework: [sdk/framework/README.md](sdk/framework/README.md) - CLI: [sdk/cli/README.md](sdk/cli/README.md) - **Test examples**: See [test_workflows/](test_workflows/) directory diff --git a/RELEASING.md b/RELEASING.md index 6dc3b597..07dc26b8 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -14,7 +14,6 @@ This monorepo publishes the following packages to npm: | `@outputai/http` | `sdk/http` | HTTP client with tracing | | `@outputai/evals` | `sdk/evals` | Evaluation framework (LLM-as-judge) | | `@outputai/credentials` | `sdk/credentials` | Encrypted credential management | -| `@outputai/output` | `sdk/framework` | Umbrella package (re-exports all SDK packages) | | `output-api` | `api` | API server (private, Docker image only) | All `@outputai/*` packages and `output-api` are in a **fixed version group** — they always share the same version number and are bumped together. This is configured in `.changeset/config.json`. diff --git a/sdk/cli/src/commands/update.spec.ts b/sdk/cli/src/commands/update.spec.ts index a5d659b1..22a096e9 100644 --- a/sdk/cli/src/commands/update.spec.ts +++ b/sdk/cli/src/commands/update.spec.ts @@ -4,6 +4,7 @@ import Update from './update.js'; import { fetchLatestVersion, getGlobalInstalledVersion, + getLocalInstalledPackages, getLocalInstalledVersion, updateGlobal, updateLocal, @@ -15,6 +16,7 @@ import { confirm } from '#utils/prompt.js'; vi.mock( '#services/npm_update_service.js', () => ( { fetchLatestVersion: vi.fn(), getGlobalInstalledVersion: vi.fn(), + getLocalInstalledPackages: vi.fn(), getLocalInstalledVersion: vi.fn(), updateGlobal: vi.fn(), updateLocal: vi.fn(), @@ -44,6 +46,7 @@ describe( 'update command', () => { vi.clearAllMocks(); vi.mocked( fetchLatestVersion ).mockResolvedValue( '1.0.0' ); vi.mocked( getGlobalInstalledVersion ).mockResolvedValue( '0.8.4' ); + vi.mocked( getLocalInstalledPackages ).mockResolvedValue( [] ); vi.mocked( getLocalInstalledVersion ).mockResolvedValue( null ); vi.mocked( isOutdated ).mockReturnValue( true ); vi.mocked( confirm ).mockResolvedValue( true ); @@ -195,7 +198,50 @@ describe( 'update command', () => { } ); describe( 'local update', () => { - it( 'should prompt and update local install when outdated', async () => { + it( 'should prompt and update local SDK packages when outdated', async () => { + vi.mocked( getGlobalInstalledVersion ).mockResolvedValue( null ); + vi.mocked( getLocalInstalledPackages ) + .mockResolvedValueOnce( [ + { name: '@outputai/cli', version: '0.8.3' }, + { name: '@outputai/core', version: '0.8.3' }, + { name: '@outputai/http', version: '1.0.0' } + ] ) + .mockResolvedValueOnce( [ + { name: '@outputai/cli', version: '1.0.0' }, + { name: '@outputai/core', version: '1.0.0' }, + { name: '@outputai/http', version: '1.0.0' } + ] ); + vi.mocked( isOutdated ).mockImplementation( ( current, latest ) => current !== latest ); + + const cmd = createTestCommand( { cli: true } ); + await cmd.run(); + + expect( updateLocal ).toHaveBeenCalledWith( + process.cwd(), + [ '@outputai/cli', '@outputai/core', '@outputai/http' ], + '1.0.0' + ); + expect( confirm ).toHaveBeenCalledWith( + expect.objectContaining( { message: expect.stringContaining( 'Output SDK packages' ) } ) + ); + } ); + + it( 'should show local SDK packages as up to date', async () => { + vi.mocked( getGlobalInstalledVersion ).mockResolvedValue( null ); + vi.mocked( getLocalInstalledPackages ).mockResolvedValue( [ + { name: '@outputai/cli', version: '1.0.0' }, + { name: '@outputai/core', version: '1.0.0' } + ] ); + vi.mocked( isOutdated ).mockReturnValue( false ); + + const cmd = createTestCommand( { cli: true } ); + await cmd.run(); + + expect( updateLocal ).not.toHaveBeenCalled(); + expect( cmd.log ).toHaveBeenCalledWith( expect.stringContaining( 'up to date' ) ); + } ); + + it( 'should prompt and update legacy local install when outdated', async () => { vi.mocked( getLocalInstalledVersion ) .mockResolvedValueOnce( '0.8.3' ) .mockResolvedValueOnce( '1.0.0' ); diff --git a/sdk/cli/src/commands/update.ts b/sdk/cli/src/commands/update.ts index 5e9b9c2b..6cd0b076 100644 --- a/sdk/cli/src/commands/update.ts +++ b/sdk/cli/src/commands/update.ts @@ -3,10 +3,12 @@ import { confirm } from '#utils/prompt.js'; import { fetchLatestVersion, getGlobalInstalledVersion, + getLocalInstalledPackages, getLocalInstalledVersion, updateGlobal, updateLocal, - isOutdated + isOutdated, + type LocalInstalledPackage } from '#services/npm_update_service.js'; import { ensureClaudePlugin } from '#services/coding_agents.js'; import { getErrorMessage } from '#utils/error_utils.js'; @@ -49,7 +51,7 @@ export default class Update extends Command { this.error( 'Could not fetch the latest version from the npm registry. Run with DEBUG=output-cli:npm-update for details.' ); } - this.log( `\nLatest @outputai/cli version: v${latest}\n` ); + this.log( `\nLatest Output SDK version: v${latest}\n` ); await this.handleGlobalUpdate( latest ); await this.handleLocalUpdate( latest ); @@ -106,6 +108,12 @@ export default class Update extends Command { private async handleLocalUpdate( latest: string ): Promise { const cwd = process.cwd(); + const localPackages = await getLocalInstalledPackages( cwd ); + + if ( localPackages.length > 0 ) { + return this.handleLocalSdkPackageUpdate( cwd, latest, localPackages ); + } + const localVersion = await getLocalInstalledVersion( cwd ); if ( !localVersion ) { @@ -128,7 +136,7 @@ export default class Update extends Command { } try { - await updateLocal( cwd ); + await updateLocal( cwd, [ '@outputai/cli' ], latest ); const newLocalVersion = await getLocalInstalledVersion( cwd ); if ( newLocalVersion ) { @@ -136,8 +144,8 @@ export default class Update extends Command { if ( isOutdated( newLocalVersion, latest ) ) { this.warn( - `Your package.json constrains @outputai/output which limits @outputai/cli to v${newLocalVersion}. ` + - 'Update the @outputai/output version range in package.json to get the latest CLI.' + `Your package.json constrains @outputai/cli to v${newLocalVersion}. ` + + 'Update your Output SDK package version ranges to get the latest CLI.' ); } } else { @@ -150,4 +158,55 @@ export default class Update extends Command { return false; } } + + private async handleLocalSdkPackageUpdate( + cwd: string, + latest: string, + localPackages: LocalInstalledPackage[] + ): Promise { + const outdatedPackages = localPackages.filter( pkg => isOutdated( pkg.version, latest ) ); + + if ( outdatedPackages.length === 0 ) { + this.log( '\nLocal Output SDK packages: up to date' ); + return false; + } + + this.log( '\nLocal Output SDK packages:' ); + for ( const pkg of localPackages ) { + const suffix = isOutdated( pkg.version, latest ) ? ` -> v${latest}` : ' (up to date)'; + this.log( ` ${pkg.name}: v${pkg.version}${suffix}` ); + } + + const shouldUpdate = await confirm( { + message: `Update local Output SDK packages to v${latest}?` + } ); + + if ( !shouldUpdate ) { + return false; + } + + const packageNames = localPackages.map( pkg => pkg.name ); + + try { + await updateLocal( cwd, packageNames, latest ); + const newLocalPackages = await getLocalInstalledPackages( cwd ); + + if ( newLocalPackages.length > 0 ) { + this.log( `\nLocal Output SDK packages updated to v${latest}` ); + + const stalePackages = newLocalPackages.filter( pkg => isOutdated( pkg.version, latest ) ); + if ( stalePackages.length > 0 ) { + const staleNames = stalePackages.map( pkg => `${pkg.name}@${pkg.version}` ).join( ', ' ); + this.warn( `Some Output SDK packages are still behind v${latest}: ${staleNames}` ); + } + } else { + this.log( '\nLocal update completed (could not verify new versions)' ); + } + + return true; + } catch ( error: unknown ) { + this.warn( `Failed to update local install: ${getErrorMessage( error )}` ); + return false; + } + } } diff --git a/sdk/cli/src/services/npm_update_service.spec.ts b/sdk/cli/src/services/npm_update_service.spec.ts index a8fd8595..30e57140 100644 --- a/sdk/cli/src/services/npm_update_service.spec.ts +++ b/sdk/cli/src/services/npm_update_service.spec.ts @@ -1,16 +1,23 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { EventEmitter } from 'node:events'; import { fetchLatestVersion, getGlobalInstalledVersion, + getLocalInstalledPackages, getLocalInstalledVersion, + updateLocal, isOutdated } from './npm_update_service.js'; -const { mockExecFile } = vi.hoisted( () => ( { mockExecFile: vi.fn() } ) ); +const { mockExecFile, mockReadFile, mockSpawn } = vi.hoisted( () => ( { + mockExecFile: vi.fn(), + mockReadFile: vi.fn(), + mockSpawn: vi.fn() +} ) ); vi.mock( 'node:child_process', () => ( { execFile: vi.fn(), - spawn: vi.fn() + spawn: mockSpawn } ) ); vi.mock( 'node:util', () => ( { @@ -20,9 +27,14 @@ vi.mock( 'node:util', () => ( { const mockFetch = vi.fn(); vi.stubGlobal( 'fetch', mockFetch ); +vi.mock( 'node:fs/promises', () => ( { + readFile: mockReadFile +} ) ); + describe( 'npm_update_service', () => { beforeEach( () => { vi.clearAllMocks(); + mockReadFile.mockResolvedValue( JSON.stringify( { dependencies: {} } ) ); } ); describe( 'fetchLatestVersion', () => { @@ -131,6 +143,72 @@ describe( 'npm_update_service', () => { } ); } ); + describe( 'getLocalInstalledPackages', () => { + it( 'should return directly installed Output SDK package versions', async () => { + mockReadFile.mockResolvedValue( JSON.stringify( { + dependencies: { + '@outputai/cli': '0.8.3', + '@outputai/core': '0.8.3', + 'other-package': '1.0.0' + }, + devDependencies: { + '@outputai/llm': '0.8.3' + } + } ) ); + mockExecFile.mockImplementation( async ( _command, args ) => { + const packageName = args[1]; + return { + stdout: JSON.stringify( { + dependencies: { [packageName]: { version: '0.8.3' } } + } ) + }; + } ); + + const result = await getLocalInstalledPackages( '/some/project' ); + + expect( result ).toEqual( [ + { name: '@outputai/cli', version: '0.8.3' }, + { name: '@outputai/core', version: '0.8.3' }, + { name: '@outputai/llm', version: '0.8.3' } + ] ); + expect( mockExecFile ).toHaveBeenCalledWith( + 'npm', [ 'ls', '@outputai/cli', '--json' ], { cwd: '/some/project' } + ); + expect( mockExecFile ).toHaveBeenCalledWith( + 'npm', [ 'ls', '@outputai/core', '--json' ], { cwd: '/some/project' } + ); + expect( mockExecFile ).toHaveBeenCalledWith( + 'npm', [ 'ls', '@outputai/llm', '--json' ], { cwd: '/some/project' } + ); + } ); + + it( 'should return an empty list when package.json cannot be read', async () => { + mockReadFile.mockRejectedValue( new Error( 'missing package.json' ) ); + + const result = await getLocalInstalledPackages( '/some/project' ); + + expect( result ).toEqual( [] ); + expect( mockExecFile ).not.toHaveBeenCalled(); + } ); + } ); + + describe( 'updateLocal', () => { + it( 'should install local packages at the target version exactly', async () => { + const proc = new EventEmitter(); + mockSpawn.mockReturnValue( proc ); + + const promise = updateLocal( '/some/project', [ '@outputai/cli', '@outputai/core' ], '1.0.0' ); + proc.emit( 'close', 0 ); + await promise; + + expect( mockSpawn ).toHaveBeenCalledWith( + 'npm', + [ 'install', '--ignore-scripts', '--save-exact', '@outputai/cli@1.0.0', '@outputai/core@1.0.0' ], + { cwd: '/some/project', stdio: 'inherit' } + ); + } ); + } ); + describe( 'isOutdated', () => { it( 'should return true when latest is newer', () => { expect( isOutdated( '0.8.4', '0.8.5' ) ).toBe( true ); diff --git a/sdk/cli/src/services/npm_update_service.ts b/sdk/cli/src/services/npm_update_service.ts index 8baf9ba4..d4943523 100644 --- a/sdk/cli/src/services/npm_update_service.ts +++ b/sdk/cli/src/services/npm_update_service.ts @@ -1,4 +1,6 @@ import { execFile as execFileCb, spawn } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; import { promisify } from 'node:util'; import debugFactory from 'debug'; import semver from 'semver'; @@ -7,23 +9,36 @@ import packageJson from '../../package.json' with { type: 'json' }; const execFile = promisify( execFileCb ); const debug = debugFactory( 'output-cli:npm-update' ); -const PACKAGE_NAME = packageJson.name; +const CLI_PACKAGE_NAME = packageJson.name; const REGISTRY_URL = 'https://registry.npmjs.org'; const REGISTRY_TIMEOUT_MS = 5000; +export const LOCAL_SDK_PACKAGE_NAMES = [ + '@outputai/cli', + '@outputai/core', + '@outputai/http', + '@outputai/llm', + '@outputai/credentials', + '@outputai/evals' +] as const; /* eslint-disable @typescript-eslint/no-explicit-any */ -function findVersionInTree( deps: Record | undefined ): string | null { +export interface LocalInstalledPackage { + name: string; + version: string; +} + +function findVersionInTree( deps: Record | undefined, packageName: string ): string | null { if ( !deps ) { return null; } - if ( deps[PACKAGE_NAME]?.version ) { - return deps[PACKAGE_NAME].version; + if ( deps[packageName]?.version ) { + return deps[packageName].version; } for ( const dep of Object.values( deps ) ) { - const found = findVersionInTree( dep.dependencies ); + const found = findVersionInTree( dep.dependencies, packageName ); if ( found ) { return found; } @@ -32,18 +47,35 @@ function findVersionInTree( deps: Record | undefined ): string | nu return null; } -function parseNpmLsVersion( output: string ): string | null { +function parseNpmLsVersion( output: string, packageName: string ): string | null { try { const parsed = JSON.parse( output ); - return findVersionInTree( parsed.dependencies ); + return findVersionInTree( parsed.dependencies, packageName ); } catch { return null; } } +async function readDirectOutputDependencies( cwd: string ): Promise> { + try { + const raw = await readFile( path.join( cwd, 'package.json' ), 'utf-8' ); + const pkg = JSON.parse( raw ) as { + dependencies?: Record; + devDependencies?: Record; + }; + return new Set( [ + ...Object.keys( pkg.dependencies ?? {} ), + ...Object.keys( pkg.devDependencies ?? {} ) + ] ); + } catch ( error ) { + debug( 'Failed to read local package.json: %O', error ); + return new Set(); + } +} + export async function fetchLatestVersion(): Promise { try { - const response = await fetch( `${REGISTRY_URL}/${PACKAGE_NAME}/latest`, { + const response = await fetch( `${REGISTRY_URL}/${CLI_PACKAGE_NAME}/latest`, { signal: AbortSignal.timeout( REGISTRY_TIMEOUT_MS ) } ); if ( !response.ok ) { @@ -60,8 +92,8 @@ export async function fetchLatestVersion(): Promise { export async function getGlobalInstalledVersion(): Promise { try { - const { stdout } = await execFile( 'npm', [ 'ls', '-g', PACKAGE_NAME, '--json' ] ); - return parseNpmLsVersion( stdout ); + const { stdout } = await execFile( 'npm', [ 'ls', '-g', CLI_PACKAGE_NAME, '--json' ] ); + return parseNpmLsVersion( stdout, CLI_PACKAGE_NAME ); } catch ( error ) { debug( 'Failed to get global version: %O', error ); return null; @@ -70,14 +102,34 @@ export async function getGlobalInstalledVersion(): Promise { export async function getLocalInstalledVersion( cwd: string ): Promise { try { - const { stdout } = await execFile( 'npm', [ 'ls', PACKAGE_NAME, '--json' ], { cwd } ); - return parseNpmLsVersion( stdout ); + const { stdout } = await execFile( 'npm', [ 'ls', CLI_PACKAGE_NAME, '--json' ], { cwd } ); + return parseNpmLsVersion( stdout, CLI_PACKAGE_NAME ); } catch ( error ) { debug( 'Failed to get local version: %O', error ); return null; } } +export async function getLocalInstalledPackages( cwd: string ): Promise { + const directDeps = await readDirectOutputDependencies( cwd ); + const packageNames: string[] = LOCAL_SDK_PACKAGE_NAMES.filter( name => directDeps.has( name ) ); + + const versions = await Promise.all( + packageNames.map( async name => { + try { + const { stdout } = await execFile( 'npm', [ 'ls', name, '--json' ], { cwd } ); + const version = parseNpmLsVersion( stdout, name ); + return version ? { name, version } : null; + } catch ( error ) { + debug( 'Failed to get local version for %s: %O', name, error ); + return null; + } + } ) + ); + + return versions.filter( ( item ): item is LocalInstalledPackage => item !== null ); +} + function spawnInherit( command: string, args: string[], cwd?: string ): Promise { return new Promise( ( resolve, reject ) => { const proc = spawn( command, args, { cwd, stdio: 'inherit' } ); @@ -94,11 +146,12 @@ function spawnInherit( command: string, args: string[], cwd?: string ): Promise< } export async function updateGlobal(): Promise { - await spawnInherit( 'npm', [ 'install', '-g', '--ignore-scripts', `${PACKAGE_NAME}@latest` ] ); + await spawnInherit( 'npm', [ 'install', '-g', '--ignore-scripts', `${CLI_PACKAGE_NAME}@latest` ] ); } -export async function updateLocal( cwd: string ): Promise { - await spawnInherit( 'npm', [ 'update', '--ignore-scripts', PACKAGE_NAME ], cwd ); +export async function updateLocal( cwd: string, packageNames: string[], version: string ): Promise { + const packages = packageNames.map( name => `${name}@${version}` ); + await spawnInherit( 'npm', [ 'install', '--ignore-scripts', '--save-exact', ...packages ], cwd ); } export function isOutdated( current: string, latest: string ): boolean { diff --git a/sdk/framework/CHANGELOG.md b/sdk/framework/CHANGELOG.md deleted file mode 100644 index 34d8972c..00000000 --- a/sdk/framework/CHANGELOG.md +++ /dev/null @@ -1,449 +0,0 @@ -# @outputai/output - -## 0.9.0 - -### Patch Changes - -- Updated dependencies [ec4c07d] -- Updated dependencies [4b5c049] -- Updated dependencies [c12766f] -- Updated dependencies [7929835] -- Updated dependencies [e658cc2] -- Updated dependencies [aaa08cc] -- Updated dependencies [ad732b1] -- Updated dependencies [42a0ddf] - - @outputai/core@0.9.0 - - @outputai/cli@0.9.0 - - @outputai/llm@0.9.0 - - @outputai/http@0.9.0 - - @outputai/credentials@0.9.0 - - @outputai/evals@0.9.0 - -## 0.8.1 - -### Patch Changes - -- Updated dependencies [aa8ed5e] -- Updated dependencies [c2347a6] - - @outputai/core@0.8.1 - - @outputai/cli@0.8.1 - - @outputai/credentials@0.8.1 - - @outputai/evals@0.8.1 - - @outputai/http@0.8.1 - - @outputai/llm@0.8.1 - -## 0.8.0 - -### Patch Changes - -- Updated dependencies [5485680] -- Updated dependencies [5485680] -- Updated dependencies [5485680] -- Updated dependencies [5485680] -- Updated dependencies [0e958f3] -- Updated dependencies [5485680] -- Updated dependencies [5485680] -- Updated dependencies [5485680] -- Updated dependencies [5485680] -- Updated dependencies [5485680] -- Updated dependencies [5485680] - - @outputai/core@0.8.0 - - @outputai/cli@0.8.0 - - @outputai/llm@0.8.0 - - @outputai/credentials@0.8.0 - - @outputai/evals@0.8.0 - - @outputai/http@0.8.0 - -## 0.7.0 - -### Patch Changes - -- Updated dependencies [383b24b] -- Updated dependencies [5d7e612] -- Updated dependencies [1f47248] -- Updated dependencies [2cc4685] -- Updated dependencies [34badf9] -- Updated dependencies [0d08ff5] -- Updated dependencies [383b24b] -- Updated dependencies [fc6a93e] -- Updated dependencies [83742db] -- Updated dependencies [f8d698e] - - @outputai/core@0.7.0 - - @outputai/llm@0.7.0 - - @outputai/http@0.7.0 - - @outputai/cli@0.7.0 - - @outputai/credentials@0.7.0 - - @outputai/evals@0.7.0 - -## 0.6.0 - -### Patch Changes - -- Updated dependencies [bdf47aa] -- Updated dependencies [69060d7] -- Updated dependencies [69060d7] - - @outputai/core@0.6.0 - - @outputai/cli@0.6.0 - - @outputai/credentials@0.6.0 - - @outputai/evals@0.6.0 - - @outputai/http@0.6.0 - - @outputai/llm@0.6.0 - -## 0.5.2 - -### Patch Changes - -- Updated dependencies [b54869d] -- Updated dependencies [17d8711] -- Updated dependencies [8738f60] -- Updated dependencies [93dd22e] -- Updated dependencies [cc8a372] - - @outputai/http@0.5.2 - - @outputai/core@0.5.2 - - @outputai/cli@0.5.2 - - @outputai/credentials@0.5.2 - - @outputai/evals@0.5.2 - - @outputai/llm@0.5.2 - -## 0.5.1 - -### Patch Changes - -- Updated dependencies [93f660c] -- Updated dependencies [8e45051] - - @outputai/core@0.5.1 - - @outputai/cli@0.5.1 - - @outputai/credentials@0.5.1 - - @outputai/evals@0.5.1 - - @outputai/http@0.5.1 - - @outputai/llm@0.5.1 - -## 0.5.0 - -### Patch Changes - -- Updated dependencies [43c9293] -- Updated dependencies [d085dde] -- Updated dependencies [d43aa3d] -- Updated dependencies [ae3ab85] -- Updated dependencies [6bc541c] -- Updated dependencies [d43aa3d] - - @outputai/core@0.5.0 - - @outputai/http@0.5.0 - - @outputai/llm@0.5.0 - - @outputai/cli@0.5.0 - - @outputai/credentials@0.5.0 - - @outputai/evals@0.5.0 - -## 0.4.0 - -### Patch Changes - -- Updated dependencies [6137ea6] -- Updated dependencies [b23002f] -- Updated dependencies [e8eff63] -- Updated dependencies [33928d3] -- Updated dependencies [32f4d87] -- Updated dependencies [b4a190e] -- Updated dependencies [2650161] -- Updated dependencies [7ccc4fe] - - @outputai/cli@0.4.0 - - @outputai/llm@0.4.0 - - @outputai/core@0.4.0 - - @outputai/credentials@0.4.0 - - @outputai/evals@0.4.0 - - @outputai/http@0.4.0 - -## 0.3.2 - -### Patch Changes - -- Updated dependencies [1282dcf] - - @outputai/cli@0.3.2 - - @outputai/core@0.3.2 - - @outputai/credentials@0.3.2 - - @outputai/evals@0.3.2 - - @outputai/http@0.3.2 - - @outputai/llm@0.3.2 - -## 0.3.1 - -### Patch Changes - -- Updated dependencies [00e0047] - - @outputai/llm@0.3.1 - - @outputai/cli@0.3.1 - - @outputai/core@0.3.1 - - @outputai/credentials@0.3.1 - - @outputai/evals@0.3.1 - - @outputai/http@0.3.1 - -## 0.3.0 - -### Patch Changes - -- b87b58f: ## Dependencies updates - - ### Vulnerabilities fixed: - - - uuid: Missing buffer bounds check in v3/v5/v6 when buf: (bump to `>=14.0.0`) - - postcss: PostCSS has XSS via Unescaped in its CSS Stringify Output (bump to `>=8.5.10`) - - @anthropic-ai/sdk: Claude SDK for TypeScript has Insecure Default File Permissions in Local Filesystem Memory Tool (bump to `>=0.91.1`) - - ### Root package.json updates - - - @changesets/cli: `2.30.0` -> `2.31.0` - - eslint: `10.2.0` -> `10.2.1` - - mintlify: `4.2.520` -> `4.2.536` - - typescript-eslint: `8.58.2` -> `8.59.1` - - vitest: `4.1.4` -> `4.1.5` - - ### pnpm-workspace.yaml (catalog) updates - - - @aws-sdk/client-s3: `3.1031.0` -> `3.1038.0` - - ### sdk/cli/package.json updates - - - @inquirer/prompts: `8.4.1` -> `8.4.2` - - @oclif/core: `4.10.5` -> `4.10.6` - - @oclif/plugin-help: `6.2.44` -> `6.2.45` - - undici: `8.0.2` -> `catalog:` - - orval: `8.8.0` -> `8.9.0` - - ### sdk/llm/package.json updates - - - @ai-sdk/amazon-bedrock: `4.0.95` -> `4.0.96` - - liquidjs: `10.25.5` -> `10.25.7` - -- Updated dependencies [8836247] -- Updated dependencies [2809e50] -- Updated dependencies [b87b58f] -- Updated dependencies [bc8ccee] -- Updated dependencies [05462f4] -- Updated dependencies [2ddcc3e] -- Updated dependencies [6cd5716] -- Updated dependencies [899ddaf] -- Updated dependencies [756d32d] -- Updated dependencies [7fd86e7] -- Updated dependencies [7e1c76d] -- Updated dependencies [52e960c] -- Updated dependencies [0cbee89] -- Updated dependencies [6499038] -- Updated dependencies [bd54540] -- Updated dependencies [23c3ed0] -- Updated dependencies [815b3a9] -- Updated dependencies [fd72d95] -- Updated dependencies [f1502fb] - - @outputai/cli@0.3.0 - - @outputai/core@0.3.0 - - @outputai/llm@0.3.0 - - @outputai/credentials@0.3.0 - - @outputai/evals@0.3.0 - - @outputai/http@0.3.0 - -## 0.2.0 - -### Patch Changes - -- f13723b: Updating dependencies: - - - @oclif/plugin-help - - dotenv - - json-schema-library - - react - - redis - - undici - - @noble/ciphers - - @ai-sdk/amazon-bedrock - - @ai-sdk/anthropic - - @ai-sdk/azure - - @ai-sdk/google-vertex - - @ai-sdk/openai - - @ai-sdk/perplexity - - ai - - liquidjs - - Adding version overrides to fix vulnerabilities: - - - vite@>=7.1.0 <=7.3.1: `>=7.3.2` - - hono@<4.12.12: `>=4.12.12` - - hono@>=4.0.0 <=4.12.11: `>=4.12.12` - - @hono/node-server@<1.19.13: `>=1.19.13` - - follow-redirects@<=1.15.11: `>=1.16.0` - - hono@<4.12.14: `>=4.12.14` - - axios@>=1.0.0 <1.15.0: `>=1.15.0` - - protobufjs@<7.5.5: `>=7.5.5` - -- Updated dependencies [91c5d78] -- Updated dependencies [455ac5e] -- Updated dependencies [b651368] -- Updated dependencies [cc1ead7] -- Updated dependencies [b3dea5c] -- Updated dependencies [0fd573d] -- Updated dependencies [4407119] -- Updated dependencies [320acd1] -- Updated dependencies [04243eb] -- Updated dependencies [f13723b] -- Updated dependencies [0bb44fb] -- Updated dependencies [ac8c0f7] - - @outputai/cli@0.2.0 - - @outputai/evals@0.2.0 - - @outputai/credentials@0.2.0 - - @outputai/core@0.2.0 - - @outputai/llm@0.2.0 - - @outputai/http@0.2.0 - -## 0.1.12 - -### Patch Changes - -- Updated dependencies [76bcede] -- Updated dependencies [5ef9a7c] -- Updated dependencies [3ed2168] -- Updated dependencies [0990e42] -- Updated dependencies [e3a6d72] - - @outputai/core@0.1.12 - - @outputai/llm@0.1.12 - - @outputai/cli@0.1.12 - - @outputai/credentials@0.1.12 - - @outputai/evals@0.1.12 - - @outputai/http@0.1.12 - -## 0.1.11 - -### Patch Changes - -- Updated dependencies [49171f5] -- Updated dependencies [7b8340c] -- Updated dependencies [e0a5d0f] -- Updated dependencies [c4f84d5] - - @outputai/cli@0.1.11 - - @outputai/core@0.1.11 - - @outputai/credentials@0.1.11 - - @outputai/evals@0.1.11 - - @outputai/http@0.1.11 - - @outputai/llm@0.1.11 - -## 0.1.10 - -### Patch Changes - -- 41ecc1b: Updating dependencies to latest and overriding version to fix vulnerabilities -- Updated dependencies [41ecc1b] - - @outputai/core@0.1.10 - - @outputai/cli@0.1.10 - - @outputai/llm@0.1.10 - - @outputai/credentials@0.1.10 - - @outputai/evals@0.1.10 - - @outputai/http@0.1.10 - -## 0.1.9 - -### Patch Changes - -- Updated dependencies [133551f] - - @outputai/cli@0.1.9 - - @outputai/core@0.1.9 - - @outputai/credentials@0.1.9 - - @outputai/evals@0.1.9 - - @outputai/http@0.1.9 - - @outputai/llm@0.1.9 - -## 0.1.8 - -### Patch Changes - -- Updated dependencies [f78154c] -- Updated dependencies [834d0aa] - - @outputai/llm@0.1.8 - - @outputai/cli@0.1.8 - - @outputai/core@0.1.8 - - @outputai/credentials@0.1.8 - - @outputai/evals@0.1.8 - - @outputai/http@0.1.8 - -## 0.1.7 - -### Patch Changes - -- ac7fc2b: Bumping dependecies minor, patch versions -- Updated dependencies [ac7fc2b] - - @outputai/core@0.1.7 - - @outputai/cli@0.1.7 - - @outputai/llm@0.1.7 - - @outputai/credentials@0.1.7 - - @outputai/evals@0.1.7 - - @outputai/http@0.1.7 - -## 0.1.6 - -### Patch Changes - -- Updated dependencies [2dba5c6] - - @outputai/cli@0.1.6 - - @outputai/core@0.1.6 - - @outputai/credentials@0.1.6 - - @outputai/evals@0.1.6 - - @outputai/http@0.1.6 - - @outputai/llm@0.1.6 - -## 0.1.5 - -### Patch Changes - -- Updated dependencies [a03318d] - - @outputai/cli@0.1.5 - - @outputai/core@0.1.5 - - @outputai/credentials@0.1.5 - - @outputai/evals@0.1.5 - - @outputai/http@0.1.5 - - @outputai/llm@0.1.5 - -## 0.1.4 - -### Patch Changes - -- b9b986d: Patching vulnerable dependencies -- Updated dependencies [b9b986d] - - @outputai/cli@0.1.4 - - @outputai/core@0.1.4 - - @outputai/credentials@0.1.4 - - @outputai/evals@0.1.4 - - @outputai/http@0.1.4 - - @outputai/llm@0.1.4 - -## 0.1.3 - -### Patch Changes - -- Updated dependencies [2547029] - - @outputai/core@0.1.3 - - @outputai/credentials@0.1.3 - - @outputai/cli@0.1.3 - - @outputai/evals@0.1.3 - - @outputai/http@0.1.3 - - @outputai/llm@0.1.3 - -## 0.1.2 - -### Patch Changes - -- Updated dependencies [5f1d559] - - @outputai/cli@0.1.2 - - @outputai/core@0.1.2 - - @outputai/credentials@0.1.2 - - @outputai/evals@0.1.2 - - @outputai/http@0.1.2 - - @outputai/llm@0.1.2 - -## 0.1.1 - -### Patch Changes - -- Updated dependencies [ec4c478] - - @outputai/core@0.1.1 - - @outputai/cli@0.1.1 - - @outputai/credentials@0.1.1 - - @outputai/evals@0.1.1 - - @outputai/http@0.1.1 - - @outputai/llm@0.1.1 diff --git a/sdk/framework/README.md b/sdk/framework/README.md deleted file mode 100644 index 9a7fcc6b..00000000 --- a/sdk/framework/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# @outputai/output - -Unified package for building durable LLM applications with the Output Framework. - -[![npm version](https://img.shields.io/npm/v/@outputai/output)](https://www.npmjs.com/package/@outputai/output) - -## Documentation -- [Getting Started](https://docs.output.ai/) -- [@outputai/core](https://docs.output.ai/packages/core) - Workflow orchestration and worker runtime -- [@outputai/llm](https://docs.output.ai/packages/llm) - LLM generation with prompt templating -- [@outputai/http](https://docs.output.ai/packages/http) - HTTP client with tracing -- [@outputai/cli](https://docs.output.ai/packages/cli) - CLI for creating and running workflows diff --git a/sdk/framework/package.json b/sdk/framework/package.json deleted file mode 100644 index 836b06f8..00000000 --- a/sdk/framework/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "@outputai/output", - "version": "0.9.0", - "description": "The Output.ai Framework", - "type": "module", - "dependencies": { - "@outputai/cli": "workspace:", - "@outputai/core": "workspace:", - "@outputai/credentials": "workspace:", - "@outputai/evals": "workspace:", - "@outputai/http": "workspace:", - "@outputai/llm": "workspace:" - }, - "license": "Apache-2.0", - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "https://github.com/growthxai/output" - }, - "keywords": [ - "temporal", - "workflow", - "llm", - "ai", - "sdk" - ] -} From 9f7b159e725784a5b143761381230f41420b2110 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9fano=20Zanata?= <221676+szanata@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:38:00 +0100 Subject: [PATCH 3/4] Improving strategy --- .changeset/salty-insects-kick.md | 12 +++ docs/guides/docs.json | 3 +- docs/guides/migrations/index.mdx | 3 + docs/guides/migrations/v0.9.0-to-v0.10.0.mdx | 97 +++++++++++++++++++ pnpm-lock.yaml | 21 ---- sdk/cli/scripts/set_sdk_version.js | 14 +-- sdk/cli/scripts/set_sdk_version.test.js | 24 ++--- sdk/cli/src/commands/dev/index.spec.ts | 30 ++++++ sdk/cli/src/commands/dev/index.ts | 12 ++- sdk/cli/src/commands/migrate.spec.ts | 45 +++++++++ sdk/cli/src/commands/migrate.ts | 9 ++ sdk/cli/src/commands/update.spec.ts | 69 +++++++++++-- sdk/cli/src/commands/update.ts | 26 +++-- sdk/cli/src/generated/framework_version.json | 3 - sdk/cli/src/generated/sdk_version.json | 3 + sdk/cli/src/services/fix_package.ts | 2 +- .../src/services/npm_update_service.spec.ts | 77 ++++++++++++++- sdk/cli/src/services/npm_update_service.ts | 58 +++++++++-- sdk/cli/src/services/project_scaffold.spec.ts | 8 +- sdk/cli/src/services/project_scaffold.ts | 8 +- .../templates/project/package.json.template | 12 +-- sdk/cli/src/utils/framework_version.spec.ts | 17 ---- sdk/cli/src/utils/framework_version.ts | 9 -- sdk/cli/src/utils/sdk_version.spec.ts | 17 ++++ sdk/cli/src/utils/sdk_version.ts | 9 ++ 25 files changed, 472 insertions(+), 116 deletions(-) create mode 100644 .changeset/salty-insects-kick.md create mode 100644 docs/guides/migrations/v0.9.0-to-v0.10.0.mdx create mode 100644 sdk/cli/src/commands/migrate.spec.ts delete mode 100644 sdk/cli/src/generated/framework_version.json create mode 100644 sdk/cli/src/generated/sdk_version.json delete mode 100644 sdk/cli/src/utils/framework_version.spec.ts delete mode 100644 sdk/cli/src/utils/framework_version.ts create mode 100644 sdk/cli/src/utils/sdk_version.spec.ts create mode 100644 sdk/cli/src/utils/sdk_version.ts diff --git a/.changeset/salty-insects-kick.md b/.changeset/salty-insects-kick.md new file mode 100644 index 00000000..ee3fbe44 --- /dev/null +++ b/.changeset/salty-insects-kick.md @@ -0,0 +1,12 @@ +--- +"@outputai/credentials": minor +"@outputai/evals": minor +"@outputai/core": minor +"@outputai/http": minor +"@outputai/cli": minor +"@outputai/llm": minor +--- + +Drop the deprecated `@outputai/output` wrapper package. Projects should install the `@outputai/*` SDK packages they use directly, which avoids hidden transitive "ghost" dependencies. + +New scaffolds now declare direct SDK packages, and `output update`, `output migrate`, and `output dev` warn when an existing project still depends on the deprecated wrapper. diff --git a/docs/guides/docs.json b/docs/guides/docs.json index 625dd67d..4328a913 100644 --- a/docs/guides/docs.json +++ b/docs/guides/docs.json @@ -158,7 +158,8 @@ "migrations/v0.5.2-to-v0.6.0", "migrations/v0.6.0-to-v0.7.0", "migrations/v0.7.0-to-v0.8.0", - "migrations/v0.8.0-to-v0.9.0" + "migrations/v0.8.0-to-v0.9.0", + "migrations/v0.9.0-to-v0.10.0" ] } ] diff --git a/docs/guides/migrations/index.mdx b/docs/guides/migrations/index.mdx index add84975..43ea2521 100644 --- a/docs/guides/migrations/index.mdx +++ b/docs/guides/migrations/index.mdx @@ -42,4 +42,7 @@ It reads the `@outputai/*` version in your `package.json`, finds the matching gu CLI replaces `--format json` with the native `--json` flag. + + Replaces the deprecated `@outputai/output` wrapper package with explicit SDK packages. + diff --git a/docs/guides/migrations/v0.9.0-to-v0.10.0.mdx b/docs/guides/migrations/v0.9.0-to-v0.10.0.mdx new file mode 100644 index 00000000..8ae4d46d --- /dev/null +++ b/docs/guides/migrations/v0.9.0-to-v0.10.0.mdx @@ -0,0 +1,97 @@ +--- +title: "v0.9.0 → v0.10.0" +description: "Upgrading Output.ai projects from v0.9.0 to v0.10.0: replace the deprecated @outputai/output wrapper package with explicit SDK packages." +--- + +This guide covers the removal of the `@outputai/output` wrapper package in v0.10.0. The wrapper installed all SDK modules at once, but it could hide modules your project was using through transitive dependencies. Explicit package installs make each workflow dependency visible in `package.json`. + +## What changed + +New projects no longer install `@outputai/output`. They declare the SDK packages they use directly: + +```json +{ + "dependencies": { + "@outputai/core": "0.10.0", + "@outputai/cli": "0.10.0", + "@outputai/http": "0.10.0", + "@outputai/llm": "0.10.0", + "@outputai/credentials": "0.10.0", + "@outputai/evals": "0.10.0" + } +} +``` + +`output update`, `output migrate`, and `output dev` now warn when they detect the deprecated wrapper package. + +## Migration steps + +### Prefer the migration command + +The recommended path is to let the CLI apply this guide: + +```bash +npx output migrate +``` + +The migration command reads your current Output SDK version, fetches the matching migration guide, applies the changes, and runs the project checks. + +### Manual package.json update + +If you migrate manually, remove `@outputai/output` and add the direct SDK packages your project uses. + +```json +{ + "dependencies": { + "@outputai/output": "0.9.0" + } +} +``` + +Replace it with explicit packages: + +```json +{ + "dependencies": { + "@outputai/core": "0.10.0", + "@outputai/cli": "0.10.0", + "@outputai/http": "0.10.0", + "@outputai/llm": "0.10.0", + "@outputai/credentials": "0.10.0", + "@outputai/evals": "0.10.0" + } +} +``` + +Install only the packages you actually use. Typical workflow projects need `@outputai/core` and often `@outputai/llm`; projects with HTTP clients need `@outputai/http`; projects using credentials or evals should include `@outputai/credentials` or `@outputai/evals`. + + +Keep each package in the same dependency section that makes sense for your project. Runtime workflow imports belong in `dependencies`; CLI-only tooling can live in `devDependencies` if your project already keeps it there. + + +### Reinstall and update + +After editing `package.json`, reinstall dependencies and run the update command: + +```bash +npm install +npx output update --cli +``` + +`output update --cli` updates directly declared Output SDK packages and preserves whether each package was declared in `dependencies` or `devDependencies`. + +### Re-scaffold if preferred + +For small or early projects, it may be faster to scaffold a fresh v0.10.0 project and copy your workflow code over: + +```bash +npx output init my-output-project +``` + +## Checklist + +- Remove `@outputai/output` from `package.json`. +- Add explicit `@outputai/*` packages for every SDK module your workflows import. +- Run `npm install`. +- Run `npx output update --cli` to align the direct SDK packages. +- Run your project type checker or test suite. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4af37fb1..2c0331e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -356,27 +356,6 @@ importers: specifier: 'workspace:' version: link:../llm - sdk/framework: - dependencies: - '@outputai/cli': - specifier: 'workspace:' - version: link:../cli - '@outputai/core': - specifier: 'workspace:' - version: link:../core - '@outputai/credentials': - specifier: 'workspace:' - version: link:../credentials - '@outputai/evals': - specifier: 'workspace:' - version: link:../evals - '@outputai/http': - specifier: 'workspace:' - version: link:../http - '@outputai/llm': - specifier: 'workspace:' - version: link:../llm - sdk/http: dependencies: '@outputai/core': diff --git a/sdk/cli/scripts/set_sdk_version.js b/sdk/cli/scripts/set_sdk_version.js index 50c6fcd2..da188e46 100755 --- a/sdk/cli/scripts/set_sdk_version.js +++ b/sdk/cli/scripts/set_sdk_version.js @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * Writes the given version into framework_version.json and docker-compose-dev.yml + * Writes the given version into sdk_version.json and docker-compose-dev.yml * (OUTPUT_API_VERSION default). Pass version as first arg, e.g. v0.1.0 or 0.1.0. */ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; @@ -21,10 +21,10 @@ const __dirname = dirname( fileURLToPath( import.meta.url ) ); const src = join( __dirname, '..', 'src' ); const dockerComposeFilename = join( src, 'assets', 'docker', 'docker-compose-dev.yml' ); -const frameworkVersionFilename = join( src, 'generated', 'framework_version.json' ); +const sdkVersionFilename = join( src, 'generated', 'sdk_version.json' ); -if ( !existsSync( frameworkVersionFilename ) ) { - console.error( `Missing file ${frameworkVersionFilename}` ); +if ( !existsSync( sdkVersionFilename ) ) { + console.error( `Missing file ${sdkVersionFilename}` ); process.exit( 1 ); } if ( !existsSync( dockerComposeFilename ) ) { @@ -45,9 +45,9 @@ if ( !apiVarMatcher.test( dockerComposeContent ) ) { // overwrites the docker-compose-dev.yml with the new env var version writeFileSync( dockerComposeFilename, dockerComposeContent.replace( apiVarMatcher, `\${${apiVarName}:-${version}}` ) ); -console.log( `- Rewriting ${frameworkVersionFilename}` ); +console.log( `- Rewriting ${sdkVersionFilename}` ); -// overwrites the framework_version file with the new version -writeFileSync( frameworkVersionFilename, JSON.stringify( { framework: version }, null, 2 ) + '\n' ); +// overwrites the SDK version file with the new version +writeFileSync( sdkVersionFilename, JSON.stringify( { sdk: version }, null, 2 ) + '\n' ); console.log( '\x1b[0;32m%s\x1b[0m', '- Rewrite completed' ); diff --git a/sdk/cli/scripts/set_sdk_version.test.js b/sdk/cli/scripts/set_sdk_version.test.js index cbe88946..59148333 100644 --- a/sdk/cli/scripts/set_sdk_version.test.js +++ b/sdk/cli/scripts/set_sdk_version.test.js @@ -28,11 +28,11 @@ function layoutPaths( root ) { root, scriptPath: join( scriptsDir, 'set_sdk_version.js' ), dockerPath: join( src, 'assets', 'docker', 'docker-compose-dev.yml' ), - frameworkPath: join( src, 'generated', 'framework_version.json' ) + sdkPath: join( src, 'generated', 'sdk_version.json' ) }; } -function createFixture( root, { dockerContent, frameworkContent } ) { +function createFixture( root, { dockerContent, sdkContent } ) { const paths = layoutPaths( root ); mkdirSync( join( root, 'scripts' ), { recursive: true } ); mkdirSync( join( root, 'src', 'assets', 'docker' ), { recursive: true } ); @@ -41,8 +41,8 @@ function createFixture( root, { dockerContent, frameworkContent } ) { copyFileSync( scriptSource, paths.scriptPath ); writeFileSync( paths.dockerPath, dockerContent ?? '', 'utf-8' ); writeFileSync( - paths.frameworkPath, - frameworkContent ?? JSON.stringify( { framework: '0.0.0' }, null, 2 ) + '\n', + paths.sdkPath, + sdkContent ?? JSON.stringify( { sdk: '0.0.0' }, null, 2 ) + '\n', 'utf-8' ); return paths; @@ -89,7 +89,7 @@ describe( 'set_sdk_version.js', () => { expect( result.stderr ).toContain( 'Missing version argument' ); } ); - it( 'exits 1 when framework_version.json is missing', () => { + it( 'exits 1 when sdk_version.json is missing', () => { fixtureState.tempRoot = mkdtempSync( join( __dirname, 'set_sdk_version-fixture-' ) ); const paths = layoutPaths( fixtureState.tempRoot ); mkdirSync( join( fixtureState.tempRoot, 'scripts' ), { recursive: true } ); @@ -97,12 +97,12 @@ describe( 'set_sdk_version.js', () => { writeFileSync( join( fixtureState.tempRoot, 'package.json' ), JSON.stringify( { type: 'module' } ) ); copyFileSync( scriptSource, paths.scriptPath ); writeFileSync( paths.dockerPath, 'image: x:${OUTPUT_API_VERSION:-1.0.0}\n', 'utf-8' ); - // no framework_version.json + // no sdk_version.json const result = runScript( fixtureState.tempRoot, [ '1.0.0' ] ); expect( result.status ).toBe( 1 ); expect( result.stderr ).toContain( 'Missing file' ); - expect( result.stderr ).toContain( 'framework_version.json' ); + expect( result.stderr ).toContain( 'sdk_version.json' ); } ); it( 'exits 1 when docker-compose-dev.yml is missing', () => { @@ -112,7 +112,7 @@ describe( 'set_sdk_version.js', () => { mkdirSync( join( fixtureState.tempRoot, 'src', 'generated' ), { recursive: true } ); writeFileSync( join( fixtureState.tempRoot, 'package.json' ), JSON.stringify( { type: 'module' } ) ); copyFileSync( scriptSource, paths.scriptPath ); - writeFileSync( paths.frameworkPath, '{}\n', 'utf-8' ); + writeFileSync( paths.sdkPath, '{}\n', 'utf-8' ); // no docker-compose-dev.yml const result = runScript( fixtureState.tempRoot, [ '1.0.0' ] ); @@ -129,7 +129,7 @@ describe( 'set_sdk_version.js', () => { expect( result.stderr ).toContain( 'does not have the OUTPUT_API_VERSION env var reference' ); } ); - it( 'replaces one occurrence and writes framework_version.json', () => { + it( 'replaces one occurrence and writes sdk_version.json', () => { fixtureState.tempRoot = mkdtempSync( join( __dirname, 'set_sdk_version-fixture-' ) ); const dockerContent = 'image: outputai/api:${OUTPUT_API_VERSION:-2.0.0}\n'; createFixture( fixtureState.tempRoot, { dockerContent } ); @@ -145,8 +145,8 @@ describe( 'set_sdk_version.js', () => { expect( readFileSync( paths.dockerPath, 'utf-8' ) ).toBe( 'image: outputai/api:${OUTPUT_API_VERSION:-3.4.5}\n' ); - expect( readFileSync( paths.frameworkPath, 'utf-8' ) ).toBe( - JSON.stringify( { framework: '3.4.5' }, null, 2 ) + '\n' + expect( readFileSync( paths.sdkPath, 'utf-8' ) ).toBe( + JSON.stringify( { sdk: '3.4.5' }, null, 2 ) + '\n' ); } ); @@ -187,6 +187,6 @@ describe( 'set_sdk_version.js', () => { expect( readFileSync( paths.dockerPath, 'utf-8' ) ).toContain( '${OUTPUT_API_VERSION:-1.0.0}' ); - expect( readFileSync( paths.frameworkPath, 'utf-8' ) ).toContain( '"framework": "1.0.0"' ); + expect( readFileSync( paths.sdkPath, 'utf-8' ) ).toContain( '"sdk": "1.0.0"' ); } ); } ); diff --git a/sdk/cli/src/commands/dev/index.spec.ts b/sdk/cli/src/commands/dev/index.spec.ts index 385926f3..d703e6d1 100644 --- a/sdk/cli/src/commands/dev/index.spec.ts +++ b/sdk/cli/src/commands/dev/index.spec.ts @@ -6,6 +6,7 @@ import type { ChildProcess } from 'node:child_process'; import { render } from 'ink'; import * as dockerService from '#services/docker.js'; import * as codingAgentsService from '#services/coding_agents.js'; +import * as npmUpdateService from '#services/npm_update_service.js'; import * as portAvailability from '#utils/port_availability.js'; import Dev from './index.js'; @@ -13,6 +14,11 @@ vi.mock( '#services/coding_agents.js', () => ( { ensureClaudePlugin: vi.fn().mockResolvedValue( undefined ) } ) ); +vi.mock( '#services/npm_update_service.js', () => ( { + DEPRECATED_WRAPPER_PACKAGE_WARNING: 'deprecated wrapper warning', + hasDeprecatedWrapperPackage: vi.fn().mockResolvedValue( false ) +} ) ); + vi.mock( '#utils/port_availability.js', () => ( { findUnavailablePorts: vi.fn().mockResolvedValue( [] ) } ) ); @@ -111,6 +117,8 @@ describe( 'dev command', () => { vi.mocked( fs ).access.mockResolvedValue( undefined ); // By default, ensureClaudePlugin succeeds vi.mocked( codingAgentsService.ensureClaudePlugin ).mockResolvedValue( undefined ); + // By default, project does not use the deprecated wrapper package. + vi.mocked( npmUpdateService.hasDeprecatedWrapperPackage ).mockResolvedValue( false ); } ); afterEach( () => { @@ -203,6 +211,28 @@ describe( 'dev command', () => { } ); describe( 'Claude plugin update', () => { + it( 'should warn when the deprecated wrapper package is present', async () => { + vi.mocked( npmUpdateService.hasDeprecatedWrapperPackage ).mockResolvedValue( true ); + + const cmd = new Dev( [], {} as any ); + cmd.log = vi.fn() as any; + cmd.warn = vi.fn() as any; + cmd.error = vi.fn() as any; + + Object.defineProperty( cmd, 'parse', { + value: vi.fn().mockResolvedValue( { flags: { 'compose-file': undefined, 'image-pull-policy': 'always' }, args: {} } ), + configurable: true + } ); + + const runPromise = cmd.run(); + + await new Promise( resolve => setImmediate( resolve ) ); + + expect( cmd.warn ).toHaveBeenCalledWith( 'deprecated wrapper warning' ); + + runPromise.catch( () => {} ); + } ); + it( 'should call ensureClaudePlugin on startup', async () => { const cmd = new Dev( [], {} as any ); cmd.log = vi.fn() as any; diff --git a/sdk/cli/src/commands/dev/index.ts b/sdk/cli/src/commands/dev/index.ts index 8eddb87c..2bf33e90 100644 --- a/sdk/cli/src/commands/dev/index.ts +++ b/sdk/cli/src/commands/dev/index.ts @@ -17,6 +17,10 @@ import { getErrorMessage } from '#utils/error_utils.js'; import { formatPortCollisionHint, formatPortCollisionsHint } from '#utils/port_collision.js'; import { findUnavailablePorts } from '#utils/port_availability.js'; import { ensureClaudePlugin } from '#services/coding_agents.js'; +import { + DEPRECATED_WRAPPER_PACKAGE_WARNING, + hasDeprecatedWrapperPackage +} from '#services/npm_update_service.js'; import { DevApp } from '#views/dev/dev_app.js'; import { config } from '#config.js'; @@ -61,9 +65,15 @@ export default class Dev extends Command { async run(): Promise { const { flags } = await this.parse( Dev ); + const projectRoot = process.cwd(); + const hasDeprecatedWrapper = await hasDeprecatedWrapperPackage( projectRoot ); // Ensure Claude plugin is configured (fire-and-forget, silent) - ensureClaudePlugin( process.cwd(), { silent: true } ).catch( () => {} ); + ensureClaudePlugin( projectRoot, { silent: true } ).catch( () => {} ); + + if ( hasDeprecatedWrapper ) { + this.warn( DEPRECATED_WRAPPER_PACKAGE_WARNING ); + } validateDockerEnvironment(); diff --git a/sdk/cli/src/commands/migrate.spec.ts b/sdk/cli/src/commands/migrate.spec.ts new file mode 100644 index 00000000..2fee635c --- /dev/null +++ b/sdk/cli/src/commands/migrate.spec.ts @@ -0,0 +1,45 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import Migrate from './migrate.js'; +import { ensureOutputAISystem } from '#services/coding_agents.js'; +import { invokeMigrate } from '#services/claude_client.js'; +import { + hasDeprecatedWrapperPackage +} from '#services/npm_update_service.js'; + +vi.mock( '#services/coding_agents.js', () => ( { + ensureOutputAISystem: vi.fn() +} ) ); + +vi.mock( '#services/claude_client.js', () => ( { + invokeMigrate: vi.fn() +} ) ); + +vi.mock( '#services/npm_update_service.js', () => ( { + DEPRECATED_WRAPPER_PACKAGE_WARNING: 'deprecated wrapper warning', + hasDeprecatedWrapperPackage: vi.fn() +} ) ); + +describe( 'migrate command', () => { + beforeEach( () => { + vi.clearAllMocks(); + vi.mocked( ensureOutputAISystem ).mockResolvedValue(); + vi.mocked( invokeMigrate ).mockResolvedValue( 'Migration complete' ); + vi.mocked( hasDeprecatedWrapperPackage ).mockResolvedValue( false ); + } ); + + it( 'should warn when the deprecated wrapper package is present', async () => { + vi.mocked( hasDeprecatedWrapperPackage ).mockResolvedValue( true ); + + const cmd = new Migrate( [], {} as any ); + cmd.log = vi.fn(); + cmd.warn = vi.fn(); + ( cmd as any ).parse = vi.fn().mockResolvedValue( { flags: {}, args: {} } ); + + await cmd.run(); + + expect( cmd.warn ).toHaveBeenCalledWith( 'deprecated wrapper warning' ); + expect( ensureOutputAISystem ).toHaveBeenCalledWith( process.cwd() ); + expect( invokeMigrate ).toHaveBeenCalledWith( '', '', undefined ); + } ); +} ); diff --git a/sdk/cli/src/commands/migrate.ts b/sdk/cli/src/commands/migrate.ts index 80b13967..3647e18f 100644 --- a/sdk/cli/src/commands/migrate.ts +++ b/sdk/cli/src/commands/migrate.ts @@ -1,6 +1,10 @@ import { Command, Flags } from '@oclif/core'; import { ensureOutputAISystem } from '#services/coding_agents.js'; import { invokeMigrate } from '#services/claude_client.js'; +import { + DEPRECATED_WRAPPER_PACKAGE_WARNING, + hasDeprecatedWrapperPackage +} from '#services/npm_update_service.js'; export default class Migrate extends Command { static description = @@ -33,6 +37,11 @@ export default class Migrate extends Command { async run(): Promise { const { flags } = await this.parse( Migrate ); const projectRoot = process.cwd(); + const hasDeprecatedWrapper = await hasDeprecatedWrapperPackage( projectRoot ); + + if ( hasDeprecatedWrapper ) { + this.warn( DEPRECATED_WRAPPER_PACKAGE_WARNING ); + } this.log( 'Checking .outputai directory structure...' ); await ensureOutputAISystem( projectRoot ); diff --git a/sdk/cli/src/commands/update.spec.ts b/sdk/cli/src/commands/update.spec.ts index 22a096e9..04908082 100644 --- a/sdk/cli/src/commands/update.spec.ts +++ b/sdk/cli/src/commands/update.spec.ts @@ -4,22 +4,27 @@ import Update from './update.js'; import { fetchLatestVersion, getGlobalInstalledVersion, + hasDeprecatedWrapperPackage, getLocalInstalledPackages, getLocalInstalledVersion, updateGlobal, updateLocal, + updateLocalPackages, isOutdated } from '#services/npm_update_service.js'; import { ensureClaudePlugin } from '#services/coding_agents.js'; import { confirm } from '#utils/prompt.js'; vi.mock( '#services/npm_update_service.js', () => ( { + DEPRECATED_WRAPPER_PACKAGE_WARNING: 'deprecated wrapper warning', fetchLatestVersion: vi.fn(), getGlobalInstalledVersion: vi.fn(), + hasDeprecatedWrapperPackage: vi.fn(), getLocalInstalledPackages: vi.fn(), getLocalInstalledVersion: vi.fn(), updateGlobal: vi.fn(), updateLocal: vi.fn(), + updateLocalPackages: vi.fn(), isOutdated: vi.fn() } ) ); @@ -46,12 +51,14 @@ describe( 'update command', () => { vi.clearAllMocks(); vi.mocked( fetchLatestVersion ).mockResolvedValue( '1.0.0' ); vi.mocked( getGlobalInstalledVersion ).mockResolvedValue( '0.8.4' ); + vi.mocked( hasDeprecatedWrapperPackage ).mockResolvedValue( false ); vi.mocked( getLocalInstalledPackages ).mockResolvedValue( [] ); vi.mocked( getLocalInstalledVersion ).mockResolvedValue( null ); vi.mocked( isOutdated ).mockReturnValue( true ); vi.mocked( confirm ).mockResolvedValue( true ); vi.mocked( updateGlobal ).mockResolvedValue(); vi.mocked( updateLocal ).mockResolvedValue(); + vi.mocked( updateLocalPackages ).mockResolvedValue(); vi.mocked( ensureClaudePlugin ).mockResolvedValue(); } ); @@ -198,27 +205,43 @@ describe( 'update command', () => { } ); describe( 'local update', () => { + it( 'should warn when the deprecated wrapper package is present', async () => { + vi.mocked( getGlobalInstalledVersion ).mockResolvedValue( null ); + vi.mocked( hasDeprecatedWrapperPackage ).mockResolvedValue( true ); + + const cmd = createTestCommand( { cli: true } ); + await cmd.run(); + + expect( cmd.warn ).toHaveBeenCalledWith( + 'deprecated wrapper warning' + ); + } ); + it( 'should prompt and update local SDK packages when outdated', async () => { vi.mocked( getGlobalInstalledVersion ).mockResolvedValue( null ); vi.mocked( getLocalInstalledPackages ) .mockResolvedValueOnce( [ - { name: '@outputai/cli', version: '0.8.3' }, - { name: '@outputai/core', version: '0.8.3' }, - { name: '@outputai/http', version: '1.0.0' } + { name: '@outputai/cli', version: '0.8.3', declaredVersion: '0.8.3', dependencyType: 'dependencies' }, + { name: '@outputai/core', version: '0.8.3', declaredVersion: '0.8.3', dependencyType: 'dependencies' }, + { name: '@outputai/http', version: '1.0.0', declaredVersion: '1.0.0', dependencyType: 'dependencies' } ] ) .mockResolvedValueOnce( [ - { name: '@outputai/cli', version: '1.0.0' }, - { name: '@outputai/core', version: '1.0.0' }, - { name: '@outputai/http', version: '1.0.0' } + { name: '@outputai/cli', version: '1.0.0', declaredVersion: '1.0.0', dependencyType: 'dependencies' }, + { name: '@outputai/core', version: '1.0.0', declaredVersion: '1.0.0', dependencyType: 'dependencies' }, + { name: '@outputai/http', version: '1.0.0', declaredVersion: '1.0.0', dependencyType: 'dependencies' } ] ); vi.mocked( isOutdated ).mockImplementation( ( current, latest ) => current !== latest ); const cmd = createTestCommand( { cli: true } ); await cmd.run(); - expect( updateLocal ).toHaveBeenCalledWith( + expect( updateLocalPackages ).toHaveBeenCalledWith( process.cwd(), - [ '@outputai/cli', '@outputai/core', '@outputai/http' ], + [ + { name: '@outputai/cli', version: '0.8.3', declaredVersion: '0.8.3', dependencyType: 'dependencies' }, + { name: '@outputai/core', version: '0.8.3', declaredVersion: '0.8.3', dependencyType: 'dependencies' }, + { name: '@outputai/http', version: '1.0.0', declaredVersion: '1.0.0', dependencyType: 'dependencies' } + ], '1.0.0' ); expect( confirm ).toHaveBeenCalledWith( @@ -229,8 +252,8 @@ describe( 'update command', () => { it( 'should show local SDK packages as up to date', async () => { vi.mocked( getGlobalInstalledVersion ).mockResolvedValue( null ); vi.mocked( getLocalInstalledPackages ).mockResolvedValue( [ - { name: '@outputai/cli', version: '1.0.0' }, - { name: '@outputai/core', version: '1.0.0' } + { name: '@outputai/cli', version: '1.0.0', declaredVersion: '1.0.0', dependencyType: 'dependencies' }, + { name: '@outputai/core', version: '1.0.0', declaredVersion: '1.0.0', dependencyType: 'dependencies' } ] ); vi.mocked( isOutdated ).mockReturnValue( false ); @@ -241,6 +264,32 @@ describe( 'update command', () => { expect( cmd.log ).toHaveBeenCalledWith( expect.stringContaining( 'up to date' ) ); } ); + it( 'should update declared SDK packages when installed versions are unknown', async () => { + vi.mocked( getGlobalInstalledVersion ).mockResolvedValue( null ); + vi.mocked( getLocalInstalledPackages ) + .mockResolvedValueOnce( [ + { name: '@outputai/cli', version: null, declaredVersion: '0.8.3', dependencyType: 'devDependencies' }, + { name: '@outputai/core', version: null, declaredVersion: '^0.8.0', dependencyType: 'dependencies' } + ] ) + .mockResolvedValueOnce( [ + { name: '@outputai/cli', version: '1.0.0', declaredVersion: '1.0.0', dependencyType: 'devDependencies' }, + { name: '@outputai/core', version: '1.0.0', declaredVersion: '1.0.0', dependencyType: 'dependencies' } + ] ); + + const cmd = createTestCommand( { cli: true } ); + await cmd.run(); + + expect( updateLocalPackages ).toHaveBeenCalledWith( + process.cwd(), + [ + { name: '@outputai/cli', version: null, declaredVersion: '0.8.3', dependencyType: 'devDependencies' }, + { name: '@outputai/core', version: null, declaredVersion: '^0.8.0', dependencyType: 'dependencies' } + ], + '1.0.0' + ); + expect( cmd.log ).toHaveBeenCalledWith( expect.stringContaining( 'declared 0.8.3' ) ); + } ); + it( 'should prompt and update legacy local install when outdated', async () => { vi.mocked( getLocalInstalledVersion ) .mockResolvedValueOnce( '0.8.3' ) diff --git a/sdk/cli/src/commands/update.ts b/sdk/cli/src/commands/update.ts index 6cd0b076..3002cf2d 100644 --- a/sdk/cli/src/commands/update.ts +++ b/sdk/cli/src/commands/update.ts @@ -2,11 +2,14 @@ import { Command, Flags } from '@oclif/core'; import { confirm } from '#utils/prompt.js'; import { fetchLatestVersion, + DEPRECATED_WRAPPER_PACKAGE_WARNING, getGlobalInstalledVersion, + hasDeprecatedWrapperPackage, getLocalInstalledPackages, getLocalInstalledVersion, updateGlobal, updateLocal, + updateLocalPackages, isOutdated, type LocalInstalledPackage } from '#services/npm_update_service.js'; @@ -108,6 +111,12 @@ export default class Update extends Command { private async handleLocalUpdate( latest: string ): Promise { const cwd = process.cwd(); + const hasDeprecatedWrapper = await hasDeprecatedWrapperPackage( cwd ); + + if ( hasDeprecatedWrapper ) { + this.warn( DEPRECATED_WRAPPER_PACKAGE_WARNING ); + } + const localPackages = await getLocalInstalledPackages( cwd ); if ( localPackages.length > 0 ) { @@ -164,7 +173,7 @@ export default class Update extends Command { latest: string, localPackages: LocalInstalledPackage[] ): Promise { - const outdatedPackages = localPackages.filter( pkg => isOutdated( pkg.version, latest ) ); + const outdatedPackages = localPackages.filter( pkg => this.isLocalSdkPackageOutdated( pkg, latest ) ); if ( outdatedPackages.length === 0 ) { this.log( '\nLocal Output SDK packages: up to date' ); @@ -173,8 +182,9 @@ export default class Update extends Command { this.log( '\nLocal Output SDK packages:' ); for ( const pkg of localPackages ) { - const suffix = isOutdated( pkg.version, latest ) ? ` -> v${latest}` : ' (up to date)'; - this.log( ` ${pkg.name}: v${pkg.version}${suffix}` ); + const current = pkg.version ? `v${pkg.version}` : `declared ${pkg.declaredVersion}`; + const suffix = this.isLocalSdkPackageOutdated( pkg, latest ) ? ` -> v${latest}` : ' (up to date)'; + this.log( ` ${pkg.name}: ${current}${suffix}` ); } const shouldUpdate = await confirm( { @@ -185,16 +195,14 @@ export default class Update extends Command { return false; } - const packageNames = localPackages.map( pkg => pkg.name ); - try { - await updateLocal( cwd, packageNames, latest ); + await updateLocalPackages( cwd, localPackages, latest ); const newLocalPackages = await getLocalInstalledPackages( cwd ); if ( newLocalPackages.length > 0 ) { this.log( `\nLocal Output SDK packages updated to v${latest}` ); - const stalePackages = newLocalPackages.filter( pkg => isOutdated( pkg.version, latest ) ); + const stalePackages = newLocalPackages.filter( pkg => pkg.version && isOutdated( pkg.version, latest ) ); if ( stalePackages.length > 0 ) { const staleNames = stalePackages.map( pkg => `${pkg.name}@${pkg.version}` ).join( ', ' ); this.warn( `Some Output SDK packages are still behind v${latest}: ${staleNames}` ); @@ -209,4 +217,8 @@ export default class Update extends Command { return false; } } + + private isLocalSdkPackageOutdated( pkg: LocalInstalledPackage, latest: string ): boolean { + return pkg.version ? isOutdated( pkg.version, latest ) : true; + } } diff --git a/sdk/cli/src/generated/framework_version.json b/sdk/cli/src/generated/framework_version.json deleted file mode 100644 index 5c1a887a..00000000 --- a/sdk/cli/src/generated/framework_version.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "framework": "0.9.0" -} diff --git a/sdk/cli/src/generated/sdk_version.json b/sdk/cli/src/generated/sdk_version.json new file mode 100644 index 00000000..75961742 --- /dev/null +++ b/sdk/cli/src/generated/sdk_version.json @@ -0,0 +1,3 @@ +{ + "sdk": "0.9.0" +} diff --git a/sdk/cli/src/services/fix_package.ts b/sdk/cli/src/services/fix_package.ts index 45cb88ea..db9c47a9 100644 --- a/sdk/cli/src/services/fix_package.ts +++ b/sdk/cli/src/services/fix_package.ts @@ -98,7 +98,7 @@ export function planFix( projectRoot: string ): FixPlan { } const templateRaw = readFileSync( getTemplatesPackageJsonPath(), 'utf-8' ); - const templateVars = { projectName: '', description: '', frameworkVersion: '' }; + const templateVars = { projectName: '', description: '', sdkVersion: '' }; const processed = processTemplate( templateRaw, templateVars ); const templatePkg = parseTemplatePackageJson( processed ); const templateScripts = templatePkg.scripts; diff --git a/sdk/cli/src/services/npm_update_service.spec.ts b/sdk/cli/src/services/npm_update_service.spec.ts index 30e57140..1ff679a0 100644 --- a/sdk/cli/src/services/npm_update_service.spec.ts +++ b/sdk/cli/src/services/npm_update_service.spec.ts @@ -3,9 +3,11 @@ import { EventEmitter } from 'node:events'; import { fetchLatestVersion, getGlobalInstalledVersion, + hasDeprecatedWrapperPackage, getLocalInstalledPackages, getLocalInstalledVersion, updateLocal, + updateLocalPackages, isOutdated } from './npm_update_service.js'; @@ -167,9 +169,9 @@ describe( 'npm_update_service', () => { const result = await getLocalInstalledPackages( '/some/project' ); expect( result ).toEqual( [ - { name: '@outputai/cli', version: '0.8.3' }, - { name: '@outputai/core', version: '0.8.3' }, - { name: '@outputai/llm', version: '0.8.3' } + { name: '@outputai/cli', version: '0.8.3', declaredVersion: '0.8.3', dependencyType: 'dependencies' }, + { name: '@outputai/core', version: '0.8.3', declaredVersion: '0.8.3', dependencyType: 'dependencies' }, + { name: '@outputai/llm', version: '0.8.3', declaredVersion: '0.8.3', dependencyType: 'devDependencies' } ] ); expect( mockExecFile ).toHaveBeenCalledWith( 'npm', [ 'ls', '@outputai/cli', '--json' ], { cwd: '/some/project' } @@ -190,6 +192,49 @@ describe( 'npm_update_service', () => { expect( result ).toEqual( [] ); expect( mockExecFile ).not.toHaveBeenCalled(); } ); + + it( 'should return declared packages when npm ls cannot read installed versions', async () => { + mockReadFile.mockResolvedValue( JSON.stringify( { + dependencies: { + '@outputai/cli': '0.8.3', + '@outputai/core': '^0.8.0' + } + } ) ); + mockExecFile.mockRejectedValue( new Error( 'missing node_modules' ) ); + + const result = await getLocalInstalledPackages( '/some/project' ); + + expect( result ).toEqual( [ + { name: '@outputai/cli', version: null, declaredVersion: '0.8.3', dependencyType: 'dependencies' }, + { name: '@outputai/core', version: null, declaredVersion: '^0.8.0', dependencyType: 'dependencies' } + ] ); + } ); + } ); + + describe( 'hasDeprecatedWrapperPackage', () => { + it( 'should return true when package.json declares the deprecated wrapper package', async () => { + mockReadFile.mockResolvedValue( JSON.stringify( { + dependencies: { + '@outputai/output': '0.9.0' + } + } ) ); + + const result = await hasDeprecatedWrapperPackage( '/some/project' ); + + expect( result ).toBe( true ); + } ); + + it( 'should return false when package.json does not declare the deprecated wrapper package', async () => { + mockReadFile.mockResolvedValue( JSON.stringify( { + dependencies: { + '@outputai/core': '0.9.0' + } + } ) ); + + const result = await hasDeprecatedWrapperPackage( '/some/project' ); + + expect( result ).toBe( false ); + } ); } ); describe( 'updateLocal', () => { @@ -209,6 +254,32 @@ describe( 'npm_update_service', () => { } ); } ); + describe( 'updateLocalPackages', () => { + it( 'should preserve dependency sections when updating packages', async () => { + mockSpawn.mockImplementation( () => { + const proc = new EventEmitter(); + queueMicrotask( () => proc.emit( 'close', 0 ) ); + return proc; + } ); + + await updateLocalPackages( '/some/project', [ + { name: '@outputai/core', version: '0.8.3', declaredVersion: '0.8.3', dependencyType: 'dependencies' }, + { name: '@outputai/cli', version: '0.8.3', declaredVersion: '0.8.3', dependencyType: 'devDependencies' } + ], '1.0.0' ); + + expect( mockSpawn ).toHaveBeenCalledWith( + 'npm', + [ 'install', '--ignore-scripts', '--save-exact', '@outputai/core@1.0.0' ], + { cwd: '/some/project', stdio: 'inherit' } + ); + expect( mockSpawn ).toHaveBeenCalledWith( + 'npm', + [ 'install', '--ignore-scripts', '--save-dev', '--save-exact', '@outputai/cli@1.0.0' ], + { cwd: '/some/project', stdio: 'inherit' } + ); + } ); + } ); + describe( 'isOutdated', () => { it( 'should return true when latest is newer', () => { expect( isOutdated( '0.8.4', '0.8.5' ) ).toBe( true ); diff --git a/sdk/cli/src/services/npm_update_service.ts b/sdk/cli/src/services/npm_update_service.ts index d4943523..9654d1ff 100644 --- a/sdk/cli/src/services/npm_update_service.ts +++ b/sdk/cli/src/services/npm_update_service.ts @@ -10,6 +10,10 @@ const execFile = promisify( execFileCb ); const debug = debugFactory( 'output-cli:npm-update' ); const CLI_PACKAGE_NAME = packageJson.name; +const DEPRECATED_WRAPPER_PACKAGE_NAME = '@outputai/output'; +export const DEPRECATED_WRAPPER_PACKAGE_WARNING = + 'This project depends on the deprecated @outputai/output wrapper package, which can hide transitive SDK modules ' + + 'as ghost dependencies. Run `output migrate`, re-scaffold the project, or install the Output SDK packages you use directly.'; const REGISTRY_URL = 'https://registry.npmjs.org'; const REGISTRY_TIMEOUT_MS = 5000; export const LOCAL_SDK_PACKAGE_NAMES = [ @@ -25,7 +29,14 @@ export const LOCAL_SDK_PACKAGE_NAMES = [ export interface LocalInstalledPackage { name: string; + version: string | null; + declaredVersion: string; + dependencyType: 'dependencies' | 'devDependencies'; +} + +interface DirectOutputDependency { version: string; + dependencyType: LocalInstalledPackage['dependencyType']; } function findVersionInTree( deps: Record | undefined, packageName: string ): string | null { @@ -56,20 +67,27 @@ function parseNpmLsVersion( output: string, packageName: string ): string | null } } -async function readDirectOutputDependencies( cwd: string ): Promise> { +async function readDirectOutputDependencies( cwd: string ): Promise> { try { const raw = await readFile( path.join( cwd, 'package.json' ), 'utf-8' ); const pkg = JSON.parse( raw ) as { dependencies?: Record; devDependencies?: Record; }; - return new Set( [ - ...Object.keys( pkg.dependencies ?? {} ), - ...Object.keys( pkg.devDependencies ?? {} ) - ] ); + const directDeps = new Map(); + + for ( const [ name, version ] of Object.entries( pkg.dependencies ?? {} ) ) { + directDeps.set( name, { version, dependencyType: 'dependencies' } ); + } + + for ( const [ name, version ] of Object.entries( pkg.devDependencies ?? {} ) ) { + directDeps.set( name, { version, dependencyType: 'devDependencies' } ); + } + + return directDeps; } catch ( error ) { debug( 'Failed to read local package.json: %O', error ); - return new Set(); + return new Map(); } } @@ -114,20 +132,27 @@ export async function getLocalInstalledPackages( cwd: string ): Promise directDeps.has( name ) ); - const versions = await Promise.all( + return Promise.all( packageNames.map( async name => { + const declaredDependency = directDeps.get( name ); + const declaredVersion = declaredDependency?.version ?? ''; + const dependencyType = declaredDependency?.dependencyType ?? 'dependencies'; + try { const { stdout } = await execFile( 'npm', [ 'ls', name, '--json' ], { cwd } ); const version = parseNpmLsVersion( stdout, name ); - return version ? { name, version } : null; + return { name, version, declaredVersion, dependencyType }; } catch ( error ) { debug( 'Failed to get local version for %s: %O', name, error ); - return null; + return { name, version: null, declaredVersion, dependencyType }; } } ) ); +} - return versions.filter( ( item ): item is LocalInstalledPackage => item !== null ); +export async function hasDeprecatedWrapperPackage( cwd: string ): Promise { + const directDeps = await readDirectOutputDependencies( cwd ); + return directDeps.has( DEPRECATED_WRAPPER_PACKAGE_NAME ); } function spawnInherit( command: string, args: string[], cwd?: string ): Promise { @@ -154,6 +179,19 @@ export async function updateLocal( cwd: string, packageNames: string[], version: await spawnInherit( 'npm', [ 'install', '--ignore-scripts', '--save-exact', ...packages ], cwd ); } +export async function updateLocalPackages( cwd: string, packages: LocalInstalledPackage[], version: string ): Promise { + const dependencies = packages.filter( pkg => pkg.dependencyType === 'dependencies' ).map( pkg => `${pkg.name}@${version}` ); + const devDependencies = packages.filter( pkg => pkg.dependencyType === 'devDependencies' ).map( pkg => `${pkg.name}@${version}` ); + + if ( dependencies.length > 0 ) { + await spawnInherit( 'npm', [ 'install', '--ignore-scripts', '--save-exact', ...dependencies ], cwd ); + } + + if ( devDependencies.length > 0 ) { + await spawnInherit( 'npm', [ 'install', '--ignore-scripts', '--save-dev', '--save-exact', ...devDependencies ], cwd ); + } +} + export function isOutdated( current: string, latest: string ): boolean { return semver.lt( current, latest ); } diff --git a/sdk/cli/src/services/project_scaffold.spec.ts b/sdk/cli/src/services/project_scaffold.spec.ts index bf7b0ace..6295d97b 100644 --- a/sdk/cli/src/services/project_scaffold.spec.ts +++ b/sdk/cli/src/services/project_scaffold.spec.ts @@ -2,10 +2,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { getProjectConfig, checkDependencies, createSigintHandler } from './project_scaffold.js'; import { UserCancelledError } from '#types/errors.js'; -// Mock the framework version utility -vi.mock( '#utils/framework_version.js', () => ( { - getFrameworkVersion: vi.fn().mockResolvedValue( { - framework: '0.1.1' +// Mock the SDK version utility +vi.mock( '#utils/sdk_version.js', () => ( { + getSdkVersion: vi.fn().mockResolvedValue( { + sdk: '0.1.1' } ) } ) ); diff --git a/sdk/cli/src/services/project_scaffold.ts b/sdk/cli/src/services/project_scaffold.ts index bdde7bc5..457dd65b 100644 --- a/sdk/cli/src/services/project_scaffold.ts +++ b/sdk/cli/src/services/project_scaffold.ts @@ -14,7 +14,7 @@ import { executeCommand, executeCommandWithMessages } from '#utils/process.js'; -import { getFrameworkVersion } from '#utils/framework_version.js'; +import { getSdkVersion } from '#utils/sdk_version.js'; import { getErrorMessage, getErrorCode } from '#utils/error_utils.js'; import { isDockerInstalled } from '#services/docker.js'; import { isClaudeCliAvailable } from '#utils/claude.js'; @@ -130,14 +130,14 @@ async function scaffoldProjectFiles( const __dirname = path.dirname( __filename ); const templatesDir = path.join( __dirname, '..', 'templates', 'project' ); - // Get framework version for dynamic template injection - const frameworkVersion = await getFrameworkVersion(); + // Get SDK version for dynamic template injection + const sdkVersion = await getSdkVersion(); const templateVars = { projectName: kebabCase( projectName ), ProjectName: pascalCase( projectName ), description: description || `An Output.ai workflow for ${projectName}`, - frameworkVersion: frameworkVersion.framework + sdkVersion: sdkVersion.sdk }; const templateFiles = await getTemplateFiles( templatesDir ); diff --git a/sdk/cli/src/templates/project/package.json.template b/sdk/cli/src/templates/project/package.json.template index 2e171210..888cd4b1 100644 --- a/sdk/cli/src/templates/project/package.json.template +++ b/sdk/cli/src/templates/project/package.json.template @@ -14,12 +14,12 @@ "output:dev": "output dev" }, "dependencies": { - "@outputai/core": "{{frameworkVersion}}", - "@outputai/cli": "{{frameworkVersion}}", - "@outputai/http": "{{frameworkVersion}}", - "@outputai/llm": "{{frameworkVersion}}", - "@outputai/credentials": "{{frameworkVersion}}", - "@outputai/evals": "{{frameworkVersion}}" + "@outputai/core": "{{sdkVersion}}", + "@outputai/cli": "{{sdkVersion}}", + "@outputai/http": "{{sdkVersion}}", + "@outputai/llm": "{{sdkVersion}}", + "@outputai/credentials": "{{sdkVersion}}", + "@outputai/evals": "{{sdkVersion}}" }, "devDependencies": { "@types/node": "24.5.2", diff --git a/sdk/cli/src/utils/framework_version.spec.ts b/sdk/cli/src/utils/framework_version.spec.ts deleted file mode 100644 index 285a7b2e..00000000 --- a/sdk/cli/src/utils/framework_version.spec.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { getFrameworkVersion } from './framework_version.js'; - -describe( 'getFrameworkVersion', () => { - it( 'should return the framework version', async () => { - const version = await getFrameworkVersion(); - - expect( version ).toHaveProperty( 'framework' ); - } ); - - it( 'should return version in semver format', async () => { - const version = await getFrameworkVersion(); - const semverPattern = /^\d+\.\d+\.\d+$/; - - expect( version.framework ).toMatch( semverPattern ); - } ); -} ); diff --git a/sdk/cli/src/utils/framework_version.ts b/sdk/cli/src/utils/framework_version.ts deleted file mode 100644 index 56ee1f55..00000000 --- a/sdk/cli/src/utils/framework_version.ts +++ /dev/null @@ -1,9 +0,0 @@ -import frameworkVersion from '../generated/framework_version.json' with { type: 'json' }; - -export interface FrameworkVersion { - framework: string; -} - -export async function getFrameworkVersion(): Promise { - return frameworkVersion as FrameworkVersion; -} diff --git a/sdk/cli/src/utils/sdk_version.spec.ts b/sdk/cli/src/utils/sdk_version.spec.ts new file mode 100644 index 00000000..973a3c35 --- /dev/null +++ b/sdk/cli/src/utils/sdk_version.spec.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { getSdkVersion } from './sdk_version.js'; + +describe( 'getSdkVersion', () => { + it( 'should return the SDK version', async () => { + const version = await getSdkVersion(); + + expect( version ).toHaveProperty( 'sdk' ); + } ); + + it( 'should return version in semver format', async () => { + const version = await getSdkVersion(); + const semverPattern = /^\d+\.\d+\.\d+$/; + + expect( version.sdk ).toMatch( semverPattern ); + } ); +} ); diff --git a/sdk/cli/src/utils/sdk_version.ts b/sdk/cli/src/utils/sdk_version.ts new file mode 100644 index 00000000..6d39aca2 --- /dev/null +++ b/sdk/cli/src/utils/sdk_version.ts @@ -0,0 +1,9 @@ +import sdkVersion from '../generated/sdk_version.json' with { type: 'json' }; + +export interface SdkVersion { + sdk: string; +} + +export async function getSdkVersion(): Promise { + return sdkVersion as SdkVersion; +} From 8724fb69bdf277e698d75b4c8d6b34faabad6e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9fano=20Zanata?= <221676+szanata@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:27:01 +0100 Subject: [PATCH 4/4] More improvements, dropping npm ls --- sdk/cli/src/commands/update.spec.ts | 106 +++++++++++++----- sdk/cli/src/commands/update.ts | 26 ++--- .../src/services/npm_update_service.spec.ts | 67 +++++------ sdk/cli/src/services/npm_update_service.ts | 67 +++++------ 4 files changed, 158 insertions(+), 108 deletions(-) diff --git a/sdk/cli/src/commands/update.spec.ts b/sdk/cli/src/commands/update.spec.ts index 04908082..afda40cf 100644 --- a/sdk/cli/src/commands/update.spec.ts +++ b/sdk/cli/src/commands/update.spec.ts @@ -5,12 +5,13 @@ import { fetchLatestVersion, getGlobalInstalledVersion, hasDeprecatedWrapperPackage, - getLocalInstalledPackages, + getLocalSdkPackages, getLocalInstalledVersion, updateGlobal, updateLocal, updateLocalPackages, - isOutdated + isOutdated, + isPackageJsonVersionOutdated } from '#services/npm_update_service.js'; import { ensureClaudePlugin } from '#services/coding_agents.js'; import { confirm } from '#utils/prompt.js'; @@ -20,12 +21,13 @@ vi.mock( '#services/npm_update_service.js', () => ( { fetchLatestVersion: vi.fn(), getGlobalInstalledVersion: vi.fn(), hasDeprecatedWrapperPackage: vi.fn(), - getLocalInstalledPackages: vi.fn(), + getLocalSdkPackages: vi.fn(), getLocalInstalledVersion: vi.fn(), updateGlobal: vi.fn(), updateLocal: vi.fn(), updateLocalPackages: vi.fn(), - isOutdated: vi.fn() + isOutdated: vi.fn(), + isPackageJsonVersionOutdated: vi.fn() } ) ); vi.mock( '#services/coding_agents.js', () => ( { @@ -52,9 +54,10 @@ describe( 'update command', () => { vi.mocked( fetchLatestVersion ).mockResolvedValue( '1.0.0' ); vi.mocked( getGlobalInstalledVersion ).mockResolvedValue( '0.8.4' ); vi.mocked( hasDeprecatedWrapperPackage ).mockResolvedValue( false ); - vi.mocked( getLocalInstalledPackages ).mockResolvedValue( [] ); + vi.mocked( getLocalSdkPackages ).mockResolvedValue( [] ); vi.mocked( getLocalInstalledVersion ).mockResolvedValue( null ); vi.mocked( isOutdated ).mockReturnValue( true ); + vi.mocked( isPackageJsonVersionOutdated ).mockReturnValue( true ); vi.mocked( confirm ).mockResolvedValue( true ); vi.mocked( updateGlobal ).mockResolvedValue(); vi.mocked( updateLocal ).mockResolvedValue(); @@ -219,18 +222,18 @@ describe( 'update command', () => { it( 'should prompt and update local SDK packages when outdated', async () => { vi.mocked( getGlobalInstalledVersion ).mockResolvedValue( null ); - vi.mocked( getLocalInstalledPackages ) + vi.mocked( getLocalSdkPackages ) .mockResolvedValueOnce( [ - { name: '@outputai/cli', version: '0.8.3', declaredVersion: '0.8.3', dependencyType: 'dependencies' }, - { name: '@outputai/core', version: '0.8.3', declaredVersion: '0.8.3', dependencyType: 'dependencies' }, - { name: '@outputai/http', version: '1.0.0', declaredVersion: '1.0.0', dependencyType: 'dependencies' } + { name: '@outputai/cli', version: '0.8.3', dependencyType: 'dependencies' }, + { name: '@outputai/core', version: '0.8.3', dependencyType: 'dependencies' }, + { name: '@outputai/http', version: '1.0.0', dependencyType: 'dependencies' } ] ) .mockResolvedValueOnce( [ - { name: '@outputai/cli', version: '1.0.0', declaredVersion: '1.0.0', dependencyType: 'dependencies' }, - { name: '@outputai/core', version: '1.0.0', declaredVersion: '1.0.0', dependencyType: 'dependencies' }, - { name: '@outputai/http', version: '1.0.0', declaredVersion: '1.0.0', dependencyType: 'dependencies' } + { name: '@outputai/cli', version: '1.0.0', dependencyType: 'dependencies' }, + { name: '@outputai/core', version: '1.0.0', dependencyType: 'dependencies' }, + { name: '@outputai/http', version: '1.0.0', dependencyType: 'dependencies' } ] ); - vi.mocked( isOutdated ).mockImplementation( ( current, latest ) => current !== latest ); + vi.mocked( isPackageJsonVersionOutdated ).mockImplementation( ( current, latest ) => current !== latest ); const cmd = createTestCommand( { cli: true } ); await cmd.run(); @@ -238,9 +241,8 @@ describe( 'update command', () => { expect( updateLocalPackages ).toHaveBeenCalledWith( process.cwd(), [ - { name: '@outputai/cli', version: '0.8.3', declaredVersion: '0.8.3', dependencyType: 'dependencies' }, - { name: '@outputai/core', version: '0.8.3', declaredVersion: '0.8.3', dependencyType: 'dependencies' }, - { name: '@outputai/http', version: '1.0.0', declaredVersion: '1.0.0', dependencyType: 'dependencies' } + { name: '@outputai/cli', version: '0.8.3', dependencyType: 'dependencies' }, + { name: '@outputai/core', version: '0.8.3', dependencyType: 'dependencies' } ], '1.0.0' ); @@ -249,13 +251,43 @@ describe( 'update command', () => { ); } ); + it( 'should update behind SDK packages when CLI is already current', async () => { + vi.mocked( getGlobalInstalledVersion ).mockResolvedValue( null ); + vi.mocked( getLocalSdkPackages ) + .mockResolvedValueOnce( [ + { name: '@outputai/cli', version: '1.0.0', dependencyType: 'dependencies' }, + { name: '@outputai/core', version: '1.0.0', dependencyType: 'dependencies' }, + { name: '@outputai/http', version: '0.8.1', dependencyType: 'dependencies' }, + { name: '@outputai/llm', version: '0.8.1', dependencyType: 'dependencies' } + ] ) + .mockResolvedValueOnce( [ + { name: '@outputai/cli', version: '1.0.0', dependencyType: 'dependencies' }, + { name: '@outputai/core', version: '1.0.0', dependencyType: 'dependencies' }, + { name: '@outputai/http', version: '1.0.0', dependencyType: 'dependencies' }, + { name: '@outputai/llm', version: '1.0.0', dependencyType: 'dependencies' } + ] ); + vi.mocked( isPackageJsonVersionOutdated ).mockImplementation( ( current, latest ) => current !== latest ); + + const cmd = createTestCommand( { cli: true } ); + await cmd.run(); + + expect( updateLocalPackages ).toHaveBeenCalledWith( + process.cwd(), + [ + { name: '@outputai/http', version: '0.8.1', dependencyType: 'dependencies' }, + { name: '@outputai/llm', version: '0.8.1', dependencyType: 'dependencies' } + ], + '1.0.0' + ); + } ); + it( 'should show local SDK packages as up to date', async () => { vi.mocked( getGlobalInstalledVersion ).mockResolvedValue( null ); - vi.mocked( getLocalInstalledPackages ).mockResolvedValue( [ - { name: '@outputai/cli', version: '1.0.0', declaredVersion: '1.0.0', dependencyType: 'dependencies' }, - { name: '@outputai/core', version: '1.0.0', declaredVersion: '1.0.0', dependencyType: 'dependencies' } + vi.mocked( getLocalSdkPackages ).mockResolvedValue( [ + { name: '@outputai/cli', version: '1.0.0', dependencyType: 'dependencies' }, + { name: '@outputai/core', version: '1.0.0', dependencyType: 'dependencies' } ] ); - vi.mocked( isOutdated ).mockReturnValue( false ); + vi.mocked( isPackageJsonVersionOutdated ).mockReturnValue( false ); const cmd = createTestCommand( { cli: true } ); await cmd.run(); @@ -264,16 +296,32 @@ describe( 'update command', () => { expect( cmd.log ).toHaveBeenCalledWith( expect.stringContaining( 'up to date' ) ); } ); - it( 'should update declared SDK packages when installed versions are unknown', async () => { + it( 'should show package.json packages as up to date when their versions satisfy latest', async () => { + vi.mocked( getGlobalInstalledVersion ).mockResolvedValue( null ); + vi.mocked( getLocalSdkPackages ).mockResolvedValue( [ + { name: '@outputai/cli', version: '1.0.0', dependencyType: 'dependencies' }, + { name: '@outputai/core', version: '1.0.0', dependencyType: 'dependencies' }, + { name: '@outputai/llm', version: '^1.0.0', dependencyType: 'dependencies' } + ] ); + vi.mocked( isPackageJsonVersionOutdated ).mockReturnValue( false ); + + const cmd = createTestCommand( { cli: true } ); + await cmd.run(); + + expect( updateLocalPackages ).not.toHaveBeenCalled(); + expect( cmd.log ).toHaveBeenCalledWith( '\nLocal Output SDK packages: up to date' ); + } ); + + it( 'should update SDK packages when package.json declarations are outdated', async () => { vi.mocked( getGlobalInstalledVersion ).mockResolvedValue( null ); - vi.mocked( getLocalInstalledPackages ) + vi.mocked( getLocalSdkPackages ) .mockResolvedValueOnce( [ - { name: '@outputai/cli', version: null, declaredVersion: '0.8.3', dependencyType: 'devDependencies' }, - { name: '@outputai/core', version: null, declaredVersion: '^0.8.0', dependencyType: 'dependencies' } + { name: '@outputai/cli', version: '0.8.3', dependencyType: 'devDependencies' }, + { name: '@outputai/core', version: '^0.8.0', dependencyType: 'dependencies' } ] ) .mockResolvedValueOnce( [ - { name: '@outputai/cli', version: '1.0.0', declaredVersion: '1.0.0', dependencyType: 'devDependencies' }, - { name: '@outputai/core', version: '1.0.0', declaredVersion: '1.0.0', dependencyType: 'dependencies' } + { name: '@outputai/cli', version: '1.0.0', dependencyType: 'devDependencies' }, + { name: '@outputai/core', version: '1.0.0', dependencyType: 'dependencies' } ] ); const cmd = createTestCommand( { cli: true } ); @@ -282,12 +330,12 @@ describe( 'update command', () => { expect( updateLocalPackages ).toHaveBeenCalledWith( process.cwd(), [ - { name: '@outputai/cli', version: null, declaredVersion: '0.8.3', dependencyType: 'devDependencies' }, - { name: '@outputai/core', version: null, declaredVersion: '^0.8.0', dependencyType: 'dependencies' } + { name: '@outputai/cli', version: '0.8.3', dependencyType: 'devDependencies' }, + { name: '@outputai/core', version: '^0.8.0', dependencyType: 'dependencies' } ], '1.0.0' ); - expect( cmd.log ).toHaveBeenCalledWith( expect.stringContaining( 'declared 0.8.3' ) ); + expect( cmd.log ).toHaveBeenCalledWith( expect.stringContaining( 'package.json 0.8.3' ) ); } ); it( 'should prompt and update legacy local install when outdated', async () => { diff --git a/sdk/cli/src/commands/update.ts b/sdk/cli/src/commands/update.ts index 3002cf2d..7fe7a31a 100644 --- a/sdk/cli/src/commands/update.ts +++ b/sdk/cli/src/commands/update.ts @@ -5,13 +5,14 @@ import { DEPRECATED_WRAPPER_PACKAGE_WARNING, getGlobalInstalledVersion, hasDeprecatedWrapperPackage, - getLocalInstalledPackages, + getLocalSdkPackages, getLocalInstalledVersion, updateGlobal, updateLocal, updateLocalPackages, isOutdated, - type LocalInstalledPackage + isPackageJsonVersionOutdated, + type LocalSdkPackage } from '#services/npm_update_service.js'; import { ensureClaudePlugin } from '#services/coding_agents.js'; import { getErrorMessage } from '#utils/error_utils.js'; @@ -117,7 +118,7 @@ export default class Update extends Command { this.warn( DEPRECATED_WRAPPER_PACKAGE_WARNING ); } - const localPackages = await getLocalInstalledPackages( cwd ); + const localPackages = await getLocalSdkPackages( cwd ); if ( localPackages.length > 0 ) { return this.handleLocalSdkPackageUpdate( cwd, latest, localPackages ); @@ -171,9 +172,10 @@ export default class Update extends Command { private async handleLocalSdkPackageUpdate( cwd: string, latest: string, - localPackages: LocalInstalledPackage[] + localPackages: LocalSdkPackage[] ): Promise { - const outdatedPackages = localPackages.filter( pkg => this.isLocalSdkPackageOutdated( pkg, latest ) ); + const outdatedPackages = localPackages.filter( pkg => isPackageJsonVersionOutdated( pkg.version, latest ) ); + const outdatedPackageNames = new Set( outdatedPackages.map( pkg => pkg.name ) ); if ( outdatedPackages.length === 0 ) { this.log( '\nLocal Output SDK packages: up to date' ); @@ -182,8 +184,8 @@ export default class Update extends Command { this.log( '\nLocal Output SDK packages:' ); for ( const pkg of localPackages ) { - const current = pkg.version ? `v${pkg.version}` : `declared ${pkg.declaredVersion}`; - const suffix = this.isLocalSdkPackageOutdated( pkg, latest ) ? ` -> v${latest}` : ' (up to date)'; + const current = `package.json ${pkg.version}`; + const suffix = outdatedPackageNames.has( pkg.name ) ? ` -> v${latest}` : ' (up to date)'; this.log( ` ${pkg.name}: ${current}${suffix}` ); } @@ -196,13 +198,13 @@ export default class Update extends Command { } try { - await updateLocalPackages( cwd, localPackages, latest ); - const newLocalPackages = await getLocalInstalledPackages( cwd ); + await updateLocalPackages( cwd, outdatedPackages, latest ); + const newLocalPackages = await getLocalSdkPackages( cwd ); if ( newLocalPackages.length > 0 ) { this.log( `\nLocal Output SDK packages updated to v${latest}` ); - const stalePackages = newLocalPackages.filter( pkg => pkg.version && isOutdated( pkg.version, latest ) ); + const stalePackages = newLocalPackages.filter( pkg => isPackageJsonVersionOutdated( pkg.version, latest ) ); if ( stalePackages.length > 0 ) { const staleNames = stalePackages.map( pkg => `${pkg.name}@${pkg.version}` ).join( ', ' ); this.warn( `Some Output SDK packages are still behind v${latest}: ${staleNames}` ); @@ -217,8 +219,4 @@ export default class Update extends Command { return false; } } - - private isLocalSdkPackageOutdated( pkg: LocalInstalledPackage, latest: string ): boolean { - return pkg.version ? isOutdated( pkg.version, latest ) : true; - } } diff --git a/sdk/cli/src/services/npm_update_service.spec.ts b/sdk/cli/src/services/npm_update_service.spec.ts index 1ff679a0..343380ac 100644 --- a/sdk/cli/src/services/npm_update_service.spec.ts +++ b/sdk/cli/src/services/npm_update_service.spec.ts @@ -4,11 +4,12 @@ import { fetchLatestVersion, getGlobalInstalledVersion, hasDeprecatedWrapperPackage, - getLocalInstalledPackages, + getLocalSdkPackages, getLocalInstalledVersion, updateLocal, updateLocalPackages, - isOutdated + isOutdated, + isPackageJsonVersionOutdated } from './npm_update_service.js'; const { mockExecFile, mockReadFile, mockSpawn } = vi.hoisted( () => ( { @@ -145,8 +146,8 @@ describe( 'npm_update_service', () => { } ); } ); - describe( 'getLocalInstalledPackages', () => { - it( 'should return directly installed Output SDK package versions', async () => { + describe( 'getLocalSdkPackages', () => { + it( 'should return directly declared Output SDK packages from package.json', async () => { mockReadFile.mockResolvedValue( JSON.stringify( { dependencies: { '@outputai/cli': '0.8.3', @@ -157,43 +158,27 @@ describe( 'npm_update_service', () => { '@outputai/llm': '0.8.3' } } ) ); - mockExecFile.mockImplementation( async ( _command, args ) => { - const packageName = args[1]; - return { - stdout: JSON.stringify( { - dependencies: { [packageName]: { version: '0.8.3' } } - } ) - }; - } ); - const result = await getLocalInstalledPackages( '/some/project' ); + const result = await getLocalSdkPackages( '/some/project' ); expect( result ).toEqual( [ - { name: '@outputai/cli', version: '0.8.3', declaredVersion: '0.8.3', dependencyType: 'dependencies' }, - { name: '@outputai/core', version: '0.8.3', declaredVersion: '0.8.3', dependencyType: 'dependencies' }, - { name: '@outputai/llm', version: '0.8.3', declaredVersion: '0.8.3', dependencyType: 'devDependencies' } + { name: '@outputai/cli', version: '0.8.3', dependencyType: 'dependencies' }, + { name: '@outputai/core', version: '0.8.3', dependencyType: 'dependencies' }, + { name: '@outputai/llm', version: '0.8.3', dependencyType: 'devDependencies' } ] ); - expect( mockExecFile ).toHaveBeenCalledWith( - 'npm', [ 'ls', '@outputai/cli', '--json' ], { cwd: '/some/project' } - ); - expect( mockExecFile ).toHaveBeenCalledWith( - 'npm', [ 'ls', '@outputai/core', '--json' ], { cwd: '/some/project' } - ); - expect( mockExecFile ).toHaveBeenCalledWith( - 'npm', [ 'ls', '@outputai/llm', '--json' ], { cwd: '/some/project' } - ); + expect( mockExecFile ).not.toHaveBeenCalled(); } ); it( 'should return an empty list when package.json cannot be read', async () => { mockReadFile.mockRejectedValue( new Error( 'missing package.json' ) ); - const result = await getLocalInstalledPackages( '/some/project' ); + const result = await getLocalSdkPackages( '/some/project' ); expect( result ).toEqual( [] ); expect( mockExecFile ).not.toHaveBeenCalled(); } ); - it( 'should return declared packages when npm ls cannot read installed versions', async () => { + it( 'should not require installed node_modules to return package.json packages', async () => { mockReadFile.mockResolvedValue( JSON.stringify( { dependencies: { '@outputai/cli': '0.8.3', @@ -202,12 +187,13 @@ describe( 'npm_update_service', () => { } ) ); mockExecFile.mockRejectedValue( new Error( 'missing node_modules' ) ); - const result = await getLocalInstalledPackages( '/some/project' ); + const result = await getLocalSdkPackages( '/some/project' ); expect( result ).toEqual( [ - { name: '@outputai/cli', version: null, declaredVersion: '0.8.3', dependencyType: 'dependencies' }, - { name: '@outputai/core', version: null, declaredVersion: '^0.8.0', dependencyType: 'dependencies' } + { name: '@outputai/cli', version: '0.8.3', dependencyType: 'dependencies' }, + { name: '@outputai/core', version: '^0.8.0', dependencyType: 'dependencies' } ] ); + expect( mockExecFile ).not.toHaveBeenCalled(); } ); } ); @@ -263,8 +249,8 @@ describe( 'npm_update_service', () => { } ); await updateLocalPackages( '/some/project', [ - { name: '@outputai/core', version: '0.8.3', declaredVersion: '0.8.3', dependencyType: 'dependencies' }, - { name: '@outputai/cli', version: '0.8.3', declaredVersion: '0.8.3', dependencyType: 'devDependencies' } + { name: '@outputai/core', version: '0.8.3', dependencyType: 'dependencies' }, + { name: '@outputai/cli', version: '0.8.3', dependencyType: 'devDependencies' } ], '1.0.0' ); expect( mockSpawn ).toHaveBeenCalledWith( @@ -302,4 +288,21 @@ describe( 'npm_update_service', () => { expect( isOutdated( '1.0.0', '1.0.0-beta.1' ) ).toBe( false ); } ); } ); + + describe( 'isPackageJsonVersionOutdated', () => { + it( 'should return false when package.json version satisfies latest', () => { + expect( isPackageJsonVersionOutdated( '1.0.0', '1.0.0' ) ).toBe( false ); + expect( isPackageJsonVersionOutdated( '^1.0.0', '1.0.5' ) ).toBe( false ); + } ); + + it( 'should return true when package.json version does not satisfy latest', () => { + expect( isPackageJsonVersionOutdated( '0.8.1', '0.9.0' ) ).toBe( true ); + expect( isPackageJsonVersionOutdated( '^0.8.0', '0.9.0' ) ).toBe( true ); + } ); + + it( 'should return false when package.json version is not a semver range', () => { + expect( isPackageJsonVersionOutdated( 'workspace:', '1.0.0' ) ).toBe( false ); + expect( isPackageJsonVersionOutdated( 'file:../sdk/cli', '1.0.0' ) ).toBe( false ); + } ); + } ); } ); diff --git a/sdk/cli/src/services/npm_update_service.ts b/sdk/cli/src/services/npm_update_service.ts index 9654d1ff..b3d464da 100644 --- a/sdk/cli/src/services/npm_update_service.ts +++ b/sdk/cli/src/services/npm_update_service.ts @@ -27,16 +27,15 @@ export const LOCAL_SDK_PACKAGE_NAMES = [ /* eslint-disable @typescript-eslint/no-explicit-any */ -export interface LocalInstalledPackage { +export interface LocalSdkPackage { name: string; - version: string | null; - declaredVersion: string; + version: string; dependencyType: 'dependencies' | 'devDependencies'; } -interface DirectOutputDependency { +interface PackageJsonDependency { version: string; - dependencyType: LocalInstalledPackage['dependencyType']; + dependencyType: LocalSdkPackage['dependencyType']; } function findVersionInTree( deps: Record | undefined, packageName: string ): string | null { @@ -67,24 +66,24 @@ function parseNpmLsVersion( output: string, packageName: string ): string | null } } -async function readDirectOutputDependencies( cwd: string ): Promise> { +async function readPackageJsonDependencies( cwd: string ): Promise> { try { const raw = await readFile( path.join( cwd, 'package.json' ), 'utf-8' ); const pkg = JSON.parse( raw ) as { dependencies?: Record; devDependencies?: Record; }; - const directDeps = new Map(); + const packageJsonDeps = new Map(); for ( const [ name, version ] of Object.entries( pkg.dependencies ?? {} ) ) { - directDeps.set( name, { version, dependencyType: 'dependencies' } ); + packageJsonDeps.set( name, { version, dependencyType: 'dependencies' } ); } for ( const [ name, version ] of Object.entries( pkg.devDependencies ?? {} ) ) { - directDeps.set( name, { version, dependencyType: 'devDependencies' } ); + packageJsonDeps.set( name, { version, dependencyType: 'devDependencies' } ); } - return directDeps; + return packageJsonDeps; } catch ( error ) { debug( 'Failed to read local package.json: %O', error ); return new Map(); @@ -128,31 +127,19 @@ export async function getLocalInstalledVersion( cwd: string ): Promise { - const directDeps = await readDirectOutputDependencies( cwd ); - const packageNames: string[] = LOCAL_SDK_PACKAGE_NAMES.filter( name => directDeps.has( name ) ); +export async function getLocalSdkPackages( cwd: string ): Promise { + const packageJsonDeps = await readPackageJsonDependencies( cwd ); - return Promise.all( - packageNames.map( async name => { - const declaredDependency = directDeps.get( name ); - const declaredVersion = declaredDependency?.version ?? ''; - const dependencyType = declaredDependency?.dependencyType ?? 'dependencies'; + return LOCAL_SDK_PACKAGE_NAMES.flatMap( name => { + const dependency = packageJsonDeps.get( name ); - try { - const { stdout } = await execFile( 'npm', [ 'ls', name, '--json' ], { cwd } ); - const version = parseNpmLsVersion( stdout, name ); - return { name, version, declaredVersion, dependencyType }; - } catch ( error ) { - debug( 'Failed to get local version for %s: %O', name, error ); - return { name, version: null, declaredVersion, dependencyType }; - } - } ) - ); + return dependency ? [ { name, ...dependency } ] : []; + } ); } export async function hasDeprecatedWrapperPackage( cwd: string ): Promise { - const directDeps = await readDirectOutputDependencies( cwd ); - return directDeps.has( DEPRECATED_WRAPPER_PACKAGE_NAME ); + const packageJsonDeps = await readPackageJsonDependencies( cwd ); + return packageJsonDeps.has( DEPRECATED_WRAPPER_PACKAGE_NAME ); } function spawnInherit( command: string, args: string[], cwd?: string ): Promise { @@ -179,9 +166,13 @@ export async function updateLocal( cwd: string, packageNames: string[], version: await spawnInherit( 'npm', [ 'install', '--ignore-scripts', '--save-exact', ...packages ], cwd ); } -export async function updateLocalPackages( cwd: string, packages: LocalInstalledPackage[], version: string ): Promise { - const dependencies = packages.filter( pkg => pkg.dependencyType === 'dependencies' ).map( pkg => `${pkg.name}@${version}` ); - const devDependencies = packages.filter( pkg => pkg.dependencyType === 'devDependencies' ).map( pkg => `${pkg.name}@${version}` ); +export async function updateLocalPackages( cwd: string, packages: LocalSdkPackage[], version: string ): Promise { + const dependencies = packages + .filter( pkg => pkg.dependencyType === 'dependencies' ) + .map( pkg => `${pkg.name}@${version}` ); + const devDependencies = packages + .filter( pkg => pkg.dependencyType === 'devDependencies' ) + .map( pkg => `${pkg.name}@${version}` ); if ( dependencies.length > 0 ) { await spawnInherit( 'npm', [ 'install', '--ignore-scripts', '--save-exact', ...dependencies ], cwd ); @@ -195,3 +186,13 @@ export async function updateLocalPackages( cwd: string, packages: LocalInstalled export function isOutdated( current: string, latest: string ): boolean { return semver.lt( current, latest ); } + +export function isPackageJsonVersionOutdated( version: string, latest: string ): boolean { + const range = semver.validRange( version, { includePrerelease: true } ); + + if ( !range ) { + return false; + } + + return !semver.satisfies( latest, range, { includePrerelease: true } ); +}