@agentage/cli 0.37.2 → 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.
Files changed (109) hide show
  1. package/README.md +53 -83
  2. package/dist/cli.js +5 -8
  3. package/dist/commands/auth/setup.d.ts +6 -16
  4. package/dist/commands/auth/setup.js +9 -139
  5. package/dist/commands/status/status.d.ts +4 -3
  6. package/dist/commands/status/status.js +4 -27
  7. package/dist/commands/update/update.d.ts +3 -10
  8. package/dist/commands/update/update.js +24 -41
  9. package/dist/core/flag.d.ts +1 -0
  10. package/dist/core/flag.js +1 -0
  11. package/dist/lib/auth/api.d.ts +3 -2
  12. package/dist/lib/auth/api.js +50 -56
  13. package/dist/lib/auth/env-match.js +1 -0
  14. package/dist/lib/auth/introspect.js +10 -11
  15. package/dist/lib/auth/oauth.d.ts +2 -0
  16. package/dist/lib/auth/oauth.js +30 -28
  17. package/dist/lib/auth/sign-in.d.ts +20 -0
  18. package/dist/lib/auth/sign-in.js +95 -0
  19. package/dist/lib/fs/config.d.ts +1 -0
  20. package/dist/lib/fs/config.js +14 -21
  21. package/dist/lib/fs/file-lock.d.ts +7 -2
  22. package/dist/lib/fs/file-lock.js +64 -74
  23. package/dist/lib/fs/json-file.d.ts +3 -0
  24. package/dist/lib/fs/json-file.js +43 -0
  25. package/dist/lib/net/fetch.d.ts +10 -0
  26. package/dist/lib/net/fetch.js +13 -0
  27. package/dist/lib/net/http.js +51 -23
  28. package/dist/lib/net/origins.d.ts +0 -2
  29. package/dist/lib/net/origins.js +1 -5
  30. package/dist/lib/net/user-agent.d.ts +1 -6
  31. package/dist/lib/net/user-agent.js +3 -4
  32. package/dist/lib/npm/npm-cli.d.ts +17 -0
  33. package/dist/lib/npm/npm-cli.js +77 -0
  34. package/dist/lib/status/status-info.d.ts +0 -18
  35. package/dist/lib/status/status-info.js +2 -42
  36. package/dist/lib/update/update-cache.d.ts +1 -0
  37. package/dist/lib/update/update-cache.js +7 -11
  38. package/dist/lib/update/update-check.js +1 -4
  39. package/dist/plugin.d.ts +1 -0
  40. package/dist/plugin.js +2 -0
  41. package/dist/program.d.ts +18 -1
  42. package/dist/program.js +75 -44
  43. package/package.json +17 -12
  44. package/dist/commands/daemon/daemon-cmd.d.ts +0 -2
  45. package/dist/commands/daemon/daemon-cmd.js +0 -107
  46. package/dist/commands/memory/memory-verbs.d.ts +0 -21
  47. package/dist/commands/memory/memory-verbs.js +0 -98
  48. package/dist/commands/memory/memory.d.ts +0 -2
  49. package/dist/commands/memory/memory.js +0 -54
  50. package/dist/commands/vault/vault-sync.d.ts +0 -13
  51. package/dist/commands/vault/vault-sync.js +0 -90
  52. package/dist/commands/vault/vault.d.ts +0 -22
  53. package/dist/commands/vault/vault.js +0 -129
  54. package/dist/daemon/actions.d.ts +0 -5
  55. package/dist/daemon/actions.js +0 -22
  56. package/dist/daemon/client-provider.d.ts +0 -2
  57. package/dist/daemon/client-provider.js +0 -16
  58. package/dist/daemon/entry.d.ts +0 -8
  59. package/dist/daemon/entry.js +0 -113
  60. package/dist/daemon/guards.d.ts +0 -3
  61. package/dist/daemon/guards.js +0 -13
  62. package/dist/daemon/lifecycle.d.ts +0 -17
  63. package/dist/daemon/lifecycle.js +0 -151
  64. package/dist/daemon/mcp-http.d.ts +0 -3
  65. package/dist/daemon/mcp-http.js +0 -34
  66. package/dist/daemon/server.d.ts +0 -23
  67. package/dist/daemon/server.js +0 -135
  68. package/dist/daemon-entry.d.ts +0 -1
  69. package/dist/daemon-entry.js +0 -9
  70. package/dist/lib/auth/provision.d.ts +0 -14
  71. package/dist/lib/auth/provision.js +0 -49
  72. package/dist/lib/daemon/daemon-client.d.ts +0 -37
  73. package/dist/lib/daemon/daemon-client.js +0 -136
  74. package/dist/lib/daemon/daemon-pref.d.ts +0 -2
  75. package/dist/lib/daemon/daemon-pref.js +0 -10
  76. package/dist/lib/memory/memory-client.d.ts +0 -30
  77. package/dist/lib/memory/memory-client.js +0 -98
  78. package/dist/lib/status/format.d.ts +0 -1
  79. package/dist/lib/status/format.js +0 -12
  80. package/dist/lib/status/vaults-format.d.ts +0 -2
  81. package/dist/lib/status/vaults-format.js +0 -49
  82. package/dist/lib/status/vaults-status.d.ts +0 -12
  83. package/dist/lib/status/vaults-status.js +0 -54
  84. package/dist/lib/update/update-lock.d.ts +0 -2
  85. package/dist/lib/update/update-lock.js +0 -109
  86. package/dist/lib/vault/vault-registry.d.ts +0 -12
  87. package/dist/lib/vault/vault-registry.js +0 -81
  88. package/dist/lib/vault/vaults.d.ts +0 -10
  89. package/dist/lib/vault/vaults.js +0 -66
  90. package/dist/lib/vault/vaults.schema.d.ts +0 -2
  91. package/dist/lib/vault/vaults.schema.js +0 -10
  92. package/dist/mcp/local-server.d.ts +0 -4
  93. package/dist/mcp/local-server.js +0 -26
  94. package/dist/observability.d.ts +0 -4
  95. package/dist/observability.js +0 -35
  96. package/dist/sync/discover/watcher.d.ts +0 -26
  97. package/dist/sync/discover/watcher.js +0 -159
  98. package/dist/sync/git/conflict.d.ts +0 -1
  99. package/dist/sync/git/conflict.js +0 -16
  100. package/dist/sync/git/cycle.d.ts +0 -18
  101. package/dist/sync/git/cycle.js +0 -158
  102. package/dist/sync/git/git-exec.d.ts +0 -22
  103. package/dist/sync/git/git-exec.js +0 -56
  104. package/dist/sync/git/manager.d.ts +0 -28
  105. package/dist/sync/git/manager.js +0 -114
  106. package/dist/sync/git/planner.d.ts +0 -15
  107. package/dist/sync/git/planner.js +0 -46
  108. package/dist/sync/git/remote-url.d.ts +0 -3
  109. package/dist/sync/git/remote-url.js +0 -30
@@ -1,45 +1,52 @@
1
1
  import { mutateAuth } from '../fs/config.js';
2
- import { refreshTokens, TokenRequestError } from './oauth.js';
3
- import { requestHeaders } from '../net/user-agent.js';
2
+ import { cliFetch, isRedirect } from '../net/fetch.js';
3
+ import {} from '../net/origins.js';
4
4
  import { AuthRequiredError, isTerminalRefresh, TransientAuthError } from './auth-errors.js';
5
+ import { refreshTokens, TokenRequestError, tokensFrom } from './oauth.js';
5
6
  export { AuthRequiredError, TransientAuthError } from './auth-errors.js';
6
- // Every authed call is CLI-originated; identify the caller alongside the bearer.
7
- const cliHeaders = () => requestHeaders({ component: 'cli' });
8
- // Refresh the stored token in place. On success mutates auth + persists and returns. On failure it
9
- // throws: AuthRequiredError when the grant is dead (401/invalid_grant/invalid_client), else
10
- // TransientAuthError (429/5xx/network/timeout) - a blip that must not be read as a dead session.
11
- export const refreshOrThrow = async (auth, links) => {
12
- if (auth.kind === 'pat')
13
- throw new AuthRequiredError('personal access token expired or revoked - mint a new one in the dashboard (Settings -> API tokens)');
14
- if (!auth.tokens.refreshToken)
15
- throw new AuthRequiredError('no refresh token');
16
- let fresh;
7
+ const requestTokens = async (auth, links, rt) => {
17
8
  try {
18
- fresh = await refreshTokens(links.auth, auth.clientId, auth.tokens.refreshToken);
9
+ return tokensFrom(await refreshTokens(links.auth, auth.clientId, rt), rt);
19
10
  }
20
11
  catch (err) {
21
12
  if (err instanceof TokenRequestError && isTerminalRefresh(err.status, err.oauthError))
22
13
  throw new AuthRequiredError('session expired');
23
14
  throw new TransientAuthError('refresh temporarily failed');
24
15
  }
25
- const tokens = {
26
- accessToken: fresh.access_token,
27
- refreshToken: fresh.refresh_token ?? auth.tokens.refreshToken,
28
- expiresAt: fresh.expires_in ? Date.now() + fresh.expires_in * 1000 : undefined,
29
- };
30
- auth.tokens = tokens; // the caller retries its request with this in-memory copy
31
- // Persist under the lock, folding the new tokens onto the freshly-read state so a concurrent
32
- // writer's other fields (clientId, siteFqdn) are not clobbered by a stale in-memory copy. A null
33
- // re-read means a sign-out won the race: skip the write rather than resurrect it (issue #236).
16
+ };
17
+ // Never resurrect a sign-out (null) or overwrite a newer sign-in by another client.
18
+ const persistTokens = async (clientId, tokens) => {
34
19
  await mutateAuth((current) => {
35
- if (!current)
20
+ if (!current || current.clientId !== clientId)
36
21
  return null;
37
22
  current.tokens = tokens;
38
23
  return current;
39
24
  });
40
25
  };
41
- // Boolean shim for the request paths that just want "refresh once, then retry": any failure
42
- // (terminal or transient) collapses to false so they fall through to their own status handling.
26
+ const refreshAndPersist = async (auth, links, rt) => {
27
+ const tokens = await requestTokens(auth, links, rt);
28
+ await persistTokens(auth.clientId, tokens);
29
+ return tokens;
30
+ };
31
+ // Keyed by refresh token: parallel 401s share one grant instead of racing its rotation.
32
+ const inflight = new Map();
33
+ const sharedRefresh = (auth, links, rt) => {
34
+ const pending = inflight.get(rt);
35
+ if (pending)
36
+ return pending;
37
+ const next = refreshAndPersist(auth, links, rt).finally(() => inflight.delete(rt));
38
+ inflight.set(rt, next);
39
+ return next;
40
+ };
41
+ // Throws AuthRequiredError for a dead grant, TransientAuthError for a blip (429/5xx/network).
42
+ export const refreshOrThrow = async (auth, links) => {
43
+ if (auth.kind === 'pat')
44
+ throw new AuthRequiredError('personal access token expired or revoked - mint a new one in the dashboard (Settings -> API tokens)');
45
+ const rt = auth.tokens.refreshToken;
46
+ if (!rt)
47
+ throw new AuthRequiredError('no refresh token');
48
+ auth.tokens = await sharedRefresh(auth, links, rt); // the caller retries with this copy
49
+ };
43
50
  const tryRefresh = async (auth, links) => {
44
51
  try {
45
52
  await refreshOrThrow(auth, links);
@@ -49,41 +56,28 @@ const tryRefresh = async (auth, links) => {
49
56
  return false;
50
57
  }
51
58
  };
52
- // redirect: 'manual' so the bearer is never replayed to a redirect target; any 3xx is an error.
53
- const isRedirect = (res) => res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400);
54
- export const authedGet = async (auth, links, url) => {
55
- const call = () => fetch(url, {
56
- headers: { authorization: `Bearer ${auth.tokens.accessToken}`, ...cliHeaders() },
57
- redirect: 'manual',
58
- });
59
- let res = await call();
60
- if (res.status === 401 && (await tryRefresh(auth, links)))
61
- res = await call();
59
+ const withBearer = (auth, init) => ({
60
+ ...init,
61
+ headers: { ...init.headers, authorization: `Bearer ${auth.tokens.accessToken}` },
62
+ });
63
+ // The raw Response after at most one refresh-and-retry on 401; callers branch on the status.
64
+ export const authedFetch = async (auth, links, url, init = {}) => {
65
+ const res = await cliFetch(url, withBearer(auth, init));
66
+ if (res.status !== 401 || !(await tryRefresh(auth, links)))
67
+ return res;
68
+ await res.body?.cancel();
69
+ return cliFetch(url, withBearer(auth, init));
70
+ };
71
+ export const authedJson = async (auth, links, url, init = {}) => {
72
+ const method = init.method ?? 'GET';
73
+ const res = await authedFetch(auth, links, url, init);
62
74
  if (res.status === 401)
63
75
  throw new AuthRequiredError('session expired');
64
76
  if (isRedirect(res))
65
- throw new Error(`GET ${url} refused redirect (${res.status})`);
77
+ throw new Error(`${method} ${url} refused redirect (${res.status})`);
66
78
  if (!res.ok)
67
- throw new Error(`GET ${url} failed (${res.status})`);
79
+ throw new Error(`${method} ${url} failed (${res.status})`);
68
80
  return (await res.json());
69
81
  };
70
- // A bearer POST with the same refresh-once dance as authedGet. Unlike authedGet it returns the
71
- // raw Response (never throws on a non-2xx): callers branch on the status codes themselves.
72
- export const authedPost = async (auth, links, url, body) => {
73
- const call = () => fetch(url, {
74
- method: 'POST',
75
- headers: {
76
- authorization: `Bearer ${auth.tokens.accessToken}`,
77
- 'content-type': 'application/json',
78
- ...cliHeaders(),
79
- },
80
- body: JSON.stringify(body),
81
- redirect: 'manual',
82
- });
83
- let res = await call();
84
- if (res.status === 401 && (await tryRefresh(auth, links)))
85
- res = await call();
86
- return res;
87
- };
88
82
  // Re-export the introspection surface so existing callers keep importing from './api.js'.
89
83
  export { introspectToken } from './introspect.js';
@@ -1,3 +1,4 @@
1
+ import {} from '../fs/config.js';
1
2
  import { environment, normalizeFqdn } from '../net/origins.js';
2
3
  // The stored credential belongs to the environment it was issued for (auth.siteFqdn); the CLI's
3
4
  // current target comes from AGENTAGE_SITE_FQDN (production default). Introspecting a dev credential
@@ -1,4 +1,6 @@
1
- import { authedGet, refreshOrThrow } from './api.js';
1
+ import {} from '../fs/config.js';
2
+ import {} from '../net/origins.js';
3
+ import { authedJson, refreshOrThrow } from './api.js';
2
4
  import { AuthRequiredError, TransientAuthError } from './auth-errors.js';
3
5
  const isPast = (iso) => {
4
6
  if (!iso)
@@ -6,17 +8,14 @@ const isPast = (iso) => {
6
8
  const t = Date.parse(iso);
7
9
  return !Number.isNaN(t) && t <= Date.now();
8
10
  };
9
- const sleep = (ms) => new Promise((resolve) => {
10
- // unref so a pending backoff never keeps `status` from exiting.
11
- const timer = setTimeout(resolve, ms);
12
- timer.unref();
13
- });
14
- // Introspection over global fetch: a network error reflects as a transient (a blip must not read as
15
- // a dead session), never letting the raw error escape to gatherStatus. Only a 401 stays terminal.
11
+ const RETRY_BACKOFF_MS = 300;
12
+ // Ref'd on purpose: an unref'd backoff let Node exit 13 mid-retry once nothing else held the loop.
13
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
14
+ // Any failure but a 401 is a transient: a network blip must never read as a dead session.
16
15
  const getSession = async (auth, links) => {
17
16
  const url = `${links.auth}/api/auth/mcp/get-session`;
18
17
  try {
19
- return await authedGet(auth, links, url);
18
+ return await authedJson(auth, links, url);
20
19
  }
21
20
  catch (err) {
22
21
  if (err instanceof AuthRequiredError)
@@ -75,7 +74,7 @@ const introspectOnce = async (auth, links) => {
75
74
  }
76
75
  return freshenIfStale(auth, links, body);
77
76
  };
78
- // A retry-once with a short unref'd backoff absorbs a lone transient blip; a terminal error skips it.
77
+ // A retry-once with a short backoff absorbs a lone transient blip; a terminal error skips it.
79
78
  export const introspectToken = async (auth, links) => {
80
79
  try {
81
80
  return await introspectOnce(auth, links);
@@ -83,7 +82,7 @@ export const introspectToken = async (auth, links) => {
83
82
  catch (err) {
84
83
  if (err instanceof AuthRequiredError)
85
84
  throw err; // terminal: no point retrying a dead session
86
- await sleep(300);
85
+ await sleep(RETRY_BACKOFF_MS);
87
86
  return introspectOnce(auth, links); // one transient-only retry; a 2nd transient propagates
88
87
  }
89
88
  };
@@ -1,3 +1,4 @@
1
+ import { type StoredTokens } from '../fs/config.js';
1
2
  export interface PkcePair {
2
3
  verifier: string;
3
4
  challenge: string;
@@ -12,6 +13,7 @@ export interface TokenResponse {
12
13
  export declare const DEFAULT_SCOPE = "memory:read memory:write offline_access";
13
14
  export declare const pkcePair: () => PkcePair;
14
15
  export declare const randomState: () => string;
16
+ export declare const tokensFrom: (res: TokenResponse, previousRefresh?: string) => StoredTokens;
15
17
  export declare const registerClient: (authUrl: string, redirectUri: string) => Promise<string>;
16
18
  export declare const buildAuthorizeUrl: (authUrl: string, params: {
17
19
  clientId: string;
@@ -1,11 +1,11 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
- import { requestHeaders } from '../net/user-agent.js';
3
- // OAuth register/token/revoke are all CLI-originated; identify the caller on each.
4
- const cliHeaders = () => requestHeaders({ component: 'cli' });
2
+ import {} from '../fs/config.js';
3
+ import { AUTH_TIMEOUT_MS, cliFetch } from '../net/fetch.js';
5
4
  const REGISTER_PATH = '/api/auth/mcp/register';
6
5
  const AUTHORIZE_PATH = '/api/auth/mcp/authorize';
7
6
  const TOKEN_PATH = '/api/auth/mcp/token';
8
7
  const REVOKE_PATH = '/api/auth/mcp/revoke';
8
+ const REVOKE_TIMEOUT_MS = 3_000;
9
9
  export const DEFAULT_SCOPE = 'memory:read memory:write offline_access';
10
10
  export const pkcePair = () => {
11
11
  const verifier = randomBytes(32).toString('base64url');
@@ -13,18 +13,25 @@ export const pkcePair = () => {
13
13
  return { verifier, challenge };
14
14
  };
15
15
  export const randomState = () => randomBytes(16).toString('base64url');
16
+ // A refresh that does not rotate keeps the previous refresh token.
17
+ export const tokensFrom = (res, previousRefresh) => ({
18
+ accessToken: res.access_token,
19
+ refreshToken: res.refresh_token ?? previousRefresh,
20
+ expiresAt: res.expires_in ? Date.now() + res.expires_in * 1000 : undefined,
21
+ });
22
+ const registration = (redirectUri) => JSON.stringify({
23
+ client_name: 'agentage CLI',
24
+ redirect_uris: [redirectUri],
25
+ token_endpoint_auth_method: 'none',
26
+ grant_types: ['authorization_code', 'refresh_token'],
27
+ response_types: ['code'],
28
+ });
16
29
  export const registerClient = async (authUrl, redirectUri) => {
17
- const res = await fetch(`${authUrl}${REGISTER_PATH}`, {
30
+ const res = await cliFetch(`${authUrl}${REGISTER_PATH}`, {
18
31
  method: 'POST',
19
- headers: { 'content-type': 'application/json', ...cliHeaders() },
20
- body: JSON.stringify({
21
- client_name: 'agentage CLI',
22
- redirect_uris: [redirectUri],
23
- token_endpoint_auth_method: 'none',
24
- grant_types: ['authorization_code', 'refresh_token'],
25
- response_types: ['code'],
26
- }),
27
- });
32
+ headers: { 'content-type': 'application/json' },
33
+ body: registration(redirectUri),
34
+ }, { timeoutMs: AUTH_TIMEOUT_MS });
28
35
  if (!res.ok)
29
36
  throw new Error(`client registration failed (${res.status})`);
30
37
  const body = (await res.json());
@@ -55,12 +62,13 @@ export class TokenRequestError extends Error {
55
62
  this.name = 'TokenRequestError';
56
63
  }
57
64
  }
65
+ const formInit = (form) => ({
66
+ method: 'POST',
67
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
68
+ body: new URLSearchParams(form).toString(),
69
+ });
58
70
  const postForm = async (url, form) => {
59
- const res = await fetch(url, {
60
- method: 'POST',
61
- headers: { 'content-type': 'application/x-www-form-urlencoded', ...cliHeaders() },
62
- body: new URLSearchParams(form).toString(),
63
- });
71
+ const res = await cliFetch(url, formInit(form), { timeoutMs: AUTH_TIMEOUT_MS });
64
72
  if (!res.ok) {
65
73
  const body = (await res.json().catch(() => null));
66
74
  throw new TokenRequestError(res.status, body?.error);
@@ -79,18 +87,12 @@ export const refreshTokens = (authUrl, clientId, refreshToken) => postForm(`${au
79
87
  refresh_token: refreshToken,
80
88
  client_id: clientId,
81
89
  });
82
- // AbortSignal.timeout caps a stalled revoke on a packet-drop network; residual undici keepalive is
83
- // acceptable here since --disconnect exits right after and errors are swallowed anyway.
84
- export const revokeToken = async (authUrl, token, timeoutMs = 3000) => {
90
+ // Best-effort with a short cap: local credentials are removed whatever the server says.
91
+ export const revokeToken = async (authUrl, token, timeoutMs = REVOKE_TIMEOUT_MS) => {
85
92
  try {
86
- await fetch(`${authUrl}${REVOKE_PATH}`, {
87
- method: 'POST',
88
- headers: { 'content-type': 'application/x-www-form-urlencoded', ...cliHeaders() },
89
- body: new URLSearchParams({ token }).toString(),
90
- signal: AbortSignal.timeout(timeoutMs),
91
- });
93
+ await cliFetch(`${authUrl}${REVOKE_PATH}`, formInit({ token }), { timeoutMs });
92
94
  }
93
95
  catch {
94
- // best-effort: local credentials are removed regardless
96
+ // a stalled or refused revoke never blocks sign-out
95
97
  }
96
98
  };
@@ -0,0 +1,20 @@
1
+ import { startCallbackServer } from './callback-server.js';
2
+ import { introspectToken } from './introspect.js';
3
+ import { exchangeCode, registerClient, revokeToken } from './oauth.js';
4
+ export interface SignInOptions {
5
+ reauth?: boolean;
6
+ browser?: boolean;
7
+ }
8
+ export type SignInResult = 'signed-in' | 'already' | 'mismatch-resigned';
9
+ export interface SignInDeps {
10
+ register: typeof registerClient;
11
+ exchange: typeof exchangeCode;
12
+ revoke: typeof revokeToken;
13
+ startServer: typeof startCallbackServer;
14
+ openBrowser: (url: string) => Promise<void>;
15
+ introspect: typeof introspectToken;
16
+ log: (msg: string) => void;
17
+ }
18
+ export declare const signIn: (opts: SignInOptions, deps?: SignInDeps) => Promise<void>;
19
+ export declare const signOut: (deps?: SignInDeps) => Promise<void>;
20
+ export declare const ensureSignedIn: (opts: SignInOptions, deps?: SignInDeps) => Promise<SignInResult>;
@@ -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>;