@agentage/cli 0.38.0 → 0.38.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
- import { readFileSync, writeFileSync } from 'node:fs';
2
1
  import { join } from 'node:path';
3
2
  import { ensureConfigDir, getConfigDir } from '../fs/config.js';
3
+ import { isRecord, readJson, writeJsonAtomic } from '../fs/json-file.js';
4
4
  import { fetchJsonUnref } from '../net/http.js';
5
5
  import { compareVersions, INSTALL_HINT, REGISTRY_URL } from './update-check.js';
6
6
  import { VERSION } from '../../utils/version.js';
@@ -8,18 +8,14 @@ const CACHE_FILE = 'update-check.json';
8
8
  const TTL_MS = 60 * 60 * 1000; // passive checks refresh at most once an hour
9
9
  const BG_TIMEOUT_MS = 3000; // hard cap on the whole background attempt, connect included
10
10
  const cachePath = () => join(getConfigDir(), CACHE_FILE);
11
- export const readUpdateCache = () => {
12
- try {
13
- const c = JSON.parse(readFileSync(cachePath(), 'utf-8'));
14
- return typeof c.checkedAt === 'number' ? c : null;
15
- }
16
- catch {
17
- return null;
18
- }
19
- };
11
+ // A wrong-typed field reads as no cache: the post-action hint must never throw.
12
+ export const isUpdateCache = (value) => isRecord(value) &&
13
+ typeof value['checkedAt'] === 'number' &&
14
+ (value['latest'] === null || typeof value['latest'] === 'string');
15
+ export const readUpdateCache = () => readJson(cachePath(), isUpdateCache);
20
16
  const writeUpdateCache = (latest, now) => {
21
17
  ensureConfigDir();
22
- writeFileSync(cachePath(), JSON.stringify({ checkedAt: now, latest }) + '\n', 'utf-8');
18
+ writeJsonAtomic(cachePath(), { checkedAt: now, latest });
23
19
  };
24
20
  // The one dim hint line printed after a command, read purely from the cache (never the network),
25
21
  // only when the cached latest is newer than us. null = print nothing.
@@ -7,10 +7,7 @@ export const REGISTRY_URL = 'https://registry.npmjs.org/@agentage/cli/latest';
7
7
  // Compare dotted numeric versions (major.minor.patch); prerelease/build suffixes are
8
8
  // ignored - good enough for an update hint. Returns -1 / 0 / 1.
9
9
  export const compareVersions = (a, b) => {
10
- const parts = (v) => v
11
- .split('-')[0]
12
- .split('.')
13
- .map((n) => Number.parseInt(n, 10) || 0);
10
+ const parts = (v) => (v.split('-')[0] ?? '').split('.').map((n) => Number.parseInt(n, 10) || 0);
14
11
  const pa = parts(a);
15
12
  const pb = parts(b);
16
13
  for (let i = 0; i < 3; i++) {
package/dist/program.d.ts CHANGED
@@ -1 +1,18 @@
1
- export declare const run: () => Promise<void>;
1
+ import { Command } from 'commander';
2
+ import { type SetupOptions } from './commands/auth/setup.js';
3
+ import { type StatusOptions } from './commands/status/status.js';
4
+ import { type UpdateOptions } from './commands/update/update.js';
5
+ export interface ProgramDeps {
6
+ setup: (opts: SetupOptions) => Promise<void>;
7
+ status: (opts: StatusOptions) => Promise<void>;
8
+ update: (opts: UpdateOptions) => Promise<void>;
9
+ updateHint: () => string | null;
10
+ refreshUpdateCache: () => Promise<void>;
11
+ writeOut: (text: string) => void;
12
+ writeErr: (text: string) => void;
13
+ }
14
+ export declare const EXIT_OK = 0;
15
+ export declare const EXIT_FAILED = 1;
16
+ export declare const EXIT_USAGE = 2;
17
+ export declare const createProgram: (deps?: ProgramDeps) => Command;
18
+ export declare const run: (argv?: string[], deps?: ProgramDeps) => Promise<number>;
package/dist/program.js CHANGED
@@ -1,38 +1,81 @@
1
1
  import chalk from 'chalk';
2
- import { Command } from 'commander';
3
- import { registerSetup } from './commands/auth/setup.js';
4
- import { registerStatus } from './commands/status/status.js';
5
- import { registerUpdate } from './commands/update/update.js';
2
+ import { Command, CommanderError } from 'commander';
3
+ import { registerSetup, runSetup } from './commands/auth/setup.js';
4
+ import { registerStatus, runStatus } from './commands/status/status.js';
5
+ import { registerUpdate, runUpdate } from './commands/update/update.js';
6
+ import { ensureSignedIn, signOut } from './lib/auth/sign-in.js';
6
7
  import { refreshUpdateCache, updateHint } from './lib/update/update-cache.js';
7
8
  import { VERSION } from './utils/version.js';
8
- const program = new Command();
9
- program
10
- .name('agentage')
11
- .description('Connect this machine to agentage from the terminal')
12
- .version(VERSION);
13
- program.addHelpText('after', '\nNew here? Run: agentage setup to connect this machine.');
14
- // After any command (except the update-aware ones, which report richer state themselves), print a
15
- // one-line hint from the cache, then kick a fire-and-forget refresh. Never awaited, never throws -
16
- // the command output is already done and the cache-only read keeps this instant. The hint is
17
- // suppressed under --json so it never corrupts machine-readable output.
18
- program.hook('postAction', (_thisCommand, actionCommand) => {
19
- const name = actionCommand.name();
20
- if (name === 'status' || name === 'update')
9
+ const defaultDeps = {
10
+ setup: (opts) => runSetup(opts, {
11
+ ensureSignedIn: (signInOpts) => ensureSignedIn(signInOpts),
12
+ signOut: () => signOut(),
13
+ printStatus: () => runStatus({}),
14
+ }),
15
+ status: (opts) => runStatus(opts),
16
+ update: (opts) => runUpdate(opts),
17
+ updateHint,
18
+ refreshUpdateCache: () => refreshUpdateCache(),
19
+ writeOut: (text) => void process.stdout.write(text),
20
+ writeErr: (text) => void process.stderr.write(text),
21
+ };
22
+ export const EXIT_OK = 0;
23
+ export const EXIT_FAILED = 1;
24
+ export const EXIT_USAGE = 2;
25
+ const ACTION_FAILED = 'agentage.actionFailed';
26
+ const messageOf = (err) => (err instanceof Error ? err.message : String(err));
27
+ // One failure path for every command: a red stderr line, then exit 1 through run().
28
+ const withAction = (deps, fn, prefix = '') => async (opts) => {
29
+ try {
30
+ await fn(opts);
31
+ }
32
+ catch (err) {
33
+ deps.writeErr(`${chalk.red(`${prefix}${messageOf(err)}`)}\n`);
34
+ throw new CommanderError(EXIT_FAILED, ACTION_FAILED, messageOf(err));
35
+ }
36
+ };
37
+ // status and update report update state themselves; the hint never touches --json stdout.
38
+ const hintAfter = (deps) => (_program, action) => {
39
+ if (action.name() === 'status' || action.name() === 'update')
21
40
  return;
22
- if (!actionCommand.opts()['json']) {
23
- const hint = updateHint();
24
- if (hint)
25
- console.error(chalk.dim(hint)); // stderr: never corrupt piped stdout
41
+ const hint = action.opts()['json'] ? null : deps.updateHint();
42
+ if (hint)
43
+ deps.writeErr(`${chalk.dim(hint)}\n`);
44
+ void deps.refreshUpdateCache();
45
+ };
46
+ export const createProgram = (deps = defaultDeps) => {
47
+ const program = new Command()
48
+ .name('agentage')
49
+ .description('Connect this machine to agentage from the terminal')
50
+ .version(VERSION)
51
+ .exitOverride()
52
+ .configureOutput({ writeOut: deps.writeOut, writeErr: deps.writeErr })
53
+ .addHelpText('after', '\nNew here? Run: agentage setup to connect this machine.')
54
+ .hook('postAction', hintAfter(deps));
55
+ registerSetup(program, withAction(deps, deps.setup, 'Setup failed: '));
56
+ registerStatus(program, withAction(deps, deps.status));
57
+ registerUpdate(program, withAction(deps, deps.update));
58
+ return program;
59
+ };
60
+ // Commander's own exit 0 (--help, --version) stays 0; any other commander error is usage.
61
+ const exitCodeOf = (err, deps) => {
62
+ if (!(err instanceof CommanderError)) {
63
+ deps.writeErr(`${chalk.red(messageOf(err))}\n`);
64
+ return EXIT_FAILED;
65
+ }
66
+ if (err.code === ACTION_FAILED)
67
+ return EXIT_FAILED;
68
+ return err.exitCode === 0 ? EXIT_OK : EXIT_USAGE;
69
+ };
70
+ // Returns the exit code instead of exiting, so piped stdout is never cut short.
71
+ export const run = async (argv = process.argv, deps = defaultDeps) => {
72
+ if (process.env['NO_COLOR'])
73
+ chalk.level = 0; // chalk ignores NO_COLOR on its own
74
+ try {
75
+ await createProgram(deps).parseAsync(argv);
76
+ return EXIT_OK;
77
+ }
78
+ catch (err) {
79
+ return exitCodeOf(err, deps);
26
80
  }
27
- void refreshUpdateCache();
28
- });
29
- registerSetup(program);
30
- registerStatus(program);
31
- registerUpdate(program);
32
- // Called by the bin (src/cli.ts).
33
- export const run = async () => {
34
- await program.parseAsync().catch((err) => {
35
- console.error(err instanceof Error ? err.message : String(err));
36
- process.exit(1);
37
- });
38
81
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentage/cli",
3
- "version": "0.38.0",
3
+ "version": "0.38.1",
4
4
  "description": "The agentage CLI - connect this machine to agentage from the terminal",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,2 +0,0 @@
1
- export declare const acquireUpdateLock: (now?: number) => boolean;
2
- export declare const releaseUpdateLock: () => void;
@@ -1,109 +0,0 @@
1
- import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
- import { join } from 'node:path';
3
- import { ensureConfigDir, getConfigDir } from '../fs/config.js';
4
- const LOCK_FILE = 'update.lock';
5
- const LOCK_TTL_MS = 10 * 60 * 1000; // a lock older than this whose holder is gone is a crashed run
6
- // Past this age a holder is taken over even if its pid still resolves (pid reuse after a crash); far
7
- // beyond any real run or scheduler pause, so a live-but-slow holder is never mistaken for crashed.
8
- const STALE_HARD_MS = 30 * 60 * 1000;
9
- const lockPath = () => join(getConfigDir(), LOCK_FILE);
10
- // The lock file holds "<pid> <timestamp>": pid gates crash-takeover, the trailing token is the
11
- // acquisition time. A legacy single-token file parses as pid 0 (unknown holder), aging out on TTL.
12
- const holderOf = (path) => {
13
- try {
14
- const parts = readFileSync(path, 'utf-8').trim().split(/\s+/);
15
- const at = Number.parseInt(parts[parts.length - 1] ?? '', 10);
16
- if (Number.isNaN(at))
17
- return null;
18
- const pid = parts.length >= 2 ? Number.parseInt(parts[0] ?? '', 10) : 0;
19
- return { pid: Number.isNaN(pid) ? 0 : pid, at };
20
- }
21
- catch {
22
- return null;
23
- }
24
- };
25
- // Same-host liveness probe. ESRCH = the holder is gone (crashed); EPERM = alive but owned by another
26
- // user. pid 0/unknown counts as gone so a legacy or malformed lock still ages out on the TTL.
27
- const holderAlive = (pid) => {
28
- if (pid <= 0)
29
- return false;
30
- try {
31
- process.kill(pid, 0);
32
- return true;
33
- }
34
- catch (err) {
35
- return err.code === 'EPERM';
36
- }
37
- };
38
- // A holder is safe to take over only once it is past the TTL AND provably gone: a dead pid, our own
39
- // leftover pid, or so old that pid reuse is the only explanation. A live holder merely starved of
40
- // CPU is never stolen - stealing it would let two writers run concurrently.
41
- const isCrashed = (holder, now) => {
42
- const age = now - holder.at;
43
- if (age < LOCK_TTL_MS)
44
- return false;
45
- if (age >= STALE_HARD_MS)
46
- return true;
47
- return holder.pid === process.pid || !holderAlive(holder.pid);
48
- };
49
- const unlinkQuiet = (path) => {
50
- try {
51
- unlinkSync(path);
52
- }
53
- catch {
54
- // already gone
55
- }
56
- };
57
- // Serialize stale-lock takeover behind its own exclusive create, so no taker can ever delete a
58
- // lock that turned FRESH mid-takeover (the ABA race: reader sees the stale timestamp, a winner
59
- // re-creates the lock, the reader deletes the winner's file). Under the guard the staleness is
60
- // re-checked and only a genuinely stale lock is cleared; losers simply refuse. A guard left by a
61
- // crashed taker expires like the lock itself. Returns whether to re-race the create.
62
- const takeOverStale = (now) => {
63
- const guard = `${lockPath()}.takeover`;
64
- const guardHeld = holderOf(guard);
65
- if (guardHeld !== null && isCrashed(guardHeld, now))
66
- unlinkQuiet(guard);
67
- try {
68
- writeFileSync(guard, `${process.pid} ${now}`, { flag: 'wx' });
69
- }
70
- catch {
71
- return false; // another taker is mid-takeover; let it win
72
- }
73
- try {
74
- const holder = holderOf(lockPath());
75
- if (holder !== null && !isCrashed(holder, now))
76
- return false; // fresh or a live-but-slow holder
77
- unlinkQuiet(lockPath());
78
- return true;
79
- }
80
- finally {
81
- unlinkQuiet(guard);
82
- }
83
- };
84
- // Acquire the single-writer update lock. The O_EXCL ('wx') create IS the mutex - concurrent runs
85
- // cannot both win it, unlike a check-then-write. A fresh holder (younger than the TTL) refuses;
86
- // a stale one (crashed run) is cleared under the takeover guard and re-raced once. `now` is
87
- // injectable.
88
- export const acquireUpdateLock = (now = Date.now()) => {
89
- ensureConfigDir();
90
- for (let attempt = 0; attempt < 2; attempt++) {
91
- try {
92
- writeFileSync(lockPath(), `${process.pid} ${now}`, { flag: 'wx' });
93
- return true;
94
- }
95
- catch {
96
- const holder = holderOf(lockPath());
97
- if (holder !== null && !isCrashed(holder, now))
98
- return false;
99
- if (!takeOverStale(now))
100
- return false;
101
- }
102
- }
103
- return false;
104
- };
105
- export const releaseUpdateLock = () => {
106
- const path = lockPath();
107
- if (existsSync(path))
108
- unlinkSync(path);
109
- };