@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.
@@ -0,0 +1,95 @@
1
+ import chalk from 'chalk';
2
+ import { deleteAuth, ensureConfigDir, mutateAuth, readAuth } from '../fs/config.js';
3
+ import { links, siteFqdn } from '../net/origins.js';
4
+ import { AuthRequiredError } from './auth-errors.js';
5
+ import { startCallbackServer } from './callback-server.js';
6
+ import { detectEnvMismatch } from './env-match.js';
7
+ import { introspectToken } from './introspect.js';
8
+ import { buildAuthorizeUrl, exchangeCode, pkcePair, randomState, registerClient, revokeToken, tokensFrom, } from './oauth.js';
9
+ const defaultDeps = {
10
+ register: registerClient,
11
+ exchange: exchangeCode,
12
+ revoke: revokeToken,
13
+ startServer: startCallbackServer,
14
+ openBrowser: async (url) => {
15
+ const { default: open } = await import('open');
16
+ await open(url);
17
+ },
18
+ introspect: introspectToken,
19
+ log: (msg) => console.log(msg),
20
+ };
21
+ const promptBrowser = async (url, opts, deps) => {
22
+ deps.log(`\nSign in to agentage (${siteFqdn()}):\n\n ${url}\n`);
23
+ if (opts.browser === false)
24
+ deps.log('Open the URL above in a browser to continue.');
25
+ else
26
+ await deps
27
+ .openBrowser(url)
28
+ .catch(() => deps.log('Could not open a browser - use the URL above.'));
29
+ deps.log('Waiting for sign-in to complete...');
30
+ };
31
+ const authorize = async (server, state, opts, deps) => {
32
+ const authUrl = links(siteFqdn()).auth;
33
+ const { verifier, challenge } = pkcePair();
34
+ const clientId = await deps.register(authUrl, server.redirectUri);
35
+ const redirectUri = server.redirectUri;
36
+ const url = buildAuthorizeUrl(authUrl, { clientId, redirectUri, challenge, state });
37
+ await promptBrowser(url, opts, deps);
38
+ return { clientId, verifier, code: await server.waitForCode() };
39
+ };
40
+ // A fresh DCR client per run: the redirect URI binds this run's ephemeral callback port.
41
+ export const signIn = async (opts, deps = defaultDeps) => {
42
+ const fqdn = siteFqdn();
43
+ ensureConfigDir();
44
+ const state = randomState();
45
+ const server = await deps.startServer(state);
46
+ try {
47
+ const { clientId, code, verifier } = await authorize(server, state, opts, deps);
48
+ const redirectUri = server.redirectUri;
49
+ const res = await deps.exchange(links(fqdn).auth, { clientId, code, redirectUri, verifier });
50
+ await mutateAuth(() => ({ siteFqdn: fqdn, clientId, tokens: tokensFrom(res) }));
51
+ deps.log(chalk.green('\nSigned in.\n'));
52
+ }
53
+ finally {
54
+ server.close();
55
+ }
56
+ };
57
+ export const signOut = async (deps = defaultDeps) => {
58
+ const auth = readAuth();
59
+ if (!auth) {
60
+ deps.log('Nothing to disconnect.');
61
+ return;
62
+ }
63
+ await deps.revoke(links(auth.siteFqdn).auth, auth.tokens.accessToken);
64
+ await deleteAuth();
65
+ deps.log('Disconnected - local credentials removed.');
66
+ };
67
+ const mismatchNote = (m) => `Signed in to ${m.credentialFqdn} (${m.credentialEnv}), but this run targets ` +
68
+ `${m.targetFqdn} (${m.targetEnv}) - signing you into ${m.targetEnv}.\n`;
69
+ // Only a dead session re-signs: a transient verify failure keeps the credentials.
70
+ const verifyStored = async (auth, deps) => {
71
+ try {
72
+ await deps.introspect(auth, links(auth.siteFqdn));
73
+ deps.log(`Already signed in. Run ${chalk.white('agentage setup --reauth')} to sign in again.\n`);
74
+ return true;
75
+ }
76
+ catch (err) {
77
+ if (err instanceof AuthRequiredError) {
78
+ deps.log('Session expired - signing you in again.\n');
79
+ return false;
80
+ }
81
+ deps.log('You appear signed in (could not fully verify - temporary).\n');
82
+ return true;
83
+ }
84
+ };
85
+ // Never introspect a credential against another environment: it would misread as expired.
86
+ export const ensureSignedIn = async (opts, deps = defaultDeps) => {
87
+ const stored = readAuth();
88
+ const mismatch = stored && !opts.reauth ? detectEnvMismatch(stored, siteFqdn()) : null;
89
+ if (mismatch)
90
+ deps.log(mismatchNote(mismatch));
91
+ else if (stored && !opts.reauth && (await verifyStored(stored, deps)))
92
+ return 'already';
93
+ await signIn(opts, deps);
94
+ return mismatch ? 'mismatch-resigned' : 'signed-in';
95
+ };
@@ -15,6 +15,7 @@ export interface AuthState {
15
15
  }
16
16
  export declare const getConfigDir: () => string;
17
17
  export declare const ensureConfigDir: () => string;
18
+ export declare const isAuthState: (value: unknown) => value is AuthState;
18
19
  export declare const readAuth: () => AuthState | null;
19
20
  export declare const saveAuth: (state: AuthState) => void;
20
21
  export declare const deleteAuth: () => Promise<void>;
@@ -1,8 +1,8 @@
1
- import { randomBytes } from 'node:crypto';
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
1
+ import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
3
2
  import { homedir } from 'node:os';
4
3
  import { join } from 'node:path';
5
4
  import { withFileLock } from './file-lock.js';
5
+ import { isRecord, readJson, writeJsonAtomic } from './json-file.js';
6
6
  export const getConfigDir = () => process.env['AGENTAGE_CONFIG_DIR'] || join(process.env['HOME'] || homedir(), '.agentage');
7
7
  export const ensureConfigDir = () => {
8
8
  const dir = getConfigDir();
@@ -11,27 +11,20 @@ export const ensureConfigDir = () => {
11
11
  return dir;
12
12
  };
13
13
  const authPath = () => join(getConfigDir(), 'auth.json');
14
- export const readAuth = () => {
15
- const path = authPath();
16
- if (!existsSync(path))
17
- return null;
18
- try {
19
- return JSON.parse(readFileSync(path, 'utf-8'));
20
- }
21
- catch {
22
- return null;
23
- }
24
- };
25
- // Atomic 0600 write: a token file is never left half-written or briefly world-readable. Write a
26
- // per-save sibling temp (unique name: concurrent savers can never clobber each other's tmp),
27
- // chmod it 0600 BEFORE the rename, then rename over the target in one step.
14
+ const isOptional = (value, type) => value === undefined || typeof value === type;
15
+ const isStoredTokens = (value) => isRecord(value) &&
16
+ typeof value['accessToken'] === 'string' &&
17
+ isOptional(value['refreshToken'], 'string') &&
18
+ isOptional(value['expiresAt'], 'number');
19
+ // A malformed auth.json (e.g. `{}`) reads as signed out rather than crashing every command.
20
+ export const isAuthState = (value) => isRecord(value) &&
21
+ typeof value['siteFqdn'] === 'string' &&
22
+ typeof value['clientId'] === 'string' &&
23
+ isStoredTokens(value['tokens']);
24
+ export const readAuth = () => readJson(authPath(), isAuthState);
28
25
  export const saveAuth = (state) => {
29
26
  ensureConfigDir();
30
- const path = authPath();
31
- const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
32
- writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
33
- chmodSync(tmp, 0o600);
34
- renameSync(tmp, path);
27
+ writeJsonAtomic(authPath(), state);
35
28
  };
36
29
  // Sign-out under the same lock mutateAuth takes (issue #236), so a concurrent token refresh can
37
30
  // never slip its re-read+save between this existence check and the unlink.
@@ -1,3 +1,8 @@
1
- export declare const acquireFileLock: (target: string, now?: number) => boolean;
1
+ export interface LockOptions {
2
+ ttlMs?: number;
3
+ hardStaleMs?: number;
4
+ maxWaitMs?: number;
5
+ }
6
+ export declare const acquireFileLock: (target: string, now?: number, opts?: LockOptions) => boolean;
2
7
  export declare const releaseFileLock: (target: string) => void;
3
- export declare const withFileLock: <T>(target: string, fn: () => T) => Promise<T>;
8
+ export declare const withFileLock: <T>(target: string, fn: () => T, opts?: LockOptions) => Promise<T>;
@@ -1,20 +1,16 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
- // A cross-process advisory lock keyed to a target file (`<target>.lock`). Config read-modify-writes
5
- // are sub-millisecond, so a lock past this age whose holder is gone is a crashed holder to take over.
6
- const LOCK_TTL_MS = 10_000;
7
- // A caller blocks up to this long for a contended lock. Past the TTL so a crashed holder is taken
8
- // over rather than wedging the caller; a throw only happens if the lock is genuinely stuck.
9
- const MAX_WAIT_MS = 15_000;
10
- // Past this age a holder is taken over even if its pid still resolves - the only explanation left is
11
- // pid reuse after a crash. Far beyond any real critical section or plausible scheduler pause, so a
12
- // live-but-CPU-starved holder is never mistaken for crashed below it.
13
- const STALE_HARD_MS = 60_000;
4
+ // Sized for sub-millisecond config read-modify-writes; the wait outlives the TTL on purpose.
5
+ const DEFAULTS = { ttlMs: 10_000, hardStaleMs: 60_000, maxWaitMs: 15_000 };
14
6
  const STEP_MS = 25;
7
+ const limitsOf = (opts) => ({
8
+ ttlMs: opts.ttlMs ?? DEFAULTS.ttlMs,
9
+ hardStaleMs: opts.hardStaleMs ?? DEFAULTS.hardStaleMs,
10
+ maxWaitMs: opts.maxWaitMs ?? DEFAULTS.maxWaitMs,
11
+ });
15
12
  const lockPath = (target) => `${target}.lock`;
16
- // The lock file holds "<pid> <timestamp>": pid gates crash-takeover, the trailing token is the
17
- // acquisition time used for staleness. A legacy single-token file parses as pid 0 (unknown holder).
13
+ // "<pid> <at>"; a legacy single-token file parses as pid 0 (unknown holder) and ages out on the TTL.
18
14
  const holderOf = (path) => {
19
15
  try {
20
16
  const parts = readFileSync(path, 'utf-8').trim().split(/\s+/);
@@ -28,8 +24,7 @@ const holderOf = (path) => {
28
24
  return null;
29
25
  }
30
26
  };
31
- // Same-host liveness probe. ESRCH = the holder is gone (crashed); EPERM = alive but owned by another
32
- // user. pid 0/unknown counts as gone so a legacy or malformed lock still ages out on the TTL.
27
+ // ESRCH = gone; EPERM = alive under another user; pid 0 (unknown) counts as gone.
33
28
  const holderAlive = (pid) => {
34
29
  if (pid <= 0)
35
30
  return false;
@@ -41,15 +36,12 @@ const holderAlive = (pid) => {
41
36
  return err.code === 'EPERM';
42
37
  }
43
38
  };
44
- // A holder is safe to take over only once it is past the TTL AND provably gone: a dead pid, our own
45
- // leftover pid (we cannot be inside our own synchronous section while here), or so old that pid reuse
46
- // is the only explanation. A live holder merely starved of CPU is NEVER stolen - stealing it would
47
- // put two writers in the critical section and silently drop an append (issue #249).
48
- const isCrashed = (holder, now) => {
39
+ // Never steal a live-but-CPU-starved holder: two writers in the section drop an append (#249).
40
+ const isCrashed = (holder, now, lim) => {
49
41
  const age = now - holder.at;
50
- if (age < LOCK_TTL_MS)
42
+ if (age < lim.ttlMs)
51
43
  return false;
52
- if (age >= STALE_HARD_MS)
44
+ if (age >= lim.hardStaleMs)
53
45
  return true;
54
46
  return holder.pid === process.pid || !holderAlive(holder.pid);
55
47
  };
@@ -61,10 +53,7 @@ const unlinkQuiet = (path) => {
61
53
  // already gone
62
54
  }
63
55
  };
64
- // Atomic exclusive create: writeFileSync('wx') is create-then-write, so a concurrent holderOf can
65
- // read the freshly created but still-empty lock and mistake a live holder for absent (issue #231
66
- // lost-update). Instead write the full "<pid> <at>" to a unique temp, then linkSync it into place:
67
- // link is atomic and fails EEXIST if held, so the content is always complete when the lock appears.
56
+ // Write-then-link: a reader can never see the lock before its "<pid> <at>" content (#231).
68
57
  const createLockExclusive = (path, content) => {
69
58
  const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.new`;
70
59
  writeFileSync(tmp, content);
@@ -75,15 +64,12 @@ const createLockExclusive = (path, content) => {
75
64
  unlinkQuiet(tmp);
76
65
  }
77
66
  };
78
- // Serialize stale-lock takeover behind its own exclusive create so no taker can delete a lock that
79
- // turned FRESH mid-takeover (the ABA race: a reader sees the stale timestamp, a winner re-creates
80
- // the lock, the reader deletes the winner's file). Same O_EXCL-guard design proven in
81
- // update-lock.ts. Returns whether to re-race the create.
82
- const takeOverStale = (target, now) => {
67
+ // Takeover runs under its own exclusive guard so no taker deletes a lock that turned fresh (ABA).
68
+ const takeOverStale = (target, now, lim) => {
83
69
  const path = lockPath(target);
84
70
  const guard = `${path}.takeover`;
85
71
  const guardHeld = holderOf(guard);
86
- if (guardHeld !== null && isCrashed(guardHeld, now))
72
+ if (guardHeld !== null && isCrashed(guardHeld, now, lim))
87
73
  unlinkQuiet(guard);
88
74
  try {
89
75
  createLockExclusive(guard, `${process.pid} ${now}`);
@@ -93,10 +79,8 @@ const takeOverStale = (target, now) => {
93
79
  }
94
80
  try {
95
81
  const holder = holderOf(path);
96
- // A null holder means the lock file exists but its content did not read cleanly. Never treat that
97
- // as absent: clearing it here would steal a live holder still writing its bytes. Only a holder we
98
- // can positively prove crashed is taken over.
99
- if (holder === null || !isCrashed(holder, now))
82
+ // Unreadable content is not proof of a crash: only a provably crashed holder is cleared.
83
+ if (holder === null || !isCrashed(holder, now, lim))
100
84
  return false;
101
85
  unlinkQuiet(path);
102
86
  return true;
@@ -105,49 +89,56 @@ const takeOverStale = (target, now) => {
105
89
  unlinkQuiet(guard);
106
90
  }
107
91
  };
108
- // One-shot acquire: the atomic link IS the mutex - concurrent callers cannot both win it, and the
109
- // linked content is always complete so no reader sees an empty lock. A fresh or live holder refuses;
110
- // a crashed one (dead holder past the TTL) is cleared under the takeover guard and re-raced once.
111
- // `now` is injectable for tests.
112
- export const acquireFileLock = (target, now = Date.now()) => {
113
- mkdirSync(dirname(target), { recursive: true });
92
+ // Only EEXIST is contention; a permission error never clears, so it fails fast.
93
+ const lockWriteError = (err, path) => {
94
+ const code = err.code;
95
+ if (code === 'EEXIST')
96
+ return null;
97
+ if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS')
98
+ return new Error(`permission denied: ${path}`);
99
+ return new Error(`could not write lock file ${path}${code ? ` (${code})` : ''}`);
100
+ };
101
+ const tryCreate = (path, now) => {
102
+ try {
103
+ createLockExclusive(path, `${process.pid} ${now}`);
104
+ return true;
105
+ }
106
+ catch (err) {
107
+ const fatal = lockWriteError(err, path);
108
+ if (fatal)
109
+ throw fatal;
110
+ return false;
111
+ }
112
+ };
113
+ // One-shot acquire: the atomic link IS the mutex; a crashed holder is cleared and re-raced once.
114
+ export const acquireFileLock = (target, now = Date.now(), opts = {}) => {
115
+ const lim = limitsOf(opts);
116
+ const path = lockPath(target);
117
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
114
118
  for (let attempt = 0; attempt < 2; attempt++) {
115
- try {
116
- createLockExclusive(lockPath(target), `${process.pid} ${now}`);
119
+ if (tryCreate(path, now))
117
120
  return true;
118
- }
119
- catch (err) {
120
- // Only EEXIST means contention. A permission/read-only error never clears, so failing fast
121
- // beats burning the full MAX_WAIT_MS before reporting a generic "could not acquire lock".
122
- const code = err.code;
123
- if (code !== 'EEXIST') {
124
- const path = lockPath(target);
125
- throw new Error(code === 'EACCES' || code === 'EPERM' || code === 'EROFS'
126
- ? `permission denied: ${path}`
127
- : `could not write lock file ${path}${code ? ` (${code})` : ''}`);
128
- }
129
- const holder = holderOf(lockPath(target));
130
- // Refuse unless the holder is positively crashed. A null holder (unreadable content) is NOT
131
- // proof of a crash: attempting takeover on it once stole a live holder's lock (issue #231).
132
- if (holder === null || !isCrashed(holder, now))
133
- return false;
134
- if (!takeOverStale(target, now))
135
- return false;
136
- }
121
+ const holder = holderOf(path);
122
+ if (holder === null || !isCrashed(holder, now, lim))
123
+ return false;
124
+ if (!takeOverStale(target, now, lim))
125
+ return false;
137
126
  }
138
127
  return false;
139
128
  };
140
- export const releaseFileLock = (target) => unlinkQuiet(lockPath(target));
129
+ // Only our own lock: a holder that took over after the hard-stale age keeps theirs.
130
+ export const releaseFileLock = (target) => {
131
+ const path = lockPath(target);
132
+ if (holderOf(path)?.pid === process.pid)
133
+ unlinkQuiet(path);
134
+ };
141
135
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
142
- // Run `fn` while holding the advisory lock for `target`, blocking with bounded backoff until the
143
- // lock is free (or a crashed holder is taken over past the TTL). `fn` MUST be synchronous: the lock
144
- // is held only across one synchronous critical section, so in-process callers can never both be
145
- // inside it. The whole read-modify-write belongs in `fn` - locking only the write still loses
146
- // updates across the read->write gap. Released in a finally, so a throw never wedges the lock.
147
- export const withFileLock = async (target, fn) => {
148
- const deadline = Date.now() + MAX_WAIT_MS;
136
+ // `fn` MUST be synchronous and hold the whole read-modify-write; released in a finally.
137
+ export const withFileLock = async (target, fn, opts = {}) => {
138
+ const { maxWaitMs } = limitsOf(opts);
139
+ const deadline = Date.now() + maxWaitMs;
149
140
  for (;;) {
150
- if (acquireFileLock(target)) {
141
+ if (acquireFileLock(target, Date.now(), opts)) {
151
142
  try {
152
143
  return fn();
153
144
  }
@@ -155,9 +146,8 @@ export const withFileLock = async (target, fn) => {
155
146
  releaseFileLock(target);
156
147
  }
157
148
  }
158
- if (Date.now() >= deadline) {
159
- throw new Error(`could not acquire lock for ${target} within ${MAX_WAIT_MS}ms`);
160
- }
149
+ if (Date.now() >= deadline)
150
+ throw new Error(`could not acquire lock for ${target} within ${maxWaitMs}ms`);
161
151
  await sleep(STEP_MS + Math.floor(Math.random() * STEP_MS));
162
152
  }
163
153
  };
@@ -0,0 +1,3 @@
1
+ export declare const isRecord: (value: unknown) => value is Record<string, unknown>;
2
+ export declare const readJson: <T>(path: string, guard: (value: unknown) => value is T) => T | null;
3
+ export declare const writeJsonAtomic: (path: string, value: unknown, mode?: number) => void;
@@ -0,0 +1,43 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { chmodSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ export const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
4
+ // null on a missing, unparsable or wrong-shaped file: callers treat all three as "absent".
5
+ export const readJson = (path, guard) => {
6
+ let raw;
7
+ try {
8
+ raw = readFileSync(path, 'utf-8');
9
+ }
10
+ catch {
11
+ return null;
12
+ }
13
+ try {
14
+ const value = JSON.parse(raw);
15
+ return guard(value) ? value : null;
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ };
21
+ const unlinkQuiet = (path) => {
22
+ try {
23
+ unlinkSync(path);
24
+ }
25
+ catch {
26
+ // already gone
27
+ }
28
+ };
29
+ // Unique temp sibling + chmod before rename: never half-written, never briefly world-readable.
30
+ export const writeJsonAtomic = (path, value, mode = 0o600) => {
31
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
32
+ let renamed = false;
33
+ try {
34
+ writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', { encoding: 'utf-8', mode });
35
+ chmodSync(tmp, mode);
36
+ renameSync(tmp, path);
37
+ renamed = true;
38
+ }
39
+ finally {
40
+ if (!renamed)
41
+ unlinkQuiet(tmp);
42
+ }
43
+ };
@@ -0,0 +1,10 @@
1
+ export declare const DEFAULT_TIMEOUT_MS = 10000;
2
+ export declare const AUTH_TIMEOUT_MS = 15000;
3
+ export type CliRequestInit = Omit<RequestInit, 'headers' | 'redirect' | 'signal'> & {
4
+ headers?: Record<string, string>;
5
+ };
6
+ export interface CliFetchOptions {
7
+ timeoutMs?: number;
8
+ }
9
+ export declare const cliFetch: (url: string, init?: CliRequestInit, opts?: CliFetchOptions) => Promise<Response>;
10
+ export declare const isRedirect: (res: Response) => boolean;
@@ -0,0 +1,13 @@
1
+ import { requestHeaders } from './user-agent.js';
2
+ export const DEFAULT_TIMEOUT_MS = 10_000;
3
+ export const AUTH_TIMEOUT_MS = 15_000;
4
+ // Captured at load: plugins loaded later must not be able to swap the fetch that carries tokens.
5
+ const baseFetch = globalThis.fetch;
6
+ // redirect: 'manual' so a bearer or form body is never replayed to a redirect target.
7
+ export const cliFetch = (url, init = {}, opts = {}) => baseFetch(url, {
8
+ ...init,
9
+ headers: { ...init.headers, ...requestHeaders() },
10
+ redirect: 'manual',
11
+ signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
12
+ });
13
+ export const isRedirect = (res) => res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400);
@@ -1,27 +1,55 @@
1
- import { get } from 'node:https';
2
- // node:https instead of global fetch: undici keeps a ref'd ~10s connect timer alive even after
3
- // its AbortSignal fires, stalling process exit on an unreachable network. req.destroy() from an
4
- // unref'd timer caps the whole attempt and frees the event loop the moment it settles. Never
5
- // throws: an unreachable host or malformed body resolves null (unreachable) or json:null.
6
- export const fetchJsonUnref = (url, timeoutMs, headers = {}) => new Promise((resolve) => {
7
- const req = get(url, { headers: { Accept: 'application/json', ...headers } }, (res) => {
8
- let body = '';
9
- res.setEncoding('utf8');
10
- res.on('data', (chunk) => {
11
- body += chunk;
12
- });
13
- res.on('end', () => {
14
- const status = res.statusCode ?? 0;
15
- let json = null;
16
- try {
17
- json = body ? JSON.parse(body) : null;
18
- }
19
- catch {
20
- json = null;
21
- }
22
- resolve({ ok: status >= 200 && status < 300, status, json });
23
- });
1
+ import { get as httpGet } from 'node:http';
2
+ import { get as httpsGet } from 'node:https';
3
+ // Loopback stubs (AGENTAGE_SITE_FQDN=127.0.0.1:<port>) are plain http; every real target is https.
4
+ const getterFor = (url) => {
5
+ try {
6
+ const { protocol } = new URL(url);
7
+ if (protocol === 'https:')
8
+ return httpsGet;
9
+ if (protocol === 'http:')
10
+ return httpGet;
11
+ }
12
+ catch {
13
+ // not a URL
14
+ }
15
+ return null;
16
+ };
17
+ const parseJson = (body) => {
18
+ try {
19
+ return body ? JSON.parse(body) : null;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ };
25
+ const collect = (res, resolve) => {
26
+ let body = '';
27
+ res.setEncoding('utf8');
28
+ res.on('data', (chunk) => {
29
+ body += chunk;
30
+ });
31
+ res.on('end', () => {
32
+ const status = res.statusCode ?? 0;
33
+ resolve({ ok: status >= 200 && status < 300, status, json: parseJson(body) });
24
34
  });
35
+ };
36
+ // A synchronous throw (e.g. an invalid header value) is a failed request, not a crash.
37
+ const start = (url, headers, resolve) => {
38
+ const get = getterFor(url);
39
+ if (!get)
40
+ return null;
41
+ try {
42
+ return get(url, { headers: { Accept: 'application/json', ...headers } }, (res) => collect(res, resolve));
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ };
48
+ // node:http(s), not fetch: undici's ref'd connect timer outlives an abort and stalls process exit.
49
+ export const fetchJsonUnref = (url, timeoutMs, headers = {}) => new Promise((resolve) => {
50
+ const req = start(url, headers, resolve);
51
+ if (!req)
52
+ return resolve(null);
25
53
  const timer = setTimeout(() => req.destroy(), timeoutMs);
26
54
  timer.unref();
27
55
  req.once('error', () => resolve(null));
@@ -0,0 +1,17 @@
1
+ export interface NpmResult {
2
+ code: number;
3
+ stdout: string;
4
+ stderr: string;
5
+ }
6
+ export interface RunNpmOptions {
7
+ cwd?: string;
8
+ env?: NodeJS.ProcessEnv;
9
+ timeoutMs: number;
10
+ }
11
+ export interface ResolveNpmOptions {
12
+ execPath?: string;
13
+ env?: NodeJS.ProcessEnv;
14
+ }
15
+ export declare const resolveNpmCli: (opts?: ResolveNpmOptions) => string;
16
+ export declare const runNpm: (args: string[], opts: RunNpmOptions) => Promise<NpmResult>;
17
+ export declare const requireNpm10: (run?: typeof runNpm) => Promise<string>;
@@ -0,0 +1,77 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync, realpathSync } from 'node:fs';
3
+ import { delimiter, dirname, join } from 'node:path';
4
+ const NPM_CLI = join('npm', 'bin', 'npm-cli.js');
5
+ const NPM_MIN_MAJOR = 10;
6
+ const VERSION_TIMEOUT_MS = 30_000;
7
+ const MAX_BUFFER = 16 * 1024 * 1024;
8
+ // POSIX prefixes keep npm in <prefix>/lib/node_modules; the Windows installer puts it beside node.exe.
9
+ const bundledCandidates = (execPath) => [
10
+ join(dirname(execPath), '..', 'lib', 'node_modules', NPM_CLI),
11
+ join(dirname(execPath), 'node_modules', NPM_CLI),
12
+ ];
13
+ // Homebrew-style installs keep npm apart from node; their `npm` on PATH links to npm-cli.js.
14
+ const fromPath = (env) => {
15
+ for (const dir of (env['PATH'] ?? '').split(delimiter)) {
16
+ if (!dir)
17
+ continue;
18
+ try {
19
+ const real = realpathSync(join(dir, 'npm'));
20
+ if (real.endsWith('npm-cli.js'))
21
+ return real;
22
+ }
23
+ catch {
24
+ // no npm in this PATH entry
25
+ }
26
+ }
27
+ return undefined;
28
+ };
29
+ // npm runs as `node npm-cli.js`: no shell and no platform shim (npm.cmd) in between.
30
+ export const resolveNpmCli = (opts = {}) => {
31
+ const execPath = opts.execPath ?? process.execPath;
32
+ const env = opts.env ?? process.env;
33
+ const bundled = bundledCandidates(execPath).find((path) => existsSync(path));
34
+ if (bundled)
35
+ return bundled;
36
+ const fromEnv = env['npm_execpath'];
37
+ if (fromEnv?.endsWith('npm-cli.js'))
38
+ return fromEnv;
39
+ const onPath = fromPath(env);
40
+ if (onPath)
41
+ return onPath;
42
+ throw new Error(`npm-cli.js not found next to ${execPath}`);
43
+ };
44
+ const isScrubbed = (key) => /^npm_config_/i.test(key) || key.toUpperCase() === 'NODE_OPTIONS';
45
+ // Inherited npm_config_* (an `npx` or `npm run` parent) would redirect a global install.
46
+ const childEnv = (env = {}) => ({
47
+ ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !isScrubbed(key))),
48
+ ...env,
49
+ });
50
+ const failureNote = (err, timeoutMs) => {
51
+ if (!err || typeof err.code === 'number')
52
+ return '';
53
+ if (err.killed)
54
+ return `\nnpm timed out after ${timeoutMs}ms`;
55
+ return `\nnpm could not run (${String(err.code ?? err.signal ?? 'unknown')})`;
56
+ };
57
+ export const runNpm = (args, opts) => new Promise((resolve) => {
58
+ const argv = [resolveNpmCli(), ...args, '--no-audit', '--no-fund'];
59
+ const options = {
60
+ cwd: opts.cwd,
61
+ env: childEnv(opts.env),
62
+ timeout: opts.timeoutMs,
63
+ maxBuffer: MAX_BUFFER,
64
+ windowsHide: true,
65
+ };
66
+ execFile(process.execPath, argv, options, (err, stdout, stderr) => {
67
+ const code = !err ? 0 : typeof err.code === 'number' ? err.code : 1;
68
+ resolve({ code, stdout, stderr: stderr + failureNote(err, opts.timeoutMs) });
69
+ });
70
+ });
71
+ export const requireNpm10 = async (run = runNpm) => {
72
+ const res = await run(['--version'], { timeoutMs: VERSION_TIMEOUT_MS });
73
+ const version = res.stdout.trim();
74
+ if (res.code !== 0 || !(Number.parseInt(version, 10) >= NPM_MIN_MAJOR))
75
+ throw new Error(`npm ${NPM_MIN_MAJOR} or newer is required (found ${version || 'none'})`);
76
+ return version;
77
+ };
@@ -1,5 +1,6 @@
1
1
  import { AuthRequiredError, introspectToken } from '../auth/api.js';
2
2
  import { detectEnvMismatch } from '../auth/env-match.js';
3
+ import {} from '../fs/config.js';
3
4
  import { fetchJsonUnref } from '../net/http.js';
4
5
  import { requestHeaders } from '../net/user-agent.js';
5
6
  import { environment, links } from '../net/origins.js';
@@ -2,6 +2,7 @@ export interface UpdateCache {
2
2
  checkedAt: number;
3
3
  latest: string | null;
4
4
  }
5
+ export declare const isUpdateCache: (value: unknown) => value is UpdateCache;
5
6
  export declare const readUpdateCache: () => UpdateCache | null;
6
7
  export declare const updateHint: () => string | null;
7
8
  export declare const fetchLatestVersion: (timeoutMs: number, url?: string) => Promise<string | null>;