@sapiom/cli
Advanced tools
| /** | ||
| * Tests for `sapiom dev`: | ||
| * 1. Command registration — `dev` is registered with the correct flags and | ||
| * allowUnknownOption so future harness flags don't error. | ||
| * 2. Harness resolution — injectable resolver seam: HARNESS_NOT_INSTALLED, | ||
| * HARNESS_BIN_NOT_FOUND, ERR_PACKAGE_PATH_NOT_EXPORTED. | ||
| * 3. Flag passthrough — buildHarnessArgv assembles the correct argv including | ||
| * unknown/extra flags forwarded verbatim. | ||
| * 4. Spawn behaviour — async spawn mocked: HARNESS_SPAWN_FAILED, exit code | ||
| * propagation, SIGTERM forwarding (child.kill asserted). | ||
| * 5. Analytics — command.run fires with flag names only; [dir] never leaks. | ||
| * | ||
| * No real harness server is started in any of these tests. | ||
| */ | ||
| import { EventEmitter } from 'node:events'; | ||
| import { mkdtempSync, writeFileSync } from 'node:fs'; | ||
| import * as os from 'node:os'; | ||
| import * as path from 'node:path'; | ||
| import { Command } from 'commander'; | ||
| import { buildHarnessArgv, resolveHarnessBin, runDev } from '../commands/dev.js'; | ||
| import { registerDevCommand } from '../commands/dev-register.js'; | ||
| import { registerCommandAnalytics } from '../lib/analytics.js'; | ||
| import { action } from '../commands/shared.js'; | ||
| function makeFakeChild(opts = {}) { | ||
| const ee = new EventEmitter(); | ||
| ee.kill = jest.fn(); | ||
| if (opts.errorOnSpawn) { | ||
| setImmediate(() => ee.emit('error', opts.errorOnSpawn)); | ||
| } | ||
| else { | ||
| setImmediate(() => ee.emit('close', opts.code ?? 0, opts.signal ?? null)); | ||
| } | ||
| return ee; | ||
| } | ||
| // --------------------------------------------------------------------------- | ||
| // 1. Command registration | ||
| // --------------------------------------------------------------------------- | ||
| describe('sapiom dev — command registration', () => { | ||
| function buildMinimalProgram() { | ||
| const program = new Command('sapiom'); | ||
| registerDevCommand(program); | ||
| return program; | ||
| } | ||
| it('registers a top-level "dev" command on the program', () => { | ||
| const program = buildMinimalProgram(); | ||
| expect(program.commands.map((c) => c.name())).toContain('dev'); | ||
| }); | ||
| it('accepts --port, --no-open, --no-auth, --no-telemetry, --no-session', () => { | ||
| const program = buildMinimalProgram(); | ||
| const devCmd = program.commands.find((c) => c.name() === 'dev'); | ||
| const optionNames = devCmd.options.map((o) => o.long); | ||
| expect(optionNames).toContain('--port'); | ||
| expect(optionNames).toContain('--no-open'); | ||
| expect(optionNames).toContain('--no-auth'); | ||
| expect(optionNames).toContain('--no-telemetry'); | ||
| expect(optionNames).toContain('--no-session'); | ||
| }); | ||
| it('does not error when an unknown flag is passed', async () => { | ||
| // commander would throw/exit if allowUnknownOption() weren't set. | ||
| const program = new Command('sapiom'); | ||
| registerDevCommand(program); | ||
| // Override the action after registration with a safe stub so runDev isn't called. | ||
| const devCmd = program.commands.find((c) => c.name() === 'dev'); | ||
| devCmd.action(async () => { }); | ||
| await expect(program.parseAsync(['dev', '--future-harness-flag'], { from: 'user' })).resolves.toBeDefined(); | ||
| }); | ||
| }); | ||
| // --------------------------------------------------------------------------- | ||
| // 2. Harness resolution — injectable resolver | ||
| // --------------------------------------------------------------------------- | ||
| describe('resolveHarnessBin — injectable resolver', () => { | ||
| it('throws HARNESS_NOT_INSTALLED when resolver throws MODULE_NOT_FOUND', () => { | ||
| const err = Object.assign(new Error('Cannot find module'), { code: 'MODULE_NOT_FOUND' }); | ||
| const resolver = { | ||
| resolvePackageJson() { | ||
| throw err; | ||
| }, | ||
| }; | ||
| expect(() => resolveHarnessBin(resolver)).toThrow(expect.objectContaining({ code: 'HARNESS_NOT_INSTALLED' })); | ||
| }); | ||
| it('throws HARNESS_NOT_INSTALLED when resolver throws ERR_PACKAGE_PATH_NOT_EXPORTED', () => { | ||
| const err = Object.assign(new Error('Package path not exported'), { | ||
| code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', | ||
| }); | ||
| const resolver = { | ||
| resolvePackageJson() { | ||
| throw err; | ||
| }, | ||
| }; | ||
| expect(() => resolveHarnessBin(resolver)).toThrow(expect.objectContaining({ code: 'HARNESS_NOT_INSTALLED' })); | ||
| }); | ||
| it('includes an install hint in HARNESS_NOT_INSTALLED', () => { | ||
| const resolver = { | ||
| resolvePackageJson() { | ||
| throw Object.assign(new Error('not found'), { code: 'MODULE_NOT_FOUND' }); | ||
| }, | ||
| }; | ||
| let thrown = null; | ||
| try { | ||
| resolveHarnessBin(resolver); | ||
| } | ||
| catch (e) { | ||
| thrown = e; | ||
| } | ||
| expect(thrown?.hint).toContain('npm i -g @sapiom/harness'); | ||
| }); | ||
| it('throws HARNESS_BIN_NOT_FOUND when package.json has no bin field', () => { | ||
| const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'sapiom-dev-test-')); | ||
| const pkgPath = path.join(tmpDir, 'package.json'); | ||
| writeFileSync(pkgPath, JSON.stringify({ name: '@sapiom/harness', version: '0.1.1' })); | ||
| const resolver = { resolvePackageJson: () => pkgPath }; | ||
| expect(() => resolveHarnessBin(resolver)).toThrow(expect.objectContaining({ code: 'HARNESS_BIN_NOT_FOUND' })); | ||
| }); | ||
| it('throws HARNESS_BIN_NOT_FOUND when bin file does not exist on disk', () => { | ||
| const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'sapiom-dev-test-')); | ||
| const pkgPath = path.join(tmpDir, 'package.json'); | ||
| writeFileSync(pkgPath, JSON.stringify({ bin: { 'sapiom-harness': './dist/cli/bin.js' } })); | ||
| const resolver = { resolvePackageJson: () => pkgPath }; | ||
| expect(() => resolveHarnessBin(resolver)).toThrow(expect.objectContaining({ code: 'HARNESS_BIN_NOT_FOUND' })); | ||
| }); | ||
| }); | ||
| // --------------------------------------------------------------------------- | ||
| // 3. Flag passthrough — argv construction (pure unit test, no process spawn) | ||
| // --------------------------------------------------------------------------- | ||
| describe('buildHarnessArgv', () => { | ||
| it('returns an empty array when no options are given', () => { | ||
| expect(buildHarnessArgv({})).toEqual([]); | ||
| }); | ||
| it('places dir as the first positional argument', () => { | ||
| expect(buildHarnessArgv({ dir: '/my/project' })[0]).toBe('/my/project'); | ||
| }); | ||
| it('appends --port with its value', () => { | ||
| expect(buildHarnessArgv({ port: '4200' })).toEqual(['--port', '4200']); | ||
| }); | ||
| it('appends --no-open when noOpen is true', () => { | ||
| expect(buildHarnessArgv({ noOpen: true })).toContain('--no-open'); | ||
| }); | ||
| it('does NOT append --no-open when noOpen is false or absent', () => { | ||
| expect(buildHarnessArgv({ noOpen: false })).not.toContain('--no-open'); | ||
| expect(buildHarnessArgv({})).not.toContain('--no-open'); | ||
| }); | ||
| it('appends --no-auth when noAuth is true', () => { | ||
| expect(buildHarnessArgv({ noAuth: true })).toContain('--no-auth'); | ||
| }); | ||
| it('appends --no-telemetry when noTelemetry is true', () => { | ||
| expect(buildHarnessArgv({ noTelemetry: true })).toContain('--no-telemetry'); | ||
| }); | ||
| it('appends --no-session when noSession is true', () => { | ||
| expect(buildHarnessArgv({ noSession: true })).toContain('--no-session'); | ||
| }); | ||
| it('builds a full argv with dir and multiple flags', () => { | ||
| const opts = { | ||
| dir: '/workspace/my-app', | ||
| port: '4567', | ||
| noOpen: true, | ||
| noTelemetry: true, | ||
| }; | ||
| expect(buildHarnessArgv(opts)).toEqual(['/workspace/my-app', '--port', '4567', '--no-open', '--no-telemetry']); | ||
| }); | ||
| it('omits dir when not provided, even alongside flags', () => { | ||
| const argv = buildHarnessArgv({ port: '9000', noOpen: true }); | ||
| expect(argv[0]).toBe('--port'); | ||
| }); | ||
| it('appends extraArgs (unknown flags) verbatim after known flags', () => { | ||
| const argv = buildHarnessArgv({ | ||
| port: '4100', | ||
| extraArgs: ['--future-flag', '--debug'], | ||
| }); | ||
| expect(argv).toEqual(['--port', '4100', '--future-flag', '--debug']); | ||
| }); | ||
| it('unknown flags alone reach the child without error', () => { | ||
| expect(buildHarnessArgv({ extraArgs: ['--new-harness-only-flag'] })).toEqual([ | ||
| '--new-harness-only-flag', | ||
| ]); | ||
| }); | ||
| }); | ||
| // --------------------------------------------------------------------------- | ||
| // 4. Spawn behaviour — async spawn mocked | ||
| // --------------------------------------------------------------------------- | ||
| describe('runDev — spawn behaviour', () => { | ||
| let spawnMock; | ||
| let originalExitCode; | ||
| // A resolver that points at a real (fabricated) bin path so resolveHarnessBin passes. | ||
| function makeOkResolver() { | ||
| const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'sapiom-dev-test-')); | ||
| const binPath = path.join(tmpDir, 'bin.js'); | ||
| writeFileSync(binPath, ''); | ||
| const pkgPath = path.join(tmpDir, 'package.json'); | ||
| writeFileSync(pkgPath, JSON.stringify({ bin: { 'sapiom-harness': './bin.js' } })); | ||
| return { resolvePackageJson: () => pkgPath }; | ||
| } | ||
| beforeEach(() => { | ||
| originalExitCode = process.exitCode; | ||
| // eslint-disable-next-line @typescript-eslint/no-var-requires | ||
| spawnMock = jest.spyOn(require('node:child_process'), 'spawn'); | ||
| }); | ||
| afterEach(() => { | ||
| spawnMock.mockRestore(); | ||
| process.exitCode = originalExitCode; | ||
| }); | ||
| it('throws HARNESS_SPAWN_FAILED when the child emits an error', async () => { | ||
| const fakeChild = makeFakeChild({ errorOnSpawn: new Error('ENOENT no such file') }); | ||
| spawnMock.mockReturnValue(fakeChild); | ||
| await expect(runDev(undefined, {}, makeOkResolver())).rejects.toMatchObject({ | ||
| code: 'HARNESS_SPAWN_FAILED', | ||
| }); | ||
| }); | ||
| it('propagates non-zero exit code to process.exitCode', async () => { | ||
| const fakeChild = makeFakeChild({ code: 2 }); | ||
| spawnMock.mockReturnValue(fakeChild); | ||
| await runDev(undefined, {}, makeOkResolver()); | ||
| expect(process.exitCode).toBe(2); | ||
| }); | ||
| it('sets exitCode 0 (unchanged) on clean exit', async () => { | ||
| const fakeChild = makeFakeChild({ code: 0 }); | ||
| spawnMock.mockReturnValue(fakeChild); | ||
| process.exitCode = undefined; | ||
| await runDev(undefined, {}, makeOkResolver()); | ||
| expect(process.exitCode ?? 0).toBe(0); | ||
| }); | ||
| it('sets 128+signum (143) when child exits via SIGTERM', async () => { | ||
| const fakeChild = makeFakeChild({ code: null, signal: 'SIGTERM' }); | ||
| spawnMock.mockReturnValue(fakeChild); | ||
| await runDev(undefined, {}, makeOkResolver()); | ||
| expect(process.exitCode).toBe(143); // 128 + 15 | ||
| }); | ||
| it('forwards SIGTERM to the child and does not throw', async () => { | ||
| let sigTermHandler; | ||
| const origOn = process.on.bind(process); | ||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| const onSpy = jest.spyOn(process, 'on').mockImplementation((event, handler) => { | ||
| if (event === 'SIGTERM') | ||
| sigTermHandler = handler; | ||
| return origOn(event, handler); | ||
| }); | ||
| const fakeChild = makeFakeChild({ code: 0 }); | ||
| spawnMock.mockReturnValue(fakeChild); | ||
| const devPromise = runDev(undefined, {}, makeOkResolver()); | ||
| // Fire SIGTERM before the child closes. | ||
| if (sigTermHandler) | ||
| sigTermHandler(); | ||
| await devPromise; | ||
| expect(fakeChild.kill).toHaveBeenCalledWith('SIGTERM'); | ||
| onSpy.mockRestore(); | ||
| }); | ||
| it('forwards rawArgs verbatim to the child spawn argv', async () => { | ||
| let capturedArgs; | ||
| spawnMock.mockImplementation((_execPath, args) => { | ||
| capturedArgs = args; | ||
| return makeFakeChild({ code: 0 }); | ||
| }); | ||
| const rawArgs = ['/my/project', '--port', '5000', '--future-harness-flag']; | ||
| await runDev(undefined, { rawArgs }, makeOkResolver()); | ||
| // The spawned args are [harnessBin, ...rawArgs]; rawArgs start at index 1. | ||
| expect(capturedArgs?.slice(1)).toEqual(rawArgs); | ||
| }); | ||
| }); | ||
| function recordingTracker() { | ||
| const events = []; | ||
| return { | ||
| events, | ||
| tracker: { | ||
| track(eventType, data) { | ||
| events.push({ eventType, data: data ?? {} }); | ||
| }, | ||
| }, | ||
| }; | ||
| } | ||
| describe('sapiom dev — analytics', () => { | ||
| let stdoutSpy; | ||
| let stderrSpy; | ||
| let originalExitCode; | ||
| beforeEach(() => { | ||
| originalExitCode = process.exitCode; | ||
| stdoutSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); | ||
| stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); | ||
| }); | ||
| afterEach(() => { | ||
| stdoutSpy.mockRestore(); | ||
| stderrSpy.mockRestore(); | ||
| process.exitCode = originalExitCode; | ||
| }); | ||
| function buildDevTestProgram(tracker, act = async () => { }) { | ||
| const program = new Command('sapiom'); | ||
| registerCommandAnalytics(program, () => tracker); | ||
| program | ||
| .command('dev [dir]') | ||
| .option('--port <port>', 'port for the harness server') | ||
| .option('--no-open', 'skip browser open') | ||
| .option('--no-auth', 'skip auth') | ||
| .option('--no-telemetry', 'skip telemetry') | ||
| .option('--no-session', 'skip session') | ||
| .action(action(act)); | ||
| return program; | ||
| } | ||
| it('emits command.run with command path "dev" and exit code 0', async () => { | ||
| const { tracker, events } = recordingTracker(); | ||
| await buildDevTestProgram(tracker).parseAsync(['dev'], { from: 'user' }); | ||
| expect(events).toHaveLength(1); | ||
| expect(events[0].eventType).toBe('command.run'); | ||
| expect(events[0].data.command).toBe('dev'); | ||
| expect(events[0].data.exit_code).toBe(0); | ||
| }); | ||
| it('records --port and --no-open flag names, never their values or the dir positional', async () => { | ||
| const { tracker, events } = recordingTracker(); | ||
| const SECRET_DIR = '/secret/project/path'; | ||
| await buildDevTestProgram(tracker).parseAsync(['dev', SECRET_DIR, '--port', '9999', '--no-open'], { from: 'user' }); | ||
| expect(events[0].data.flags).toEqual(['--port', '--no-open']); | ||
| const serialized = JSON.stringify(events[0].data); | ||
| expect(serialized).not.toContain(SECRET_DIR); | ||
| expect(serialized).not.toContain('/secret'); | ||
| expect(serialized).not.toContain('9999'); | ||
| }); | ||
| it('records no flags when dev is called with only a positional [dir]', async () => { | ||
| const { tracker, events } = recordingTracker(); | ||
| await buildDevTestProgram(tracker).parseAsync(['dev', '/some/dir'], { from: 'user' }); | ||
| expect(events[0].data.flags).toEqual([]); | ||
| expect(JSON.stringify(events[0].data)).not.toContain('/some/dir'); | ||
| }); | ||
| it('records only the flags that were explicitly passed — no defaults', async () => { | ||
| const { tracker, events } = recordingTracker(); | ||
| await buildDevTestProgram(tracker).parseAsync(['dev', '--no-auth', '--no-telemetry'], { | ||
| from: 'user', | ||
| }); | ||
| expect(events[0].data.flags).toEqual(['--no-auth', '--no-telemetry']); | ||
| }); | ||
| }); |
| import { runDev } from './dev.js'; | ||
| import { fail } from '../lib/output.js'; | ||
| /** | ||
| * Register the `sapiom dev [dir]` command. | ||
| * | ||
| * The command spawns the `sapiom-harness` bin with stdio inherited, forwarding | ||
| * SIGTERM/SIGHUP and propagating the child's exit code. The program-level | ||
| * analytics hooks fire automatically via the preAction/postAction mechanism. | ||
| * | ||
| * Privacy: [dir] is a positional argument, not an option, so it never appears | ||
| * in specifiedFlagNames() and never reaches the analytics payload. | ||
| * | ||
| * Unknown flags are allowed via .allowUnknownOption() so that future harness | ||
| * flags (e.g. --some-new-flag) pass through without a CLI update. We forward | ||
| * everything after the "dev" token verbatim to the harness so the harness | ||
| * performs its own full argument parsing. | ||
| */ | ||
| export function registerDevCommand(program) { | ||
| program | ||
| .command('dev [dir]') | ||
| .description('Launch the Sapiom Harness — a local coding environment with MCP pre-wired.') | ||
| .option('--port <port>', 'port for the harness server') | ||
| .option('--no-open', 'skip opening the browser after the server starts') | ||
| .option('--no-auth', 'skip authentication (for offline/dev use)') | ||
| .option('--no-telemetry', 'disable harness telemetry collection') | ||
| .option('--no-session', 'skip creating an initial terminal session on boot') | ||
| // Allow flags not declared above; they pass through to the harness verbatim. | ||
| .allowUnknownOption() | ||
| .allowExcessArguments(true) | ||
| .action(async (dir, _opts) => { | ||
| try { | ||
| // Extract everything after the 'dev' token from the original argv and | ||
| // pass it raw to the harness. This avoids the commander ambiguity where | ||
| // unknown flags in front of [dir] get absorbed as the positional. | ||
| const rawArgs = sliceAfterDev(process.argv); | ||
| await runDev(dir, { rawArgs }); | ||
| } | ||
| catch (err) { | ||
| fail(err); | ||
| } | ||
| }); | ||
| } | ||
| /** | ||
| * Return all argv tokens that follow the first 'dev' token. This covers: | ||
| * node bin.js dev /mydir --port 4200 --future-flag | ||
| * sapiom dev --no-open /mydir | ||
| * The harness receives the same sequence the user typed. | ||
| */ | ||
| function sliceAfterDev(argv) { | ||
| const idx = argv.indexOf('dev'); | ||
| return idx === -1 ? [] : argv.slice(idx + 1); | ||
| } |
| import { spawn } from 'node:child_process'; | ||
| import { existsSync, readFileSync, realpathSync } from 'node:fs'; | ||
| import { createRequire } from 'node:module'; | ||
| import path from 'node:path'; | ||
| import { CliError } from '../lib/output.js'; | ||
| /** | ||
| * A CJS-compatible require bound to the CLI's real on-disk location. | ||
| * | ||
| * - CJS (ts-jest): __filename is defined — bind to this source file directly. | ||
| * - ESM production: __filename is not defined — resolve the REAL path of | ||
| * process.argv[1] before binding. This is the critical step: on a Unix | ||
| * global install, `npm i -g @sapiom/cli` places a symlink at e.g. | ||
| * `/usr/local/bin/sapiom` → `../lib/node_modules/@sapiom/cli/dist/bin.js`. | ||
| * `process.argv[1]` is the symlink path. `createRequire` does NOT follow | ||
| * symlinks internally, so resolution walks from `/usr/local/bin/` and never | ||
| * reaches `/usr/local/lib/node_modules/` where `@sapiom/harness` lives. | ||
| * `realpathSync` resolves the symlink to the real file, so the upward walk | ||
| * finds sibling global packages correctly. This is a no-op on Windows (no | ||
| * symlink), in the monorepo (already a real path), and in ts-jest (CJS | ||
| * __filename branch is taken instead). | ||
| * | ||
| * We avoid `import.meta.url` because ts-jest (CJS transform) rejects that | ||
| * syntax at parse time even inside unreachable branches. | ||
| */ | ||
| function getCliRequire() { | ||
| // CJS environment (ts-jest): __filename is always defined. | ||
| if (typeof __filename !== 'undefined') { | ||
| return createRequire(__filename); | ||
| } | ||
| // ESM production: realpath the argv[1] symlink before anchoring so that | ||
| // resolution walks from the package's actual on-disk location. | ||
| const entry = process.argv[1]; | ||
| if (entry) { | ||
| try { | ||
| return createRequire(realpathSync(entry)); | ||
| } | ||
| catch { | ||
| // realpathSync can fail if the path doesn't exist (e.g. piped stdin | ||
| // script). Fall back to the raw path; resolution may still work. | ||
| return createRequire(entry); | ||
| } | ||
| } | ||
| // Last resort — resolve from the process working directory. | ||
| return createRequire(process.cwd() + '/noop.js'); | ||
| } | ||
| /** Default resolver: uses the real require.resolve. */ | ||
| function defaultResolver() { | ||
| return { | ||
| resolvePackageJson() { | ||
| return getCliRequire().resolve('@sapiom/harness/package.json'); | ||
| }, | ||
| }; | ||
| } | ||
| /** | ||
| * Resolve the `sapiom-harness` bin entry from the installed @sapiom/harness | ||
| * package. | ||
| * | ||
| * - ERR_MODULE_NOT_FOUND / MODULE_NOT_FOUND → HARNESS_NOT_INSTALLED | ||
| * - ERR_PACKAGE_PATH_NOT_EXPORTED → HARNESS_NOT_INSTALLED (with a | ||
| * hint to add "./package.json" to harness exports — we own the package so | ||
| * this should never occur in practice, but we name it explicitly so the | ||
| * error never mislabels a version mismatch as "not installed"). | ||
| * - package.json found but bin absent → HARNESS_BIN_NOT_FOUND | ||
| */ | ||
| export function resolveHarnessBin(resolver = defaultResolver()) { | ||
| let pkgPath; | ||
| try { | ||
| pkgPath = resolver.resolvePackageJson(); | ||
| } | ||
| catch (err) { | ||
| const code = err.code ?? ''; | ||
| if (code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') { | ||
| throw new CliError({ | ||
| code: 'HARNESS_NOT_INSTALLED', | ||
| message: '@sapiom/harness package.json is not exported by its exports map.', | ||
| hint: 'Install or reinstall @sapiom/harness: npm i -g @sapiom/harness', | ||
| }); | ||
| } | ||
| // MODULE_NOT_FOUND, ERR_MODULE_NOT_FOUND, and any other resolution error. | ||
| throw new CliError({ | ||
| code: 'HARNESS_NOT_INSTALLED', | ||
| message: '@sapiom/harness is not installed.', | ||
| hint: 'Install it with: npm i -g @sapiom/harness', | ||
| }); | ||
| } | ||
| // Read the bin field from package.json using fs (not require()) so we avoid | ||
| // loading the entire package just to find the entry path. | ||
| let pkg; | ||
| try { | ||
| pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); | ||
| } | ||
| catch { | ||
| throw new CliError({ | ||
| code: 'HARNESS_BIN_NOT_FOUND', | ||
| message: 'Could not read @sapiom/harness/package.json.', | ||
| hint: 'Try reinstalling: npm i -g @sapiom/harness', | ||
| }); | ||
| } | ||
| const binEntry = typeof pkg.bin === 'string' | ||
| ? pkg.bin | ||
| : typeof pkg.bin === 'object' && pkg.bin !== null | ||
| ? (pkg.bin['sapiom-harness'] ?? Object.values(pkg.bin)[0]) | ||
| : undefined; | ||
| if (!binEntry) { | ||
| throw new CliError({ | ||
| code: 'HARNESS_BIN_NOT_FOUND', | ||
| message: 'Could not locate the sapiom-harness bin entry in @sapiom/harness.', | ||
| hint: 'Try reinstalling: npm i -g @sapiom/harness', | ||
| }); | ||
| } | ||
| const binPath = path.resolve(path.dirname(pkgPath), binEntry); | ||
| if (!existsSync(binPath)) { | ||
| throw new CliError({ | ||
| code: 'HARNESS_BIN_NOT_FOUND', | ||
| message: `sapiom-harness bin not found at ${binPath}.`, | ||
| hint: 'Try reinstalling: npm i -g @sapiom/harness', | ||
| }); | ||
| } | ||
| return binPath; | ||
| } | ||
| /** | ||
| * Construct the argv array passed to the harness bin from typed options. | ||
| * Positional [dir] comes first (if provided), then known flags, then any extra | ||
| * flags forwarded verbatim. Used in unit tests and as documentation of the | ||
| * known flag set; production passes rawArgs directly. | ||
| */ | ||
| export function buildHarnessArgv(opts) { | ||
| const args = []; | ||
| if (opts.dir) | ||
| args.push(opts.dir); | ||
| if (opts.port) | ||
| args.push('--port', opts.port); | ||
| if (opts.noOpen) | ||
| args.push('--no-open'); | ||
| if (opts.noAuth) | ||
| args.push('--no-auth'); | ||
| if (opts.noTelemetry) | ||
| args.push('--no-telemetry'); | ||
| if (opts.noSession) | ||
| args.push('--no-session'); | ||
| if (opts.extraArgs && opts.extraArgs.length > 0) | ||
| args.push(...opts.extraArgs); | ||
| return args; | ||
| } | ||
| /** | ||
| * `sapiom dev [dir]` — launch the Sapiom Harness. | ||
| * | ||
| * Spawns the `sapiom-harness` bin with stdio inherited so the terminal is | ||
| * handed over cleanly. SIGTERM and SIGHUP are forwarded to the child process; | ||
| * SIGINT is intentionally NOT forwarded here — the TTY process group delivers | ||
| * it to both parent and child simultaneously, so double-forwarding would cause | ||
| * the child to receive it twice. The child's exit code is propagated; if the | ||
| * child is killed by a signal, the process exits with 128+signum per POSIX | ||
| * convention. The harness handles its own doctor check, auth, consent prompt, | ||
| * browser open, and startup banner. | ||
| * | ||
| * When `opts.rawArgs` is provided it is used as-is (production path). When | ||
| * absent, `buildHarnessArgv(opts)` is used (test/programmatic path). | ||
| */ | ||
| export async function runDev(dir, opts, resolver) { | ||
| const harnessBin = resolveHarnessBin(resolver); | ||
| const argv = opts.rawArgs !== undefined ? opts.rawArgs : buildHarnessArgv({ ...opts, dir }); | ||
| await new Promise((resolve, reject) => { | ||
| const child = spawn(process.execPath, [harnessBin, ...argv], { | ||
| stdio: 'inherit', | ||
| env: process.env, | ||
| }); | ||
| // Forward SIGTERM and SIGHUP to the child. SIGINT is handled by the TTY | ||
| // process group (Ctrl-C reaches both parent and child) — do NOT re-send it. | ||
| const forwardSigterm = () => { | ||
| child.kill('SIGTERM'); | ||
| }; | ||
| const forwardSighup = () => { | ||
| child.kill('SIGHUP'); | ||
| }; | ||
| process.on('SIGTERM', forwardSigterm); | ||
| process.on('SIGHUP', forwardSighup); | ||
| child.on('error', (err) => { | ||
| process.off('SIGTERM', forwardSigterm); | ||
| process.off('SIGHUP', forwardSighup); | ||
| reject(new CliError({ | ||
| code: 'HARNESS_SPAWN_FAILED', | ||
| message: `Failed to launch sapiom-harness: ${err.message}`, | ||
| })); | ||
| }); | ||
| child.on('close', (code, signal) => { | ||
| process.off('SIGTERM', forwardSigterm); | ||
| process.off('SIGHUP', forwardSighup); | ||
| if (signal) { | ||
| // Mirror POSIX 128+signum convention so callers / shell scripts can | ||
| // distinguish signal termination from a clean non-zero exit. | ||
| const signum = signalToNumber(signal); | ||
| process.exitCode = 128 + signum; | ||
| } | ||
| else if (code !== null && code !== 0) { | ||
| process.exitCode = code; | ||
| } | ||
| resolve(); | ||
| }); | ||
| }); | ||
| } | ||
| /** Map a signal name to its POSIX number for exit-code propagation. */ | ||
| function signalToNumber(signal) { | ||
| const table = { | ||
| SIGHUP: 1, | ||
| SIGINT: 2, | ||
| SIGQUIT: 3, | ||
| SIGKILL: 9, | ||
| SIGTERM: 15, | ||
| }; | ||
| return table[signal] ?? 0; | ||
| } |
+2
-0
@@ -5,2 +5,3 @@ import { Command } from 'commander'; | ||
| import { registerAgentsCommands } from './commands/agents/index.js'; | ||
| import { registerDevCommand } from './commands/dev-register.js'; | ||
| import { registerCommandAnalytics } from './lib/analytics.js'; | ||
@@ -21,4 +22,5 @@ import { registerSandboxCommands } from './commands/sandbox/index.js'; | ||
| registerAgentsCommands(program); | ||
| registerDevCommand(program); | ||
| registerSandboxCommands(program); | ||
| return program; | ||
| } |
+12
-4
| { | ||
| "name": "@sapiom/cli", | ||
| "version": "0.4.1", | ||
| "version": "0.4.2", | ||
| "description": "The Sapiom command-line interface — scaffold, validate, and ship Sapiom orchestrations.", | ||
@@ -34,6 +34,14 @@ "license": "MIT", | ||
| "@sapiom/agent": "^0.6.2", | ||
| "@sapiom/agent-core": "^0.9.1", | ||
| "@sapiom/analytics-core": "^0.2.0", | ||
| "@sapiom/sandbox-preview": "^0.1.2" | ||
| "@sapiom/agent-core": "^0.9.2", | ||
| "@sapiom/analytics-core": "^0.2.1", | ||
| "@sapiom/sandbox-preview": "^0.1.3" | ||
| }, | ||
| "peerDependencies": { | ||
| "@sapiom/harness": ">=0.1.2" | ||
| }, | ||
| "peerDependenciesMeta": { | ||
| "@sapiom/harness": { | ||
| "optional": true | ||
| } | ||
| }, | ||
| "devDependencies": { | ||
@@ -40,0 +48,0 @@ "@types/jest": "^29.5.14", |
+17
-0
@@ -11,2 +11,19 @@ # @sapiom/cli | ||
| ## Harness | ||
| Launch a local coding environment with MCP pre-wired and your agent running in | ||
| an embedded terminal: | ||
| ```sh | ||
| sapiom dev [dir] # open the harness in the current (or given) directory | ||
| sapiom dev --port 4200 # use a custom port | ||
| sapiom dev --no-open # skip opening the browser automatically | ||
| ``` | ||
| `sapiom dev` requires `@sapiom/harness` to be installed. Install it once with: | ||
| ```sh | ||
| npm install -g @sapiom/harness | ||
| ``` | ||
| ## Agents | ||
@@ -13,0 +30,0 @@ |
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Debug access
Supply chain riskUses debug, reflection and dynamic code execution features.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
109913
32.57%44
7.32%2331
34.12%63
36.96%7
16.67%46
9.52%6
50%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
Updated