🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@sapiom/cli

Package Overview
Dependencies
Maintainers
3
Versions
22
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@sapiom/cli - npm Package Compare versions

Comparing version
0.3.2
to
0.4.0
+277
dist/__tests__/command-analytics.e2e.test.js
/**
* End-to-end analytics tests: run the BUILT CLI (dist/bin.js) as a real
* subprocess against an in-process mock collector and assert what actually
* crosses the wire — envelopes, consent, the first-run notice, identity
* placement (header, never payload), and fault tolerance.
*
* Built artifacts are guaranteed by jest.global-setup.cjs. Every run gets a
* fresh temp HOME so machine identity, config, and credentials are isolated.
*/
import { execFile } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import * as http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { FIRST_RUN_NOTICE } from '@sapiom/analytics-core';
import { startMockCollector } from '@sapiom/analytics-core/testing';
const PACKAGE_ROOT = path.resolve(__dirname, '..', '..');
const BIN = path.join(PACKAGE_ROOT, 'dist', 'bin.js');
const CLI_VERSION = JSON.parse(readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')).version;
/** Run the built CLI. Async so the in-process mock collector can respond. */
function runCli(args, env) {
return new Promise((resolve, reject) => {
execFile(process.execPath, [BIN, ...args], { env, timeout: 20000 }, (error, stdout, stderr) => {
if (error && (error.killed || typeof error.code !== 'number')) {
// Spawn failure or hang — not a CLI exit; fail loudly.
reject(error);
return;
}
resolve({ stdout, stderr, code: error ? error.code : 0 });
});
});
}
/** A throwaway HOME so identity, config, and credentials never touch the real machine. */
function freshHome() {
return mkdtempSync(path.join(os.tmpdir(), 'sapiom-cli-analytics-e2e-'));
}
/**
* A hermetic environment for one CLI run: every SAPIOM_* variable and
* consent flag from the host is stripped, HOME/XDG point into the temp dir.
*/
function cliEnv(home, overrides = {}) {
const env = {};
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith('SAPIOM_') || key === 'DO_NOT_TRACK' || key === 'NODE_OPTIONS')
continue;
env[key] = value;
}
env.HOME = home;
env.USERPROFILE = home;
env.XDG_CONFIG_HOME = path.join(home, 'xdg');
return { ...env, ...overrides };
}
const settle = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
describe('command.run analytics (built CLI against a mock collector)', () => {
let collector;
beforeAll(async () => {
expect(existsSync(BIN)).toBe(true);
collector = await startMockCollector();
});
beforeEach(() => {
collector.reset();
});
afterAll(async () => {
await collector.close();
});
it('emits one command.run envelope with command path, flag names, duration, and exit status', async () => {
const home = freshHome();
const result = await runCli(['config', 'set-target', 'staging', '--json'], cliEnv(home, { SAPIOM_ANALYTICS_ENDPOINT: collector.url }));
expect(result.code).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({ ok: true, target: 'staging' });
await collector.waitForRequests(1);
const events = collector.events();
expect(events).toHaveLength(1);
const envelope = events[0];
expect(envelope.event_type).toBe('command.run');
expect(envelope.source).toBe('cli');
expect(envelope.sdk_name).toBe('@sapiom/cli');
expect(envelope.sdk_version).toBe(CLI_VERSION);
expect(envelope.schema_version).toBe('1');
expect(typeof envelope.anonymous_id).toBe('string');
expect(typeof envelope.session_id).toBe('string');
expect(envelope.user_id).toBeUndefined();
expect(Object.keys(envelope.data).sort()).toEqual(['command', 'duration_ms', 'exit_code', 'flags']);
expect(envelope.data.command).toBe('config set-target');
expect(envelope.data.flags).toEqual(['--json']);
expect(typeof envelope.data.duration_ms).toBe('number');
expect(envelope.data.exit_code).toBe(0);
// The positional value never reaches the wire in any form.
expect(collector.requests[0].rawBody).not.toContain('staging');
}, 20000);
it('reports the real exit code on failure and never the offending value', async () => {
const home = freshHome();
const result = await runCli(['config', 'set-target', 'bogus-target-e2e-value'], cliEnv(home, { SAPIOM_ANALYTICS_ENDPOINT: collector.url }));
expect(result.code).toBe(1);
expect(result.stderr).toContain('Unknown target');
await collector.waitForRequests(1);
const [envelope] = collector.events();
expect(envelope.data.command).toBe('config set-target');
expect(envelope.data.exit_code).toBe(1);
expect(envelope.data.flags).toEqual([]);
expect(collector.requests[0].rawBody).not.toContain('bogus-target-e2e-value');
}, 20000);
it('login: tokens, codes, and org identifiers never reach the payload; identity travels as a header', async () => {
const ACCESS_TOKEN = 'sk_e2e_login_secret_token';
const DEVICE_CODE = 'e2e-device-code-secret';
const USER_CODE = 'WXYZ-1234';
const ORG_MARKER = 'e2e-org-user@example.com';
// Minimal RFC 8628 stub that approves on the first poll. The verification
// URL is deliberately non-https so the CLI's browser-opening guard skips it.
const authServer = http.createServer((request, response) => {
request.on('data', () => { });
request.on('end', () => {
response.setHeader('content-type', 'application/json');
if (request.url === '/auth/device') {
response.end(JSON.stringify({
device_code: DEVICE_CODE,
user_code: USER_CODE,
verification_uri: 'http://127.0.0.1/activate',
verification_uri_complete: `http://127.0.0.1/activate?code=${USER_CODE}`,
expires_in: 60,
interval: 1,
}));
}
else if (request.url === '/auth/device/token') {
response.end(JSON.stringify({
access_token: ACCESS_TOKEN,
token_type: 'bearer',
tenant_id: 'e2e-tenant',
organization_name: ORG_MARKER,
}));
}
else {
response.statusCode = 404;
response.end('{}');
}
});
});
await new Promise((resolve) => authServer.listen(0, '127.0.0.1', resolve));
const { port } = authServer.address();
try {
const home = freshHome();
const env = cliEnv(home, {
SAPIOM_ANALYTICS_ENDPOINT: collector.url,
SAPIOM_API_HOST: `http://127.0.0.1:${port}`,
});
const result = await runCli(['login', '--json'], env);
expect(result.code).toBe(0);
// Sanity: the flow really completed and stored the credential.
const credentials = readFileSync(path.join(home, 'xdg', 'sapiom', 'credentials.json'), 'utf8');
expect(credentials).toContain(ACCESS_TOKEN);
await collector.waitForRequests(1);
const [envelope] = collector.events();
expect(envelope.data.command).toBe('login');
expect(envelope.data.exit_code).toBe(0);
expect(envelope.data.flags).toEqual(['--json']);
for (const request of collector.requests) {
expect(request.rawBody).not.toContain(ACCESS_TOKEN);
expect(request.rawBody).not.toContain(DEVICE_CODE);
expect(request.rawBody).not.toContain(USER_CODE);
expect(request.rawBody).not.toContain(ORG_MARKER);
}
// The credential enriches server-side identity as a header, never as payload.
expect(collector.requests[0].headers['x-sapiom-api-key']).toBe(ACCESS_TOKEN);
}
finally {
await new Promise((resolve) => authServer.close(() => resolve()));
}
}, 30000);
it('SAPIOM_API_KEY and stored credentials enrich the header but never the payload', async () => {
const ENV_KEY = 'sk_e2e_env_key_secret';
const STORED_KEY = 'sk_e2e_stored_credential_secret';
const home = freshHome();
const sapiomConfigDir = path.join(home, 'xdg', 'sapiom');
mkdirSync(sapiomConfigDir, { recursive: true });
writeFileSync(path.join(sapiomConfigDir, 'credentials.json'), JSON.stringify({ profiles: { default: { apiKey: STORED_KEY } } }));
const result = await runCli(['logout', '--json'], cliEnv(home, { SAPIOM_ANALYTICS_ENDPOINT: collector.url, SAPIOM_API_KEY: ENV_KEY }));
expect(result.code).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({ ok: true, cleared: true });
await collector.waitForRequests(1);
expect(collector.events()[0].data.command).toBe('logout');
expect(collector.requests[0].headers['x-sapiom-api-key']).toBe(ENV_KEY);
for (const request of collector.requests) {
expect(request.rawBody).not.toContain(ENV_KEY);
expect(request.rawBody).not.toContain(STORED_KEY);
}
}, 20000);
it('SAPIOM_TELEMETRY_DISABLED=1 and DO_NOT_TRACK=1 send nothing, with identical command output', async () => {
const enabled = await runCli(['logout', '--json'], cliEnv(freshHome(), { SAPIOM_ANALYTICS_ENDPOINT: collector.url }));
await collector.waitForRequests(1);
expect(collector.requests).toHaveLength(1);
collector.reset();
const optedOut = await runCli(['logout', '--json'], cliEnv(freshHome(), { SAPIOM_ANALYTICS_ENDPOINT: collector.url, SAPIOM_TELEMETRY_DISABLED: '1' }));
const doNotTrack = await runCli(['logout', '--json'], cliEnv(freshHome(), { SAPIOM_ANALYTICS_ENDPOINT: collector.url, DO_NOT_TRACK: '1' }));
await settle(250);
expect(collector.requests).toHaveLength(0);
expect(optedOut.code).toBe(0);
expect(doNotTrack.code).toBe(0);
expect(optedOut.stdout).toBe(enabled.stdout);
expect(doNotTrack.stdout).toBe(enabled.stdout);
expect(optedOut.stderr).toBe('');
}, 30000);
it('live by default: with no opt-outs configured, the first-run notice prints and events reach the collector', async () => {
// No SAPIOM_TELEMETRY_DISABLED or DO_NOT_TRACK — the emitter is live by
// default and routes to the hosted collector. SAPIOM_ANALYTICS_ENDPOINT
// redirects that to the mock so nothing hits the production URL.
const home = freshHome();
const result = await runCli(['logout', '--json'], cliEnv(home, { SAPIOM_ANALYTICS_ENDPOINT: collector.url }));
expect(result.code).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({ ok: true, cleared: false });
// The first-run notice prints because no opt-out silences it.
expect(result.stderr).toContain(FIRST_RUN_NOTICE);
await collector.waitForRequests(1);
expect(collector.requests.length).toBeGreaterThan(0);
// Identity file is created (first run on a fresh HOME).
expect(existsSync(path.join(home, '.sapiom', 'analytics.json'))).toBe(true);
}, 20000);
it('prints the first-run notice exactly once per machine and stamps the marker', async () => {
const home = freshHome();
const env = cliEnv(home, { SAPIOM_ANALYTICS_ENDPOINT: collector.url });
const first = await runCli(['logout', '--json'], env);
expect(first.stderr).toContain(FIRST_RUN_NOTICE);
const identity = JSON.parse(readFileSync(path.join(home, '.sapiom', 'analytics.json'), 'utf8'));
expect(typeof identity.anonymous_id).toBe('string');
expect(typeof identity.first_run_notice_at).toBe('string');
const second = await runCli(['logout', '--json'], env);
expect(second.stderr).not.toContain(FIRST_RUN_NOTICE);
await collector.waitForRequests(2);
expect(collector.events()).toHaveLength(2);
// Same machine identity across runs, distinct sessions.
const [a, b] = collector.events();
expect(a.anonymous_id).toBe(b.anonymous_id);
expect(a.session_id).not.toBe(b.session_id);
}, 30000);
it('collector faults never change command output or exit code', async () => {
const args = ['config', 'set-target', 'local', '--json'];
const baseline = await runCli(args, cliEnv(freshHome(), { SAPIOM_ANALYTICS_ENDPOINT: collector.url }));
expect(baseline.code).toBe(0);
// The collector kills every connection before responding.
collector.setMode({ kind: 'down' });
const whileDown = await runCli(args, cliEnv(freshHome(), { SAPIOM_ANALYTICS_ENDPOINT: collector.url }));
expect(whileDown.code).toBe(0);
expect(whileDown.stdout).toBe(baseline.stdout);
// Nothing is listening at all (connection refused).
collector.setMode({ kind: 'ok' });
const refused = await runCli(args, cliEnv(freshHome(), { SAPIOM_ANALYTICS_ENDPOINT: 'http://127.0.0.1:9/v1/analytics/collector' }));
expect(refused.code).toBe(0);
expect(refused.stdout).toBe(baseline.stdout);
// The collector responds with a server error.
collector.setMode({ kind: 'status', status: 500 });
const serverError = await runCli(args, cliEnv(freshHome(), { SAPIOM_ANALYTICS_ENDPOINT: collector.url }));
expect(serverError.code).toBe(0);
expect(serverError.stdout).toBe(baseline.stdout);
}, 40000);
it('a slow collector delays exit boundedly and never changes command output', async () => {
const args = ['config', 'set-target', 'local', '--json'];
const baseline = await runCli(args, cliEnv(freshHome(), { SAPIOM_ANALYTICS_ENDPOINT: collector.url }));
expect(baseline.code).toBe(0);
// Respond only after 8s — past the sender's 5s per-attempt timeout — so
// the exit flush runs its worst case: one timed-out delivery attempt.
collector.reset();
collector.setMode({ kind: 'slow', delayMs: 8000 });
const startedAt = Date.now();
const slow = await runCli(args, cliEnv(freshHome(), { SAPIOM_ANALYTICS_ENDPOINT: collector.url }));
const wallMs = Date.now() - startedAt;
expect(slow.code).toBe(0);
expect(slow.stdout).toBe(baseline.stdout);
// The exit flush genuinely waits through the timed-out attempt (measured
// ≈5.1s locally: 5s request timeout + process startup)…
expect(wallMs).toBeGreaterThan(4500);
// …but exit latency stays bounded — generous ceiling to absorb CI noise.
expect(wallMs).toBeLessThan(12000);
// Exactly one attempt reaches the wire: after it times out, the retry
// backoff timer is unref'd inside analytics-core, so it cannot hold the
// exiting process open — the batch is dropped instead of retried.
expect(collector.requests).toHaveLength(1);
}, 45000);
});
/**
* Unit tests for the `command.run` analytics hooks: command-path resolution,
* flag-name extraction (names only — never values), duration/exit capture,
* and the guarantee that analytics can never affect a command.
*
* These run the hooks in-process against a synthetic program wired with the
* same `action()`/`json()` helpers as the real CLI; the real built binary is
* covered end-to-end in command-analytics.e2e.test.ts.
*/
import { Command } from 'commander';
import { action, json } from '../commands/shared.js';
import { commandPath, registerCommandAnalytics, specifiedFlagNames, } from '../lib/analytics.js';
import { CliError } from '../lib/output.js';
function recordingTracker() {
const events = [];
return {
events,
tracker: {
track(eventType, data) {
events.push({ eventType, data: data ?? {} });
},
},
};
}
/**
* A miniature program mirroring the real CLI's wiring: a root program with
* analytics hooks, a nested command group, and actions wrapped with the
* shared `action()` error handling.
*/
function buildTestProgram(tracker, act = async () => { }) {
const program = new Command('sapiom');
registerCommandAnalytics(program, () => tracker);
json(program.command('login').description('top-level command')).action(action(act));
const group = program
.command('things')
.alias('th')
.description('nested group')
.option('--level <n>', 'group-level option');
json(group
.command('push [dir]')
.description('nested command with a defaulted value option')
.option('-b, --branch <branch>', 'branch to push to', 'main')).action(action(act));
return program;
}
describe('command.run analytics hooks', () => {
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();
// `fail()` sets process.exitCode; leaving it set would fail the jest run.
process.exitCode = originalExitCode;
});
it('tracks a nested command with its canonical path and exit code 0', async () => {
const { tracker, events } = recordingTracker();
await buildTestProgram(tracker).parseAsync(['things', 'push'], { from: 'user' });
expect(events).toHaveLength(1);
expect(events[0].eventType).toBe('command.run');
expect(events[0].data.command).toBe('things push');
expect(events[0].data.exit_code).toBe(0);
expect(typeof events[0].data.duration_ms).toBe('number');
expect(events[0].data.duration_ms).toBeGreaterThanOrEqual(0);
});
it('emits exactly the documented data fields', async () => {
const { tracker, events } = recordingTracker();
await buildTestProgram(tracker).parseAsync(['things', 'push'], { from: 'user' });
expect(Object.keys(events[0].data).sort()).toEqual(['command', 'duration_ms', 'exit_code', 'flags']);
});
it('records the names of user-passed flags only — no defaults, no values, no positionals', async () => {
const { tracker, events } = recordingTracker();
await buildTestProgram(tracker).parseAsync(['things', 'push', 'secret-positional-dir', '--branch', 'secret-branch-value', '--json'], { from: 'user' });
expect(events[0].data.flags).toEqual(['--branch', '--json']);
const serialized = JSON.stringify(events[0].data);
expect(serialized).not.toContain('secret-positional-dir');
expect(serialized).not.toContain('secret-branch-value');
});
it('excludes defaulted options that the user did not pass', async () => {
const { tracker, events } = recordingTracker();
await buildTestProgram(tracker).parseAsync(['things', 'push'], { from: 'user' });
// --branch has a default of 'main' but was not passed; the group-level
// --level was not passed either.
expect(events[0].data.flags).toEqual([]);
});
it('captures group-level flags passed before the subcommand — names only, root-to-leaf order', async () => {
const { tracker, events } = recordingTracker();
await buildTestProgram(tracker).parseAsync(['things', '--level', 'secret-level-value', 'push', '--json'], { from: 'user' });
expect(events[0].data.command).toBe('things push');
expect(events[0].data.flags).toEqual(['--level', '--json']);
expect(JSON.stringify(events[0].data)).not.toContain('secret-level-value');
});
it('resolves group aliases to their canonical command path', async () => {
const { tracker, events } = recordingTracker();
await buildTestProgram(tracker).parseAsync(['th', 'push'], { from: 'user' });
expect(events[0].data.command).toBe('things push');
});
it('tracks top-level commands without a root-program prefix', async () => {
const { tracker, events } = recordingTracker();
await buildTestProgram(tracker).parseAsync(['login'], { from: 'user' });
expect(events[0].data.command).toBe('login');
});
it('captures exit code 1 when the action fails through fail()', async () => {
const { tracker, events } = recordingTracker();
const program = buildTestProgram(tracker, async () => {
throw new CliError({ code: 'BOOM', message: 'it broke' });
});
await program.parseAsync(['things', 'push'], { from: 'user' });
expect(events).toHaveLength(1);
expect(events[0].data.exit_code).toBe(1);
expect(process.exitCode).toBe(1);
});
it('a throwing tracker never breaks the command', async () => {
let actionRan = false;
const tracker = {
track() {
throw new Error('analytics exploded');
},
};
const program = buildTestProgram(tracker, async () => {
actionRan = true;
});
await expect(program.parseAsync(['things', 'push'], { from: 'user' })).resolves.toBeDefined();
expect(actionRan).toBe(true);
expect(process.exitCode ?? 0).toBe(0);
});
describe('helpers', () => {
it('commandPath returns an empty string for the root program', () => {
expect(commandPath(new Command('sapiom'))).toBe('');
});
it('specifiedFlagNames returns an empty list for a command with no options', () => {
expect(specifiedFlagNames(new Command('bare'))).toEqual([]);
});
});
});
/**
* Usage analytics for the CLI: one `command.run` event per executed command,
* emitted through @sapiom/analytics-core via commander lifecycle hooks.
*
* Live by default: the emitter delivers to the hosted Sapiom collector unless
* opted out. `SAPIOM_ANALYTICS_ENDPOINT` overrides the destination (useful in
* tests). Opt out with `SAPIOM_TELEMETRY_DISABLED=1` or `DO_NOT_TRACK=1` —
* either makes the emitter a complete no-op (zero network calls, zero disk
* writes, no notice).
*
* Privacy: an event carries the command path (canonical command names only),
* the NAMES of the flags that were passed, the duration, and the exit status.
* Flag values, positional arguments, tokens, and emails are never recorded.
* Delivery is enqueue-only — nothing in the command path ever awaits the
* network; batches flush best-effort on process exit inside analytics-core.
*/
import { readFileSync, realpathSync } from 'node:fs';
import path from 'node:path';
import { createAnalytics } from '@sapiom/analytics-core';
import { readCredential } from './session.js';
let sharedAnalytics = null;
/**
* Lazy process-wide analytics instance. Created on the first completed
* command, never at import time, so `--help`, parse errors, and library use
* of `buildProgram()` touch nothing.
*/
function getAnalytics() {
if (sharedAnalytics === null) {
sharedAnalytics = createAnalytics({
source: 'cli',
sdkName: '@sapiom/cli',
sdkVersion: cliVersion(),
apiKey: resolveTelemetryApiKey(),
});
}
return sharedAnalytics;
}
/**
* Identity for server-side enrichment (sent as a header by analytics-core,
* never placed in event payloads). Environment first, then the stored
* session — same precedence as the API client, but non-throwing: no
* credential simply means anonymous events.
*/
function resolveTelemetryApiKey() {
try {
if (process.env.SAPIOM_API_KEY)
return process.env.SAPIOM_API_KEY;
const stored = readCredential();
return stored?.accessToken ?? stored?.apiKey ?? undefined;
}
catch {
return undefined;
}
}
let cachedVersion = null;
/**
* The CLI's own version, read from the package.json that ships next to the
* running `dist/bin.js`. Resolved from the entry script (argv[1]) rather than
* `import.meta` so this module stays loadable under CJS test runners; the
* name check guarantees we never report some other package's version.
*
* `0.0.0` is the expected, non-error state whenever the CLI is not the
* process entrypoint — e.g. under Jest, argv[1] is the worker script, so the
* name check fails by design; likewise for library imports of buildProgram().
*/
function cliVersion() {
if (cachedVersion !== null)
return cachedVersion;
cachedVersion = '0.0.0';
try {
const entry = process.argv[1];
if (entry) {
const pkgPath = path.resolve(path.dirname(realpathSync(entry)), '..', 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
if (pkg.name === '@sapiom/cli' && typeof pkg.version === 'string')
cachedVersion = pkg.version;
}
}
catch {
// Unreadable entry path — keep the honest fallback rather than guess.
}
return cachedVersion;
}
/** Space-joined canonical command names from the root down, e.g. `agents deploy`. */
export function commandPath(command) {
const names = [];
// Stop before the root program so the path is `agents deploy`, not
// `sapiom agents deploy`. Aliases resolve to canonical names via name().
for (let current = command; current && current.parent; current = current.parent) {
names.unshift(current.name());
}
return names.join(' ');
}
/**
* The long names of the options the user actually passed on the command line
* (`--json`, `--host`, …), collected across the whole command chain (root
* program → groups → leaf) so group-level options are captured too. Defaults,
* env-derived, and implied values are excluded, and option VALUES are never
* read — names only.
*/
export function specifiedFlagNames(command) {
// Root-to-leaf, mirroring the command-path order; a name that appears on
// both an ancestor and the leaf is recorded once.
const chain = [];
for (let current = command; current; current = current.parent) {
chain.unshift(current);
}
const seen = new Set();
const flags = [];
for (const owner of chain) {
for (const option of owner.options) {
if (owner.getOptionValueSource(option.attributeName()) !== 'cli')
continue;
const name = option.long ?? option.short ?? option.name();
if (seen.has(name))
continue;
seen.add(name);
flags.push(name);
}
}
return flags;
}
/**
* The exit status the process will report. Commands signal failure by setting
* `process.exitCode` (see `fail()` in output.ts) rather than throwing, so the
* post-action hook still runs and can record it.
*/
function currentExitCode() {
const code = process.exitCode;
if (typeof code === 'number' && Number.isFinite(code))
return code;
if (typeof code === 'string') {
const parsed = Number(code);
if (Number.isFinite(parsed))
return parsed;
}
return 0;
}
/**
* Instrument a commander program with `command.run` usage analytics. Program-
* level hooks fire for every (nested) subcommand action: `preAction` stamps a
* start time, `postAction` enqueues one event. Analytics must never change
* command behavior — every hook body is fully guarded, `track()` is a
* synchronous enqueue, and nothing here awaits delivery.
*/
export function registerCommandAnalytics(program, getTracker = getAnalytics) {
const startedAt = new WeakMap();
program.hook('preAction', (_thisCommand, actionCommand) => {
try {
startedAt.set(actionCommand, Date.now());
}
catch {
// Analytics must never affect the command.
}
});
program.hook('postAction', (_thisCommand, actionCommand) => {
try {
const start = startedAt.get(actionCommand);
getTracker().track('command.run', {
command: commandPath(actionCommand),
flags: specifiedFlagNames(actionCommand),
duration_ms: start === undefined ? null : Date.now() - start,
exit_code: currentExitCode(),
});
}
catch {
// Analytics must never affect the command.
}
});
}
+3
-0

@@ -5,2 +5,3 @@ import { Command } from 'commander';

import { registerAgentsCommands } from './commands/agents/index.js';
import { registerCommandAnalytics } from './lib/analytics.js';
/**

@@ -14,2 +15,4 @@ * Build the root `sapiom` program. Account-level commands (login/logout) sit at

const program = new Command('sapiom').description('The Sapiom command-line interface.');
// Program-level hooks cover every command group registered below.
registerCommandAnalytics(program);
registerAuthCommands(program);

@@ -16,0 +19,0 @@ registerConfigCommands(program);

+4
-3
{
"name": "@sapiom/cli",
"version": "0.3.2",
"version": "0.4.0",
"description": "The Sapiom command-line interface — scaffold, validate, and ship Sapiom orchestrations.",

@@ -33,4 +33,5 @@ "license": "MIT",

"zod": "^3.25.76",
"@sapiom/agent": "^0.6.1",
"@sapiom/agent-core": "^0.8.0"
"@sapiom/agent": "^0.6.2",
"@sapiom/agent-core": "^0.9.0",
"@sapiom/analytics-core": "^0.2.0"
},

@@ -37,0 +38,0 @@ "devDependencies": {

@@ -34,1 +34,13 @@ # @sapiom/cli

`--json` for machine-readable output.
## Usage analytics
The CLI can emit anonymous usage events through
[`@sapiom/analytics-core`](https://github.com/sapiom/sapiom-js/tree/main/packages/analytics-core):
one `command.run` event per executed command, carrying the command name, the
names of the flags used (never their values or arguments), the duration, and
the exit status. Nothing is currently sent anywhere. When delivery is
enabled, it is best-effort and can never fail a command: it never slows
command execution, and on exit a final flush is bounded by the emitter's
5-second request timeout — retries never hold the process open. Opt out at
any time with `SAPIOM_TELEMETRY_DISABLED=1` or `DO_NOT_TRACK=1`.