From fce1cb1528aee62fb794b1fd66d3ea4d46ddcc5f Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 24 Sep 2026 12:05:16 -0700 Subject: [PATCH 1/2] fix(cli): support version flags Session-Id: 01a0d4c3-fc7d-7882-9e9e-342059219272 --- packages/sdk/src/cli.ts | 5 ++++ packages/sdk/src/package-version.ts | 23 +++++++++++++++++ packages/sdk/src/relay-cli.ts | 25 +----------------- packages/sdk/tests/bin.test.ts | 27 ++++++++++++++++++++ packages/sdk/tests/relay-cli-surface.test.ts | 13 +++++++++- 5 files changed, 68 insertions(+), 25 deletions(-) create mode 100644 packages/sdk/src/package-version.ts diff --git a/packages/sdk/src/cli.ts b/packages/sdk/src/cli.ts index 181b593a9..9a37bbf24 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -54,6 +54,7 @@ import { resolveObserverLinkEnv, type MintObserverOptions, } from './observer-link.js'; +import { packageVersion } from './package-version.js'; export type { CheckInputDiagnostic, CheckReport } from './cli/check.js'; @@ -202,6 +203,10 @@ export async function runCli( io: CliIo = PROCESS_IO, options: RunCliOptions = {}, ): Promise { + if (args.length === 1 && (args[0] === '--version' || args[0] === '-V')) { + io.stdout(packageVersion()); + return 0; + } if (args.length === 1 && (args[0] === '--help' || args[0] === '-h')) { io.stdout(USAGE); return 0; diff --git a/packages/sdk/src/package-version.ts b/packages/sdk/src/package-version.ts new file mode 100644 index 000000000..92afb7db5 --- /dev/null +++ b/packages/sdk/src/package-version.ts @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs'; + +/** + * Read the installed SDK package version from the manifest beside this module. + * + * The same relative URL works from `src/` during development and `dist/` in a + * built or npm-installed package. Reading the manifest at runtime keeps the + * CLI output tied to the bytes that are actually installed instead of a + * hardcoded release value that can drift between package files. + */ +export function packageVersion(): string { + try { + const manifest: unknown = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), + ); + const version = (manifest as { version?: unknown }).version; + return typeof version === 'string' && version.length > 0 ? version : '0.0.0'; + } catch { + // Version display is cosmetic. Keep the CLI usable if a host strips the + // manifest from an otherwise runnable package artifact. + return '0.0.0'; + } +} diff --git a/packages/sdk/src/relay-cli.ts b/packages/sdk/src/relay-cli.ts index 93b0ef68b..86d732837 100644 --- a/packages/sdk/src/relay-cli.ts +++ b/packages/sdk/src/relay-cli.ts @@ -1,7 +1,6 @@ -import { readFileSync } from 'node:fs'; - import { runCli } from './cli.js'; import { CLI_VERBS, CLI_VERB_NAMES, type CliCommandSpec } from './cli-commands.js'; +import { packageVersion } from './package-version.js'; /** * The `@relayflows/sdk/relay-cli` entrypoint: a mountable CLI surface. @@ -56,28 +55,6 @@ function toCommandSpec(verb: CliCommandSpec & { variants?: unknown }): CliComman return spec; } -/** - * Read this package's version from its own manifest. - * - * Resolved from `import.meta.url` rather than imported, because `package.json` - * sits outside `rootDir` and a hardcoded literal would silently go stale at the - * next release. `src/` and `dist/` are both one level below the manifest, so - * the same relative path is correct before and after a build. - */ -function packageVersion(): string { - try { - const manifest: unknown = JSON.parse( - readFileSync(new URL('../package.json', import.meta.url), 'utf8'), - ); - const version = (manifest as { version?: unknown }).version; - return typeof version === 'string' && version.length > 0 ? version : '0.0.0'; - } catch { - // A surface that cannot read its own manifest is still perfectly runnable; - // refusing to mount over a cosmetic field would be the worse failure. - return '0.0.0'; - } -} - /** * Build the relayflows CLI surface. * diff --git a/packages/sdk/tests/bin.test.ts b/packages/sdk/tests/bin.test.ts index bb36ba6c3..74e1f1214 100644 --- a/packages/sdk/tests/bin.test.ts +++ b/packages/sdk/tests/bin.test.ts @@ -15,6 +15,7 @@ import { afterEach, describe, expect, it } from 'vitest'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); const SDK = join(ROOT, 'packages', 'sdk'); const BUILT_CLI = join(SDK, 'dist', 'cli.js'); +const SDK_VERSION = (JSON.parse(readFileSync(join(SDK, 'package.json'), 'utf8')) as { version: string }).version; const PREFLIGHT = join(ROOT, 'testdata', 'preflight'); const temporaryDirectories: string[] = []; @@ -57,6 +58,32 @@ describe('built flows binary', () => { expect(statSync(BUILT_CLI).mode & 0o111).not.toBe(0); }); + it.each(['--version', '-V'])('prints the installed SDK version for %s', (flag) => { + const result = spawnSync(process.execPath, [BUILT_CLI, flag], { + cwd: ROOT, + encoding: 'utf8', + env: process.env, + }); + + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe(`${SDK_VERSION}\n`); + expect(result.stderr).toBe(''); + }); + + it('keeps version flags strict when extra arguments are supplied', () => { + const result = spawnSync(process.execPath, [BUILT_CLI, '--version', 'extra'], { + cwd: ROOT, + encoding: 'utf8', + env: process.env, + }); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(2); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('REFUSED [invalid_invocation]'); + }); + it('refuses through a symlink to the built artifact', () => { const entry = join(temporaryDirectory(), 'flows'); symlinkSync(BUILT_CLI, entry); diff --git a/packages/sdk/tests/relay-cli-surface.test.ts b/packages/sdk/tests/relay-cli-surface.test.ts index 018feb6b8..02bc782a8 100644 --- a/packages/sdk/tests/relay-cli-surface.test.ts +++ b/packages/sdk/tests/relay-cli-surface.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -15,6 +15,7 @@ import { CLI_VERBS, type CliCommandSpec, type CliOptionSpec, type CliVerbSpec } import { parseCliArgs, runCli, type ParsedArgs } from '../src/cli.js'; const temporaryDirectories: string[] = []; +const SDK_VERSION = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version; afterEach(() => { for (const directory of temporaryDirectories.splice(0)) { @@ -38,6 +39,16 @@ function capture(): RelayCliIo & { out: string; err: string } { return sink; } +it.each(['--version', '-V'])('routes %s through the mounted surface', async (flag) => { + const io = capture(); + + const surface = createRelayCliSurface(); + await expect(surface.run([flag], io)).resolves.toBe(0); + expect(surface.version).toBe(SDK_VERSION); + expect(io.out).toBe(`${SDK_VERSION}\n`); + expect(io.err).toBe(''); +}); + const DIGEST = `hello@sha256:${'a'.repeat(64)}`; const BUNDLE_DIR = `dist/flows/${DIGEST}`; const RUN_ID = '01JABCDEFGHJKMNPQRSTVWXYZ0'; From 5413fba3d90c4c6d60f1474c2819ebd09f3b92d7 Mon Sep 17 00:00:00 2001 From: khaliqgant Date: Thu, 24 Sep 2026 12:15:53 -0700 Subject: [PATCH 2/2] fix(cli): embed version in standalone binary Session-Id: 01a0d4c3-fc7d-7882-9e9e-342059219272 --- packages/sdk/src/cli.ts | 8 +++++++- packages/sdk/tests/bin.test.ts | 21 +++++++++++++++++++++ scripts/build-standalone-cli.mjs | 3 ++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/cli.ts b/packages/sdk/src/cli.ts index 9a37bbf24..878c2c081 100644 --- a/packages/sdk/src/cli.ts +++ b/packages/sdk/src/cli.ts @@ -162,6 +162,12 @@ const PROCESS_IO: CliIo = { /** Optional knobs for an embedded caller. `bin/flows.js` passes none. */ export interface RunCliOptions { + /** + * Version to print for a self-contained executable that cannot read the + * installed package manifest. Normal package entrypoints leave this unset. + */ + version?: string; + /** * Cancellation for the long-running verbs (`run --cloud`, `check --watch`, * `serve-webhook`, `hn-monitor start`, `tick start`). @@ -204,7 +210,7 @@ export async function runCli( options: RunCliOptions = {}, ): Promise { if (args.length === 1 && (args[0] === '--version' || args[0] === '-V')) { - io.stdout(packageVersion()); + io.stdout(options.version ?? packageVersion()); return 0; } if (args.length === 1 && (args[0] === '--help' || args[0] === '-h')) { diff --git a/packages/sdk/tests/bin.test.ts b/packages/sdk/tests/bin.test.ts index 74e1f1214..2ba7088e0 100644 --- a/packages/sdk/tests/bin.test.ts +++ b/packages/sdk/tests/bin.test.ts @@ -15,6 +15,7 @@ import { afterEach, describe, expect, it } from 'vitest'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); const SDK = join(ROOT, 'packages', 'sdk'); const BUILT_CLI = join(SDK, 'dist', 'cli.js'); +const STANDALONE_BUILDER = join(ROOT, 'scripts', 'build-standalone-cli.mjs'); const SDK_VERSION = (JSON.parse(readFileSync(join(SDK, 'package.json'), 'utf8')) as { version: string }).version; const PREFLIGHT = join(ROOT, 'testdata', 'preflight'); const temporaryDirectories: string[] = []; @@ -84,6 +85,26 @@ describe('built flows binary', () => { expect(result.stderr).toContain('REFUSED [invalid_invocation]'); }); + it.each(['--version', '-V'])('prints the installed SDK version from the standalone binary for %s', (flag) => { + const directory = temporaryDirectory(); + const standalone = join(directory, 'flows'); + const built = spawnSync(process.execPath, [STANDALONE_BUILDER, + process.platform === 'darwin' ? 'bun-darwin-arm64' : 'bun-linux-x64', standalone], { + cwd: ROOT, + encoding: 'utf8', + timeout: 120_000, + env: { ...process.env, FLOWS_BUILD_BUN: process.env['FLOWS_BUILD_BUN'] ?? 'bun' }, + }); + expect(built.error).toBeUndefined(); + expect(built.status, built.stderr + built.stdout).toBe(0); + + const result = spawnSync(standalone, [flag], { cwd: ROOT, encoding: 'utf8', env: process.env }); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe(`${SDK_VERSION}\n`); + expect(result.stderr).toBe(''); + }); + it('refuses through a symlink to the built artifact', () => { const entry = join(temporaryDirectory(), 'flows'); symlinkSync(BUILT_CLI, entry); diff --git a/scripts/build-standalone-cli.mjs b/scripts/build-standalone-cli.mjs index 73c0b2db2..fad1e7177 100644 --- a/scripts/build-standalone-cli.mjs +++ b/scripts/build-standalone-cli.mjs @@ -11,6 +11,7 @@ if (!/^bun-(linux-x64|darwin-arm64)$/.test(target ?? '') || !outfile) { throw new Error('usage: build-standalone-cli.mjs '); } const stage = await mkdtemp(join(tmpdir(), 'flows-standalone-')); +const sdkVersion = JSON.parse(await readFile(join(root, 'packages/sdk/package.json'), 'utf8')).version; function bun(args) { const result = spawnSync(process.env.FLOWS_BUILD_BUN ?? 'bun', args, { cwd: root, stdio: 'inherit' }); if (result.error || result.status !== 0) throw new Error('standalone CLI build failed'); @@ -23,7 +24,7 @@ try { import { installAuthoredNodeSource } from ${JSON.stringify(join(root, 'packages/sdk/src/authored-node-runner.ts'))}; import { runCli } from ${JSON.stringify(join(root, 'packages/sdk/src/cli.ts'))}; installAuthoredNodeSource(${JSON.stringify(await readFile(payload, 'utf8'))}); -process.exitCode = await runCli(process.argv.slice(2)); +process.exitCode = await runCli(process.argv.slice(2), undefined, { version: ${JSON.stringify(sdkVersion)} }); `); bun(['build', entry, '--compile', '--target='+target, '--outfile='+resolve(outfile), '--env=disable', '--no-compile-autoload-dotenv', '--no-compile-autoload-bunfig']);