@agentage/cli 0.0.3 → 0.0.4

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 (158) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +67 -25
  3. package/dist/cli.d.ts +0 -1
  4. package/dist/cli.js +44 -4
  5. package/dist/commands/{setup.d.ts → auth/setup.d.ts} +6 -3
  6. package/dist/commands/{setup.js → auth/setup.js} +56 -11
  7. package/dist/commands/daemon/daemon-cmd.d.ts +2 -0
  8. package/dist/commands/daemon/daemon-cmd.js +124 -0
  9. package/dist/commands/memory/memory-verbs.d.ts +21 -0
  10. package/dist/commands/memory/memory-verbs.js +98 -0
  11. package/dist/commands/memory/memory.d.ts +2 -0
  12. package/dist/commands/memory/memory.js +54 -0
  13. package/dist/commands/{status.d.ts → status/status.d.ts} +1 -2
  14. package/dist/commands/status/status.js +86 -0
  15. package/dist/commands/update/update.d.ts +23 -0
  16. package/dist/commands/update/update.js +89 -0
  17. package/dist/commands/vault/vault-sync.d.ts +15 -0
  18. package/dist/commands/vault/vault-sync.js +100 -0
  19. package/dist/commands/vault/vault.d.ts +22 -0
  20. package/dist/commands/vault/vault.js +128 -0
  21. package/dist/daemon/actions.d.ts +5 -0
  22. package/dist/daemon/actions.js +22 -0
  23. package/dist/daemon/client-provider.d.ts +2 -0
  24. package/dist/daemon/client-provider.js +16 -0
  25. package/dist/daemon/guards.d.ts +3 -0
  26. package/dist/daemon/guards.js +13 -0
  27. package/dist/daemon/lifecycle.d.ts +17 -0
  28. package/dist/daemon/lifecycle.js +151 -0
  29. package/dist/daemon/mcp-http.d.ts +3 -0
  30. package/dist/daemon/mcp-http.js +34 -0
  31. package/dist/daemon/server.d.ts +26 -0
  32. package/dist/daemon/server.js +136 -0
  33. package/dist/daemon-entry.d.ts +7 -0
  34. package/dist/daemon-entry.js +128 -0
  35. package/dist/index.d.ts +0 -1
  36. package/dist/index.js +0 -1
  37. package/dist/lib/auth/api.d.ts +8 -0
  38. package/dist/lib/auth/api.js +97 -0
  39. package/dist/lib/auth/auth-errors.d.ts +7 -0
  40. package/dist/lib/auth/auth-errors.js +22 -0
  41. package/dist/lib/{callback-server.d.ts → auth/callback-server.d.ts} +0 -1
  42. package/dist/lib/{callback-server.js → auth/callback-server.js} +2 -2
  43. package/dist/lib/auth/env-match.d.ts +9 -0
  44. package/dist/lib/auth/env-match.js +17 -0
  45. package/dist/lib/auth/introspect.d.ts +7 -0
  46. package/dist/lib/auth/introspect.js +89 -0
  47. package/dist/lib/{oauth.d.ts → auth/oauth.d.ts} +6 -2
  48. package/dist/lib/{oauth.js → auth/oauth.js} +26 -7
  49. package/dist/lib/auth/provision.d.ts +14 -0
  50. package/dist/lib/auth/provision.js +62 -0
  51. package/dist/lib/daemon/daemon-client.d.ts +39 -0
  52. package/dist/lib/daemon/daemon-client.js +134 -0
  53. package/dist/lib/daemon/daemon-pref.d.ts +2 -0
  54. package/dist/lib/daemon/daemon-pref.js +10 -0
  55. package/dist/lib/{config.d.ts → fs/config.d.ts} +1 -1
  56. package/dist/lib/fs/config.js +55 -0
  57. package/dist/lib/fs/file-lock.d.ts +3 -0
  58. package/dist/lib/fs/file-lock.js +163 -0
  59. package/dist/lib/memory/memory-client.d.ts +30 -0
  60. package/dist/lib/memory/memory-client.js +98 -0
  61. package/dist/lib/net/http.d.ts +6 -0
  62. package/dist/lib/net/http.js +32 -0
  63. package/dist/lib/{origins.d.ts → net/origins.d.ts} +1 -1
  64. package/dist/lib/net/origins.js +39 -0
  65. package/dist/lib/net/user-agent.d.ts +6 -0
  66. package/dist/lib/net/user-agent.js +7 -0
  67. package/dist/lib/status/format.d.ts +1 -0
  68. package/dist/lib/status/format.js +12 -0
  69. package/dist/lib/status/status-info.d.ts +45 -0
  70. package/dist/lib/status/status-info.js +107 -0
  71. package/dist/lib/status/vaults-format.d.ts +2 -0
  72. package/dist/lib/status/vaults-format.js +44 -0
  73. package/dist/lib/status/vaults-status.d.ts +12 -0
  74. package/dist/lib/status/vaults-status.js +53 -0
  75. package/dist/lib/update/update-cache.d.ts +14 -0
  76. package/dist/lib/update/update-cache.js +63 -0
  77. package/dist/lib/update/update-check.d.ts +27 -0
  78. package/dist/lib/update/update-check.js +50 -0
  79. package/dist/lib/update/update-lock.d.ts +2 -0
  80. package/dist/lib/update/update-lock.js +109 -0
  81. package/dist/lib/vault/vault-registry.d.ts +12 -0
  82. package/dist/lib/vault/vault-registry.js +82 -0
  83. package/dist/lib/vault/vaults.d.ts +10 -0
  84. package/dist/lib/vault/vaults.js +66 -0
  85. package/dist/lib/vault/vaults.schema.d.ts +2 -0
  86. package/dist/lib/vault/vaults.schema.js +10 -0
  87. package/dist/mcp/local-server.d.ts +4 -0
  88. package/dist/mcp/local-server.js +26 -0
  89. package/dist/sync/couch/cycle.d.ts +2 -0
  90. package/dist/sync/couch/cycle.js +58 -0
  91. package/dist/sync/couch/discovery.d.ts +22 -0
  92. package/dist/sync/couch/discovery.js +34 -0
  93. package/dist/sync/couch/file-store.d.ts +2 -0
  94. package/dist/sync/couch/file-store.js +47 -0
  95. package/dist/sync/couch/local-commit.d.ts +2 -0
  96. package/dist/sync/couch/local-commit.js +24 -0
  97. package/dist/sync/couch/manager.d.ts +4 -0
  98. package/dist/sync/couch/manager.fixtures.d.ts +20 -0
  99. package/dist/sync/couch/manager.fixtures.js +56 -0
  100. package/dist/sync/couch/manager.js +126 -0
  101. package/dist/sync/couch/manager.types.d.ts +81 -0
  102. package/dist/sync/couch/manager.types.js +1 -0
  103. package/dist/sync/couch/mutation-target.d.ts +5 -0
  104. package/dist/sync/couch/mutation-target.js +33 -0
  105. package/dist/sync/couch/push-on-write.d.ts +3 -0
  106. package/dist/sync/couch/push-on-write.js +33 -0
  107. package/dist/sync/couch/state-store.d.ts +3 -0
  108. package/dist/sync/couch/state-store.js +28 -0
  109. package/dist/sync/couch/targets.d.ts +9 -0
  110. package/dist/sync/couch/targets.js +23 -0
  111. package/dist/sync/couch/wire.d.ts +6 -0
  112. package/dist/sync/couch/wire.js +16 -0
  113. package/dist/sync/discover/watcher.d.ts +26 -0
  114. package/dist/sync/discover/watcher.js +159 -0
  115. package/dist/sync/git/conflict.d.ts +1 -0
  116. package/dist/sync/git/conflict.js +16 -0
  117. package/dist/sync/git/cycle.d.ts +18 -0
  118. package/dist/sync/git/cycle.js +158 -0
  119. package/dist/sync/git/git-exec.d.ts +22 -0
  120. package/dist/sync/git/git-exec.js +56 -0
  121. package/dist/sync/git/manager.d.ts +30 -0
  122. package/dist/sync/git/manager.js +114 -0
  123. package/dist/sync/git/planner.d.ts +15 -0
  124. package/dist/sync/git/planner.js +46 -0
  125. package/dist/sync/git/remote-url.d.ts +3 -0
  126. package/dist/sync/git/remote-url.js +30 -0
  127. package/dist/utils/version.d.ts +0 -1
  128. package/dist/utils/version.js +0 -1
  129. package/package.json +20 -7
  130. package/dist/cli.d.ts.map +0 -1
  131. package/dist/cli.js.map +0 -1
  132. package/dist/commands/setup.d.ts.map +0 -1
  133. package/dist/commands/setup.js.map +0 -1
  134. package/dist/commands/status.d.ts.map +0 -1
  135. package/dist/commands/status.js +0 -36
  136. package/dist/commands/status.js.map +0 -1
  137. package/dist/index.d.ts.map +0 -1
  138. package/dist/index.js.map +0 -1
  139. package/dist/lib/api.d.ts +0 -12
  140. package/dist/lib/api.d.ts.map +0 -1
  141. package/dist/lib/api.js +0 -46
  142. package/dist/lib/api.js.map +0 -1
  143. package/dist/lib/callback-server.d.ts.map +0 -1
  144. package/dist/lib/callback-server.js.map +0 -1
  145. package/dist/lib/config.d.ts.map +0 -1
  146. package/dist/lib/config.js +0 -34
  147. package/dist/lib/config.js.map +0 -1
  148. package/dist/lib/oauth.d.ts.map +0 -1
  149. package/dist/lib/oauth.js.map +0 -1
  150. package/dist/lib/origins.d.ts.map +0 -1
  151. package/dist/lib/origins.js +0 -26
  152. package/dist/lib/origins.js.map +0 -1
  153. package/dist/lib/status-info.d.ts +0 -18
  154. package/dist/lib/status-info.d.ts.map +0 -1
  155. package/dist/lib/status-info.js +0 -39
  156. package/dist/lib/status-info.js.map +0 -1
  157. package/dist/utils/version.d.ts.map +0 -1
  158. package/dist/utils/version.js.map +0 -1
@@ -0,0 +1,9 @@
1
+ import { type AuthState } from '../fs/config.js';
2
+ import { type Env } from '../net/origins.js';
3
+ export interface AuthEnvMismatch {
4
+ credentialFqdn: string;
5
+ credentialEnv: Env;
6
+ targetFqdn: string;
7
+ targetEnv: Env;
8
+ }
9
+ export declare const detectEnvMismatch: (auth: AuthState, targetFqdn: string) => AuthEnvMismatch | null;
@@ -0,0 +1,17 @@
1
+ import { environment, normalizeFqdn } from '../net/origins.js';
2
+ // The stored credential belongs to the environment it was issued for (auth.siteFqdn); the CLI's
3
+ // current target comes from AGENTAGE_SITE_FQDN (production default). Introspecting a dev credential
4
+ // against production rejects it and misreads as "expired" - so detect the mismatch by normalized
5
+ // fqdn BEFORE any introspection. Returns null when the credential matches the current target.
6
+ export const detectEnvMismatch = (auth, targetFqdn) => {
7
+ const credentialFqdn = normalizeFqdn(auth.siteFqdn);
8
+ const target = normalizeFqdn(targetFqdn);
9
+ if (credentialFqdn === target)
10
+ return null;
11
+ return {
12
+ credentialFqdn,
13
+ credentialEnv: environment(credentialFqdn),
14
+ targetFqdn: target,
15
+ targetEnv: environment(target),
16
+ };
17
+ };
@@ -0,0 +1,7 @@
1
+ import { type AuthState } from '../fs/config.js';
2
+ import { type Links } from '../net/origins.js';
3
+ export interface TokenSession {
4
+ userId?: string;
5
+ expiresAt?: string;
6
+ }
7
+ export declare const introspectToken: (auth: AuthState, links: Links) => Promise<TokenSession>;
@@ -0,0 +1,89 @@
1
+ import { authedGet, refreshOrThrow } from './api.js';
2
+ import { AuthRequiredError, TransientAuthError } from './auth-errors.js';
3
+ const isPast = (iso) => {
4
+ if (!iso)
5
+ return false;
6
+ const t = Date.parse(iso);
7
+ return !Number.isNaN(t) && t <= Date.now();
8
+ };
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.
16
+ const getSession = async (auth, links) => {
17
+ const url = `${links.auth}/api/auth/mcp/get-session`;
18
+ try {
19
+ return await authedGet(auth, links, url);
20
+ }
21
+ catch (err) {
22
+ if (err instanceof AuthRequiredError)
23
+ throw err;
24
+ throw new TransientAuthError('get-session temporarily failed');
25
+ }
26
+ };
27
+ const mapSession = (body) => ({
28
+ userId: body.userId,
29
+ expiresAt: body.accessTokenExpiresAt,
30
+ });
31
+ const isUnexpired = (auth) => auth.tokens.expiresAt !== undefined && auth.tokens.expiresAt > Date.now();
32
+ // A cosmetic freshen when get-session returns a present-but-past-expiry body: refresh to display the
33
+ // post-refresh expiry, but tolerate a transient refresh failure by keeping the present body.
34
+ const freshenIfStale = async (auth, links, body) => {
35
+ if (!isPast(body.accessTokenExpiresAt))
36
+ return mapSession(body);
37
+ try {
38
+ await refreshOrThrow(auth, links);
39
+ }
40
+ catch (err) {
41
+ if (err instanceof TransientAuthError)
42
+ return mapSession(body); // keep the last good session
43
+ throw err;
44
+ }
45
+ return mapSession((await getSession(auth, links)) ?? body);
46
+ };
47
+ // The OAuth introspection endpoint: the only live surface that validates the CLI's bearer today
48
+ // (backend REST accepts session cookies only). get-session answers 200 + null (not 401) for an
49
+ // inactive session, so the on-401 refresh never fires here. TERMINAL failures (401 / dead grant)
50
+ // throw AuthRequiredError; TRANSIENT ones (429/5xx/network/timeout) throw TransientAuthError so the
51
+ // caller renders a temporary note, never "session expired".
52
+ const introspectOnce = async (auth, links) => {
53
+ const expired = auth.tokens.expiresAt !== undefined && auth.tokens.expiresAt <= Date.now();
54
+ if (expired)
55
+ await refreshOrThrow(auth, links); // throws AuthRequired or Transient by class
56
+ // With an unexpired token, a transient get-session is not proof of a dead session: we still hold a
57
+ // valid bearer, so report signed-in against the stored expiry rather than surfacing the blip.
58
+ const holdUnexpired = isUnexpired(auth);
59
+ let body;
60
+ try {
61
+ body = await getSession(auth, links);
62
+ }
63
+ catch (err) {
64
+ if (holdUnexpired && err instanceof TransientAuthError)
65
+ return { expiresAt: new Date(auth.tokens.expiresAt).toISOString() };
66
+ throw err;
67
+ }
68
+ // 200 + null = definitive no-session: refresh is decisive (terminal -> expired, transient -> blip).
69
+ if (!body) {
70
+ await refreshOrThrow(auth, links);
71
+ const after = await getSession(auth, links);
72
+ if (!after)
73
+ throw new AuthRequiredError('no active session');
74
+ return mapSession(after);
75
+ }
76
+ return freshenIfStale(auth, links, body);
77
+ };
78
+ // A retry-once with a short unref'd backoff absorbs a lone transient blip; a terminal error skips it.
79
+ export const introspectToken = async (auth, links) => {
80
+ try {
81
+ return await introspectOnce(auth, links);
82
+ }
83
+ catch (err) {
84
+ if (err instanceof AuthRequiredError)
85
+ throw err; // terminal: no point retrying a dead session
86
+ await sleep(300);
87
+ return introspectOnce(auth, links); // one transient-only retry; a 2nd transient propagates
88
+ }
89
+ };
@@ -20,6 +20,11 @@ export declare const buildAuthorizeUrl: (authUrl: string, params: {
20
20
  state: string;
21
21
  scope?: string;
22
22
  }) => string;
23
+ export declare class TokenRequestError extends Error {
24
+ readonly status: number;
25
+ readonly oauthError?: string | undefined;
26
+ constructor(status: number, oauthError?: string | undefined);
27
+ }
23
28
  export declare const exchangeCode: (authUrl: string, params: {
24
29
  clientId: string;
25
30
  code: string;
@@ -27,5 +32,4 @@ export declare const exchangeCode: (authUrl: string, params: {
27
32
  verifier: string;
28
33
  }) => Promise<TokenResponse>;
29
34
  export declare const refreshTokens: (authUrl: string, clientId: string, refreshToken: string) => Promise<TokenResponse>;
30
- export declare const revokeToken: (authUrl: string, token: string) => Promise<void>;
31
- //# sourceMappingURL=oauth.d.ts.map
35
+ export declare const revokeToken: (authUrl: string, token: string, timeoutMs?: number) => Promise<void>;
@@ -1,4 +1,7 @@
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
5
  const REGISTER_PATH = '/api/auth/mcp/register';
3
6
  const AUTHORIZE_PATH = '/api/auth/mcp/authorize';
4
7
  const TOKEN_PATH = '/api/auth/mcp/token';
@@ -13,7 +16,7 @@ export const randomState = () => randomBytes(16).toString('base64url');
13
16
  export const registerClient = async (authUrl, redirectUri) => {
14
17
  const res = await fetch(`${authUrl}${REGISTER_PATH}`, {
15
18
  method: 'POST',
16
- headers: { 'content-type': 'application/json' },
19
+ headers: { 'content-type': 'application/json', ...cliHeaders() },
17
20
  body: JSON.stringify({
18
21
  client_name: 'agentage CLI',
19
22
  redirect_uris: [redirectUri],
@@ -40,14 +43,28 @@ export const buildAuthorizeUrl = (authUrl, params) => {
40
43
  url.searchParams.set('scope', params.scope ?? DEFAULT_SCOPE);
41
44
  return url.toString();
42
45
  };
46
+ // A token-endpoint failure that carries the HTTP status and any RFC 6749 `error` code so the
47
+ // caller can tell a dead grant (invalid_grant/invalid_client/401) from a transient blip (429/5xx).
48
+ export class TokenRequestError extends Error {
49
+ status;
50
+ oauthError;
51
+ constructor(status, oauthError) {
52
+ super(`token request failed (${status}${oauthError ? ` ${oauthError}` : ''})`);
53
+ this.status = status;
54
+ this.oauthError = oauthError;
55
+ this.name = 'TokenRequestError';
56
+ }
57
+ }
43
58
  const postForm = async (url, form) => {
44
59
  const res = await fetch(url, {
45
60
  method: 'POST',
46
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
61
+ headers: { 'content-type': 'application/x-www-form-urlencoded', ...cliHeaders() },
47
62
  body: new URLSearchParams(form).toString(),
48
63
  });
49
- if (!res.ok)
50
- throw new Error(`token request failed (${res.status})`);
64
+ if (!res.ok) {
65
+ const body = (await res.json().catch(() => null));
66
+ throw new TokenRequestError(res.status, body?.error);
67
+ }
51
68
  return (await res.json());
52
69
  };
53
70
  export const exchangeCode = (authUrl, params) => postForm(`${authUrl}${TOKEN_PATH}`, {
@@ -62,16 +79,18 @@ export const refreshTokens = (authUrl, clientId, refreshToken) => postForm(`${au
62
79
  refresh_token: refreshToken,
63
80
  client_id: clientId,
64
81
  });
65
- export const revokeToken = async (authUrl, token) => {
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) => {
66
85
  try {
67
86
  await fetch(`${authUrl}${REVOKE_PATH}`, {
68
87
  method: 'POST',
69
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
88
+ headers: { 'content-type': 'application/x-www-form-urlencoded', ...cliHeaders() },
70
89
  body: new URLSearchParams({ token }).toString(),
90
+ signal: AbortSignal.timeout(timeoutMs),
71
91
  });
72
92
  }
73
93
  catch {
74
94
  // best-effort: local credentials are removed regardless
75
95
  }
76
96
  };
77
- //# sourceMappingURL=oauth.js.map
@@ -0,0 +1,14 @@
1
+ import { type AuthState } from '../fs/config.js';
2
+ import { type Links } from '../net/origins.js';
3
+ export type ProvisionStatus = 'provisioned' | 'exists' | 'disabled' | 'conflict' | 'unauthenticated' | 'offline';
4
+ export interface ProvisionResult {
5
+ status: ProvisionStatus;
6
+ message: string;
7
+ }
8
+ export interface ProvisionDeps {
9
+ readAuth: () => AuthState | null;
10
+ links: () => Links;
11
+ post: (auth: AuthState, links: Links, url: string, body: unknown) => Promise<Response>;
12
+ }
13
+ export declare const defaultProvisionDeps: () => ProvisionDeps;
14
+ export declare const provisionAccountVault: (name: string, deps?: ProvisionDeps) => Promise<ProvisionResult>;
@@ -0,0 +1,62 @@
1
+ import { authedPost } from './api.js';
2
+ import { readAuth } from '../fs/config.js';
3
+ import { links as buildLinks, siteFqdn } from '../net/origins.js';
4
+ export const defaultProvisionDeps = () => ({
5
+ readAuth,
6
+ links: () => buildLinks(siteFqdn()),
7
+ post: authedPost,
8
+ });
9
+ // The API error envelope carries the code as `error.code` (or a top-level `code`); read it best
10
+ // effort - a non-JSON body just yields undefined and the caller falls back to non-fatal.
11
+ const errorCode = async (res) => {
12
+ try {
13
+ const body = (await res.json());
14
+ return body.error?.code ?? body.code;
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ };
20
+ const registeredLocally = (name, tail) => `Vault '${name}' registered locally${tail}`;
21
+ export const provisionAccountVault = async (name, deps = defaultProvisionDeps()) => {
22
+ const auth = deps.readAuth();
23
+ if (!auth) {
24
+ return {
25
+ status: 'unauthenticated',
26
+ message: registeredLocally(name, ' - run `agentage setup` to sync.'),
27
+ };
28
+ }
29
+ const links = deps.links();
30
+ let res;
31
+ try {
32
+ res = await deps.post(auth, links, `${links.api}/memories`, { name, channel: 'couch' });
33
+ }
34
+ catch {
35
+ return {
36
+ status: 'offline',
37
+ message: registeredLocally(name, ' - will provision when online.'),
38
+ };
39
+ }
40
+ if (res.status === 201)
41
+ return { status: 'provisioned', message: `Provisioned account vault '${name}'.` };
42
+ if (res.status === 200)
43
+ return { status: 'exists', message: `Account vault '${name}' already provisioned.` };
44
+ if (res.status === 401)
45
+ return {
46
+ status: 'unauthenticated',
47
+ message: registeredLocally(name, ' - run `agentage setup` to sync.'),
48
+ };
49
+ const code = await errorCode(res);
50
+ if (res.status === 403 && code === 'CHANNEL_DISABLED')
51
+ return {
52
+ status: 'disabled',
53
+ message: registeredLocally(name, '. Account sync is not enabled on this server.'),
54
+ };
55
+ if (res.status === 409 && code === 'CHANNEL_CONFLICT')
56
+ return {
57
+ status: 'conflict',
58
+ message: registeredLocally(name, `. A memory named '${name}' already exists on another channel - not syncing.`),
59
+ };
60
+ // Any other status stays non-fatal: keep the local entry, let the daemon retry later.
61
+ return { status: 'offline', message: registeredLocally(name, ' - will provision when online.') };
62
+ };
@@ -0,0 +1,39 @@
1
+ import { type SyncResult } from '../../sync/git/cycle.js';
2
+ import { type CouchSyncResult } from '../../sync/couch/manager.js';
3
+ import { type SyncStatus } from '../../sync/git/manager.js';
4
+ export type SyncRunResult = SyncResult | CouchSyncResult;
5
+ import { type MemoryClient } from '../memory/memory-client.js';
6
+ export interface Health {
7
+ ok: boolean;
8
+ version: string;
9
+ pid: number;
10
+ uptime: number;
11
+ served: number;
12
+ mcp?: boolean;
13
+ }
14
+ export declare const health: (port: number, timeoutMs?: number) => Promise<Health | null>;
15
+ export declare const syncStatus: (port: number, timeoutMs?: number) => Promise<SyncStatus | null>;
16
+ export declare const syncRun: (port: number, vault: string) => Promise<SyncRunResult>;
17
+ export declare const waitForHealth: (port: number, opts?: {
18
+ timeoutMs?: number;
19
+ intervalMs?: number;
20
+ }) => Promise<boolean>;
21
+ export declare const createDaemonClient: (port: number) => MemoryClient;
22
+ export declare const mismatchNotice: (daemonVersion: string) => string | null;
23
+ export type SpawnOutcome = {
24
+ ok: true;
25
+ } | {
26
+ ok: false;
27
+ reason: 'port-in-use' | 'unreachable' | 'blocked';
28
+ };
29
+ export declare const spawnDaemon: (port: number, opts?: {
30
+ timeoutMs?: number;
31
+ noMcp?: boolean;
32
+ }) => Promise<SpawnOutcome>;
33
+ export interface EnsureDeps {
34
+ port?: number;
35
+ probe?: (port: number) => Promise<Health | null>;
36
+ spawn?: (port: number) => Promise<SpawnOutcome>;
37
+ readToken?: () => string | null;
38
+ }
39
+ export declare const ensureDaemon: (deps?: EnsureDeps) => Promise<MemoryClient | null>;
@@ -0,0 +1,134 @@
1
+ import { spawn as spawnChild } from 'node:child_process';
2
+ import { fileURLToPath } from 'node:url';
3
+ import chalk from 'chalk';
4
+ import { EADDRINUSE_EXIT_CODE, readDaemonToken, resolvePort } from '../../daemon/lifecycle.js';
5
+ import { VERSION } from '../../utils/version.js';
6
+ const base = (port) => `http://127.0.0.1:${port}`;
7
+ // The 0600 token file is the daemon's auth: read it fresh per call so a restart's new token is
8
+ // picked up; absent means we cannot authenticate, so /api/* will 401 and callers fall back.
9
+ const apiHeaders = () => {
10
+ const token = readDaemonToken();
11
+ return { 'Content-Type': 'application/json', ...(token ? { 'X-Agentage-Token': token } : {}) };
12
+ };
13
+ const post = async (port, verb, body) => {
14
+ const res = await fetch(`${base(port)}/api/memory/${verb}`, {
15
+ method: 'POST',
16
+ headers: apiHeaders(),
17
+ body: JSON.stringify(body),
18
+ });
19
+ if (!res.ok) {
20
+ const data = (await res.json().catch(() => ({})));
21
+ throw new Error(data.error || `daemon request failed: ${res.status}`);
22
+ }
23
+ return res.json();
24
+ };
25
+ export const health = async (port, timeoutMs = 1000) => {
26
+ try {
27
+ const res = await fetch(`${base(port)}/api/health`, {
28
+ signal: AbortSignal.timeout(timeoutMs),
29
+ });
30
+ return res.ok ? (await res.json()) : null;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ };
36
+ // Read the daemon's per-vault sync state (last run, last error); null when the endpoint is absent
37
+ // (an older daemon) or unreachable.
38
+ export const syncStatus = async (port, timeoutMs = 1000) => {
39
+ try {
40
+ const res = await fetch(`${base(port)}/api/sync/status`, {
41
+ headers: apiHeaders(),
42
+ signal: AbortSignal.timeout(timeoutMs),
43
+ });
44
+ return res.ok ? (await res.json()) : null;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ };
50
+ // Ask the daemon to sync one vault now; the daemon runs the cycle in its own process. The result
51
+ // shape depends on the vault's channel (git SyncResult vs account CouchSyncResult).
52
+ export const syncRun = async (port, vault) => {
53
+ const res = await fetch(`${base(port)}/api/sync/run`, {
54
+ method: 'POST',
55
+ headers: apiHeaders(),
56
+ body: JSON.stringify({ vault }),
57
+ });
58
+ if (!res.ok) {
59
+ const data = (await res.json().catch(() => ({})));
60
+ throw new Error(data.error || `sync request failed: ${res.status}`);
61
+ }
62
+ return res.json();
63
+ };
64
+ export const waitForHealth = async (port, opts = {}) => {
65
+ const intervalMs = opts.intervalMs ?? 100;
66
+ const deadline = Date.now() + (opts.timeoutMs ?? 4000);
67
+ while (Date.now() < deadline) {
68
+ if (await health(port, intervalMs + 200))
69
+ return true;
70
+ await new Promise((r) => setTimeout(r, intervalMs));
71
+ }
72
+ return false;
73
+ };
74
+ // A thin MemoryClient over HTTP: every method is a pass-through POST; the daemon's in-process
75
+ // DirectClient does the vault scoping, so the wire carries the raw ref + opts unchanged.
76
+ export const createDaemonClient = (port) => ({
77
+ search: (query, opts) => post(port, 'search', { query, opts }),
78
+ read: (ref, opts) => post(port, 'read', { ref, opts }),
79
+ write: (ref, body, opts) => post(port, 'write', { ref, body, opts }),
80
+ edit: (ref, op, opts) => post(port, 'edit', { ref, op, opts }),
81
+ list: (folder, opts) => post(port, 'list', { folder, opts }),
82
+ delete: (ref, opts) => post(port, 'delete', { ref, opts }),
83
+ });
84
+ // A restart hint when the running daemon predates the current binary.
85
+ export const mismatchNotice = (daemonVersion) => daemonVersion === VERSION
86
+ ? null
87
+ : `daemon version ${daemonVersion} != cli ${VERSION}; restart with: agentage daemon stop && agentage daemon start`;
88
+ const entryPath = () => fileURLToPath(new URL('../../daemon-entry.js', import.meta.url));
89
+ // Detached spawn (not fork: fork's IPC channel keeps the parent event loop alive past unref) so
90
+ // the daemon outlives this CLI; ignore stdio + unref so the CLI can exit. Watches the child's exit
91
+ // so a fast EADDRINUSE death short-circuits the health wait instead of burning the full timeout.
92
+ export const spawnDaemon = async (port, opts = {}) => {
93
+ let child;
94
+ try {
95
+ child = spawnChild(process.execPath, [entryPath()], {
96
+ detached: true,
97
+ stdio: 'ignore',
98
+ env: {
99
+ ...process.env,
100
+ AGENTAGE_DAEMON_PORT: String(port),
101
+ ...(opts.noMcp ? { AGENTAGE_DAEMON_NO_MCP: '1' } : {}),
102
+ },
103
+ });
104
+ }
105
+ catch {
106
+ return { ok: false, reason: 'blocked' };
107
+ }
108
+ const exited = new Promise((resolve) => {
109
+ child.once('exit', (code) => resolve({ ok: false, reason: code === EADDRINUSE_EXIT_CODE ? 'port-in-use' : 'unreachable' }));
110
+ });
111
+ child.unref();
112
+ const healthy = waitForHealth(port, { timeoutMs: opts.timeoutMs ?? 4000 }).then((ok) => (ok ? { ok: true } : { ok: false, reason: 'unreachable' }));
113
+ return Promise.race([healthy, exited]);
114
+ };
115
+ // DO3/DO4/DO9: prefer a live daemon, autostart one if absent, and return null when it is
116
+ // unreachable, cannot be spawned, or has no readable token (nothing to authenticate with) so the
117
+ // caller falls back to the in-process DirectClient.
118
+ export const ensureDaemon = async (deps = {}) => {
119
+ const port = deps.port ?? resolvePort();
120
+ const probe = deps.probe ?? health;
121
+ const spawn = deps.spawn ?? spawnDaemon;
122
+ const readToken = deps.readToken ?? readDaemonToken;
123
+ const existing = await probe(port);
124
+ if (existing) {
125
+ if (!readToken())
126
+ return null;
127
+ const notice = mismatchNotice(existing.version);
128
+ if (notice)
129
+ console.error(chalk.yellow(notice));
130
+ return createDaemonClient(port);
131
+ }
132
+ const outcome = await spawn(port).catch(() => ({ ok: false, reason: 'blocked' }));
133
+ return outcome.ok && readToken() ? createDaemonClient(port) : null;
134
+ };
@@ -0,0 +1,2 @@
1
+ export declare const disableDaemon: () => void;
2
+ export declare const daemonDisabled: () => boolean;
@@ -0,0 +1,10 @@
1
+ // Whether the memory verbs must run in-process (DirectClient) instead of via the daemon.
2
+ // Set by the `--no-daemon` global flag or AGENTAGE_NO_DAEMON=1 (tests/sandbox/CI).
3
+ let forced = false;
4
+ export const disableDaemon = () => {
5
+ forced = true;
6
+ };
7
+ export const daemonDisabled = () => {
8
+ const env = process.env['AGENTAGE_NO_DAEMON'];
9
+ return forced || env === '1' || env === 'true';
10
+ };
@@ -17,4 +17,4 @@ export declare const ensureConfigDir: () => string;
17
17
  export declare const readAuth: () => AuthState | null;
18
18
  export declare const saveAuth: (state: AuthState) => void;
19
19
  export declare const deleteAuth: () => void;
20
- //# sourceMappingURL=config.d.ts.map
20
+ export declare const mutateAuth: (fn: (current: AuthState | null) => AuthState | null | void) => Promise<AuthState | null>;
@@ -0,0 +1,55 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { withFileLock } from './file-lock.js';
6
+ export const getConfigDir = () => process.env['AGENTAGE_CONFIG_DIR'] || join(process.env['HOME'] || homedir(), '.agentage');
7
+ export const ensureConfigDir = () => {
8
+ const dir = getConfigDir();
9
+ if (!existsSync(dir))
10
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
11
+ return dir;
12
+ };
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.
28
+ export const saveAuth = (state) => {
29
+ 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);
35
+ };
36
+ export const deleteAuth = () => {
37
+ const path = authPath();
38
+ if (existsSync(path))
39
+ unlinkSync(path);
40
+ };
41
+ // Cross-process-safe read-modify-write on auth.json (issue #231). Under the advisory lock, re-read
42
+ // FRESH from disk, apply `fn`, then atomic-save - so a foreground sign-in and a background token
43
+ // refresh can never clobber each other. `fn` may return a new state, mutate the fresh one and return
44
+ // void, or leave a null re-read null (a concurrent sign-out is never resurrected). Any network
45
+ // refresh must run BEFORE this call, never under the lock. Returns the state on disk after the call.
46
+ export const mutateAuth = async (fn) => {
47
+ ensureConfigDir();
48
+ return withFileLock(authPath(), () => {
49
+ const current = readAuth();
50
+ const next = fn(current) ?? current;
51
+ if (next)
52
+ saveAuth(next);
53
+ return next;
54
+ });
55
+ };
@@ -0,0 +1,3 @@
1
+ export declare const acquireFileLock: (target: string, now?: number) => boolean;
2
+ export declare const releaseFileLock: (target: string) => void;
3
+ export declare const withFileLock: <T>(target: string, fn: () => T) => Promise<T>;