Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 0 additions & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
48 changes: 47 additions & 1 deletion sdk/cli/src/commands/update.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Update from './update.js';
import {
fetchLatestVersion,
getGlobalInstalledVersion,
getLocalInstalledPackages,
getLocalInstalledVersion,
updateGlobal,
updateLocal,
Expand All @@ -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(),
Expand Down Expand Up @@ -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 );
Expand Down Expand Up @@ -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' );
Expand Down
69 changes: 64 additions & 5 deletions sdk/cli/src/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -49,7 +51,7 @@ export default class Update extends Command {
this.error( 'Could not fetch the latest version from npm. Check your network connection.' );
}

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 );
Expand Down Expand Up @@ -106,6 +108,12 @@ export default class Update extends Command {

private async handleLocalUpdate( latest: string ): Promise<boolean> {
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 ) {
Expand All @@ -128,16 +136,16 @@ export default class Update extends Command {
}

try {
await updateLocal( cwd );
await updateLocal( cwd, [ '@outputai/cli' ], latest );
const newLocalVersion = await getLocalInstalledVersion( cwd );

if ( newLocalVersion ) {
this.log( `\nLocal install updated to v${newLocalVersion}` );

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 {
Expand All @@ -150,4 +158,55 @@ export default class Update extends Command {
return false;
}
}

private async handleLocalSdkPackageUpdate(
cwd: string,
latest: string,
localPackages: LocalInstalledPackage[]
): Promise<boolean> {
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;
}
}
}
84 changes: 81 additions & 3 deletions sdk/cli/src/services/npm_update_service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,37 @@
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', () => ( {
promisify: vi.fn( () => mockExecFile )
} ) );

vi.mock( 'node:fs/promises', () => ( {
readFile: mockReadFile
} ) );

describe( 'npm_update_service', () => {
beforeEach( () => {
vi.clearAllMocks();
mockReadFile.mockResolvedValue( JSON.stringify( { dependencies: {} } ) );
} );

describe( 'fetchLatestVersion', () => {
Expand All @@ -28,7 +40,7 @@ describe( 'npm_update_service', () => {

const result = await fetchLatestVersion();
expect( result ).toBe( '1.2.3' );
expect( mockExecFile ).toHaveBeenCalledWith( 'npm', [ 'view', '@outputai/cli', 'version' ] );
expect( mockExecFile ).toHaveBeenCalledWith( 'npm', [ 'view', '@outputai/core', 'version' ] );
} );

it( 'should return null on empty output', async () => {
Expand Down Expand Up @@ -112,6 +124,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 );
Expand Down
Loading
Loading