Skip to content
Merged
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
11 changes: 11 additions & 0 deletions packages/sdk/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -161,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`).
Expand Down Expand Up @@ -202,6 +209,10 @@ export async function runCli(
io: CliIo = PROCESS_IO,
options: RunCliOptions = {},
): Promise<CliExitCode> {
if (args.length === 1 && (args[0] === '--version' || args[0] === '-V')) {
io.stdout(options.version ?? packageVersion());
return 0;
}
if (args.length === 1 && (args[0] === '--help' || args[0] === '-h')) {
io.stdout(USAGE);
return 0;
Expand Down
23 changes: 23 additions & 0 deletions packages/sdk/src/package-version.ts
Original file line number Diff line number Diff line change
@@ -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'),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Standalone executable reports placeholder version

For Bun-compiled flows, packageVersion() cannot find the SDK manifest beside its bundled module. Both version flags print 0.0.0 instead of the release version.

Learn more

The standalone build imports runCli into a temporary entrypoint, then compiles that entrypoint as a single executable. The compiled module does not have the SDK package manifest at the relative URL used by packageVersion(). The failed read reaches the placeholder fallback, so the new flags report 0.0.0 in standalone artifacts even though the installed Node CLI reports the correct version. The release workflow builds standalone binaries for both supported platforms.

Example: Compile the standalone executable into packages/runtime-linux-x64/bin/flows and invoke flows --version. The SDK package version is 2.0.31, but the manifest read fails and stdout contains 0.0.0.

Recommended fix: Embed the SDK version at standalone build time and pass it to runCli for version output, while retaining the manifest read for source and Node-installed packages. Add an assertion for both version flags against a built standalone executable.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5413fba. The standalone builder now reads packages/sdk/package.json at build time and passes the embedded version to runCli. Added bin.test.ts coverage that compiles the standalone executable and asserts both --version and -V return 2.0.31 with rc 0 and empty stderr. The Linux artifact CI is green on this head.

);
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';
}
}
25 changes: 1 addition & 24 deletions packages/sdk/src/relay-cli.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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.
*
Expand Down
48 changes: 48 additions & 0 deletions packages/sdk/tests/bin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ 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[] = [];

Expand Down Expand Up @@ -57,6 +59,52 @@ 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.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);
Expand Down
13 changes: 12 additions & 1 deletion packages/sdk/tests/relay-cli-surface.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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)) {
Expand All @@ -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';
Expand Down
3 changes: 2 additions & 1 deletion scripts/build-standalone-cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ if (!/^bun-(linux-x64|darwin-arm64)$/.test(target ?? '') || !outfile) {
throw new Error('usage: build-standalone-cli.mjs <bun-linux-x64|bun-darwin-arm64> <outfile>');
}
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');
Expand All @@ -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']);
Expand Down
Loading