@agentage/cli 0.0.2 → 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.
- package/CHANGELOG.md +68 -0
- package/README.md +67 -25
- package/dist/cli.d.ts +0 -1
- package/dist/cli.js +44 -4
- package/dist/commands/{setup.d.ts → auth/setup.d.ts} +6 -3
- package/dist/commands/{setup.js → auth/setup.js} +56 -11
- package/dist/commands/daemon/daemon-cmd.d.ts +2 -0
- package/dist/commands/daemon/daemon-cmd.js +124 -0
- package/dist/commands/memory/memory-verbs.d.ts +21 -0
- package/dist/commands/memory/memory-verbs.js +98 -0
- package/dist/commands/memory/memory.d.ts +2 -0
- package/dist/commands/memory/memory.js +54 -0
- package/dist/commands/{status.d.ts → status/status.d.ts} +1 -2
- package/dist/commands/status/status.js +86 -0
- package/dist/commands/update/update.d.ts +23 -0
- package/dist/commands/update/update.js +89 -0
- package/dist/commands/vault/vault-sync.d.ts +15 -0
- package/dist/commands/vault/vault-sync.js +100 -0
- package/dist/commands/vault/vault.d.ts +22 -0
- package/dist/commands/vault/vault.js +128 -0
- package/dist/daemon/actions.d.ts +5 -0
- package/dist/daemon/actions.js +22 -0
- package/dist/daemon/client-provider.d.ts +2 -0
- package/dist/daemon/client-provider.js +16 -0
- package/dist/daemon/guards.d.ts +3 -0
- package/dist/daemon/guards.js +13 -0
- package/dist/daemon/lifecycle.d.ts +17 -0
- package/dist/daemon/lifecycle.js +151 -0
- package/dist/daemon/mcp-http.d.ts +3 -0
- package/dist/daemon/mcp-http.js +34 -0
- package/dist/daemon/server.d.ts +26 -0
- package/dist/daemon/server.js +136 -0
- package/dist/daemon-entry.d.ts +7 -0
- package/dist/daemon-entry.js +128 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/lib/auth/api.d.ts +8 -0
- package/dist/lib/auth/api.js +97 -0
- package/dist/lib/auth/auth-errors.d.ts +7 -0
- package/dist/lib/auth/auth-errors.js +22 -0
- package/dist/lib/{callback-server.d.ts → auth/callback-server.d.ts} +0 -1
- package/dist/lib/{callback-server.js → auth/callback-server.js} +2 -2
- package/dist/lib/auth/env-match.d.ts +9 -0
- package/dist/lib/auth/env-match.js +17 -0
- package/dist/lib/auth/introspect.d.ts +7 -0
- package/dist/lib/auth/introspect.js +89 -0
- package/dist/lib/{oauth.d.ts → auth/oauth.d.ts} +6 -2
- package/dist/lib/{oauth.js → auth/oauth.js} +26 -7
- package/dist/lib/auth/provision.d.ts +14 -0
- package/dist/lib/auth/provision.js +62 -0
- package/dist/lib/daemon/daemon-client.d.ts +39 -0
- package/dist/lib/daemon/daemon-client.js +134 -0
- package/dist/lib/daemon/daemon-pref.d.ts +2 -0
- package/dist/lib/daemon/daemon-pref.js +10 -0
- package/dist/lib/{config.d.ts → fs/config.d.ts} +1 -1
- package/dist/lib/fs/config.js +55 -0
- package/dist/lib/fs/file-lock.d.ts +3 -0
- package/dist/lib/fs/file-lock.js +163 -0
- package/dist/lib/memory/memory-client.d.ts +30 -0
- package/dist/lib/memory/memory-client.js +98 -0
- package/dist/lib/net/http.d.ts +6 -0
- package/dist/lib/net/http.js +32 -0
- package/dist/lib/{origins.d.ts → net/origins.d.ts} +1 -1
- package/dist/lib/net/origins.js +39 -0
- package/dist/lib/net/user-agent.d.ts +6 -0
- package/dist/lib/net/user-agent.js +7 -0
- package/dist/lib/status/format.d.ts +1 -0
- package/dist/lib/status/format.js +12 -0
- package/dist/lib/status/status-info.d.ts +45 -0
- package/dist/lib/status/status-info.js +107 -0
- package/dist/lib/status/vaults-format.d.ts +2 -0
- package/dist/lib/status/vaults-format.js +44 -0
- package/dist/lib/status/vaults-status.d.ts +12 -0
- package/dist/lib/status/vaults-status.js +53 -0
- package/dist/lib/update/update-cache.d.ts +14 -0
- package/dist/lib/update/update-cache.js +63 -0
- package/dist/lib/update/update-check.d.ts +27 -0
- package/dist/lib/update/update-check.js +50 -0
- package/dist/lib/update/update-lock.d.ts +2 -0
- package/dist/lib/update/update-lock.js +109 -0
- package/dist/lib/vault/vault-registry.d.ts +12 -0
- package/dist/lib/vault/vault-registry.js +82 -0
- package/dist/lib/vault/vaults.d.ts +10 -0
- package/dist/lib/vault/vaults.js +66 -0
- package/dist/lib/vault/vaults.schema.d.ts +2 -0
- package/dist/lib/vault/vaults.schema.js +10 -0
- package/dist/mcp/local-server.d.ts +4 -0
- package/dist/mcp/local-server.js +26 -0
- package/dist/sync/couch/cycle.d.ts +2 -0
- package/dist/sync/couch/cycle.js +58 -0
- package/dist/sync/couch/discovery.d.ts +22 -0
- package/dist/sync/couch/discovery.js +34 -0
- package/dist/sync/couch/file-store.d.ts +2 -0
- package/dist/sync/couch/file-store.js +47 -0
- package/dist/sync/couch/local-commit.d.ts +2 -0
- package/dist/sync/couch/local-commit.js +24 -0
- package/dist/sync/couch/manager.d.ts +4 -0
- package/dist/sync/couch/manager.fixtures.d.ts +20 -0
- package/dist/sync/couch/manager.fixtures.js +56 -0
- package/dist/sync/couch/manager.js +126 -0
- package/dist/sync/couch/manager.types.d.ts +81 -0
- package/dist/sync/couch/manager.types.js +1 -0
- package/dist/sync/couch/mutation-target.d.ts +5 -0
- package/dist/sync/couch/mutation-target.js +33 -0
- package/dist/sync/couch/push-on-write.d.ts +3 -0
- package/dist/sync/couch/push-on-write.js +33 -0
- package/dist/sync/couch/state-store.d.ts +3 -0
- package/dist/sync/couch/state-store.js +28 -0
- package/dist/sync/couch/targets.d.ts +9 -0
- package/dist/sync/couch/targets.js +23 -0
- package/dist/sync/couch/wire.d.ts +6 -0
- package/dist/sync/couch/wire.js +16 -0
- package/dist/sync/discover/watcher.d.ts +26 -0
- package/dist/sync/discover/watcher.js +159 -0
- package/dist/sync/git/conflict.d.ts +1 -0
- package/dist/sync/git/conflict.js +16 -0
- package/dist/sync/git/cycle.d.ts +18 -0
- package/dist/sync/git/cycle.js +158 -0
- package/dist/sync/git/git-exec.d.ts +22 -0
- package/dist/sync/git/git-exec.js +56 -0
- package/dist/sync/git/manager.d.ts +30 -0
- package/dist/sync/git/manager.js +114 -0
- package/dist/sync/git/planner.d.ts +15 -0
- package/dist/sync/git/planner.js +46 -0
- package/dist/sync/git/remote-url.d.ts +3 -0
- package/dist/sync/git/remote-url.js +30 -0
- package/dist/utils/version.d.ts +0 -1
- package/dist/utils/version.js +0 -1
- package/package.json +20 -7
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/commands/setup.d.ts.map +0 -1
- package/dist/commands/setup.js.map +0 -1
- package/dist/commands/status.d.ts.map +0 -1
- package/dist/commands/status.js +0 -36
- package/dist/commands/status.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/lib/api.d.ts +0 -12
- package/dist/lib/api.d.ts.map +0 -1
- package/dist/lib/api.js +0 -43
- package/dist/lib/api.js.map +0 -1
- package/dist/lib/callback-server.d.ts.map +0 -1
- package/dist/lib/callback-server.js.map +0 -1
- package/dist/lib/config.d.ts.map +0 -1
- package/dist/lib/config.js +0 -34
- package/dist/lib/config.js.map +0 -1
- package/dist/lib/oauth.d.ts.map +0 -1
- package/dist/lib/oauth.js.map +0 -1
- package/dist/lib/origins.d.ts.map +0 -1
- package/dist/lib/origins.js +0 -26
- package/dist/lib/origins.js.map +0 -1
- package/dist/lib/status-info.d.ts +0 -18
- package/dist/lib/status-info.d.ts.map +0 -1
- package/dist/lib/status-info.js +0 -39
- package/dist/lib/status-info.js.map +0 -1
- package/dist/utils/version.d.ts.map +0 -1
- 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
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
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
|
+
};
|