agentlinkops 0.6.8 → 0.6.10
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +20 -0
- package/README.md +44 -12
- package/cli/admission.js +9 -1
- package/cli/agent-setup.js +189 -20
- package/cli/catalog.js +7938 -1357
- package/cli/client.js +89 -23
- package/cli/cloud-error.js +21 -0
- package/cli/commands.js +37 -6
- package/cli/connection.js +74 -12
- package/cli/doctor.js +24 -9
- package/cli/gsc-browser.js +19 -6
- package/cli/ledger.js +4 -7
- package/cli/lifecycle.js +5 -2
- package/cli/local-result.js +4 -4
- package/cli/main.js +43 -28
- package/cli/mcp-client.js +198 -0
- package/cli/native-lock.js +22 -0
- package/cli/oauth-credentials.js +196 -0
- package/cli/oauth-flow.js +259 -0
- package/cli/setup-plan.js +5 -5
- package/cli/status.js +14 -2
- package/cli/sync-lock.js +105 -0
- package/cli/sync.js +14 -6
- package/package.json +6 -3
- package/references/cli.md +22 -0
- package/references/local-crm-draft-handoff.md +76 -0
- package/references/portable-ledger-handoff.md +50 -0
- package/references/recipes/catalog.json +9 -9
- package/references/recipes/command-notes.md +17 -1
- package/references/recipes/placement-reconciliation.md +19 -5
- package/references/recipes/qualified-campaign-handoff.md +16 -4
- package/references/recipes/site-context-brief.md +2 -2
- package/references/recipes/sourced-linkable-asset.md +8 -8
- package/scripts/agentlinkops.py +30 -5
- package/scripts/outreach-crm-draft.py +121 -0
- package/scripts/portable-handoff.py +184 -0
- package/shared/exec-grammar.js +342 -0
- package/shared/lifecycle-contract.js +2 -2
- package/shared/response-contract.js +7 -1
- package/skills/agentlinkops-assets/SKILL.md +2 -2
- package/skills/agentlinkops-campaigns/SKILL.md +3 -3
- package/skills/agentlinkops-connect/SKILL.md +29 -6
- package/skills/agentlinkops-connect/references/agentlinkops.md +62 -26
- package/skills/agentlinkops-crm/SKILL.md +17 -3
- package/src/citations/adapter.js +3 -2
- package/src/citations/browser/chatgpt-citations.js +60 -6
- package/src/citations/browser/engine.js +106 -18
- package/src/citations/browser/shims/utils.js +9 -2
- package/src/citations/login.js +42 -5
- package/src/discovery/contract.js +1 -1
- package/src/response-shaping.js +58 -0
- package/src/tool-views.js +31 -4
package/cli/client.js
CHANGED
|
@@ -1,44 +1,110 @@
|
|
|
1
1
|
import {restResponseContract} from '../shared/response-contract.js';
|
|
2
|
+
import {CloudError} from './cloud-error.js';
|
|
3
|
+
import {createMcpClient} from './mcp-client.js';
|
|
4
|
+
export {CloudError} from './cloud-error.js';
|
|
2
5
|
// A small HTTP client for the cloud. Scoped keys only; no human session ever reaches here.
|
|
3
6
|
// DP-0036-T13: the CLI names itself so the usage counters can tell it from other REST callers.
|
|
4
7
|
// The version follows the plugin manifests; bump it with them.
|
|
5
|
-
export const CLI_VERSION = '0.6.
|
|
8
|
+
export const CLI_VERSION = '0.6.10';
|
|
6
9
|
export const CLI_USER_AGENT = `agentlinkops-cli/${CLI_VERSION}`;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
const MAX_ATTEMPTS = 3;
|
|
11
|
+
const MAX_RETRY_WAIT_MS = 5_000;
|
|
12
|
+
const retryableStatus = new Set([429, 503]);
|
|
13
|
+
const sleepDefault = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
14
|
+
const retryAfterMs = (value, now) => {
|
|
15
|
+
if (value == null || value === '') return null;
|
|
16
|
+
if (/^\d+$/u.test(value.trim())) return Number(value.trim()) * 1000;
|
|
17
|
+
const at = Date.parse(value);
|
|
18
|
+
return Number.isFinite(at) ? Math.max(0, at - now) : null;
|
|
19
|
+
};
|
|
20
|
+
const backoffMs = attempt => Math.min(MAX_RETRY_WAIT_MS, 250 * 2 ** (attempt - 1));
|
|
21
|
+
const isTimeout = error => ['TimeoutError', 'AbortError'].includes(error?.name) || error?.code === 'ETIMEDOUT';
|
|
22
|
+
export function createClient({ origin, token, workspaceId, projectId, fetchImpl = globalThis.fetch,
|
|
23
|
+
sleepImpl = sleepDefault, now = () => Date.now(), auth, credentialStore, createSdkClient, resolveHost } = {}) {
|
|
16
24
|
if (!origin) throw new CloudError('NO_CLOUD_ORIGIN', 0);
|
|
25
|
+
if (auth !== undefined && auth?.kind !== 'oauth') throw new CloudError('OAUTH_CONFIG_INVALID', 400);
|
|
26
|
+
if (auth?.kind === 'oauth') {
|
|
27
|
+
if (token) throw new CloudError('OAUTH_CREDENTIAL_CONFLICT', 400);
|
|
28
|
+
return createMcpClient({origin, workspaceId, projectId, fetchImpl, sleepImpl, now, auth, credentialStore, createSdkClient, resolveHost});
|
|
29
|
+
}
|
|
17
30
|
if (!token) throw new CloudError('NO_TOKEN', 0);
|
|
18
31
|
const base = origin.replace(/\/$/u, '');
|
|
19
32
|
async function call(method, path, { body = null, query = null } = {}) {
|
|
20
33
|
const url = new URL(base + path);
|
|
21
34
|
for (const [key, value] of Object.entries(query ?? {})) if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
const safeRead = method.toUpperCase() === 'GET';
|
|
36
|
+
let response, text, retryAfter = null, requestId = null;
|
|
37
|
+
for (let attempt = 1; attempt <= (safeRead ? MAX_ATTEMPTS : 1); attempt++) {
|
|
38
|
+
response = undefined; text = undefined; retryAfter = null; requestId = null;
|
|
39
|
+
try {
|
|
40
|
+
response = await fetchImpl(url.href, {
|
|
41
|
+
method, redirect: 'error', signal: AbortSignal.timeout(30000),
|
|
42
|
+
headers: {
|
|
43
|
+
Authorization: `Bearer ${token}`,
|
|
44
|
+
'User-Agent': CLI_USER_AGENT,
|
|
45
|
+
...(workspaceId ? { 'X-Workspace-ID': workspaceId } : {}),
|
|
46
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
47
|
+
},
|
|
48
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
49
|
+
});
|
|
50
|
+
retryAfter = response.headers.get('Retry-After');
|
|
51
|
+
requestId = response.headers.get('X-Request-ID') ?? requestId;
|
|
52
|
+
text = await response.text();
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (safeRead && attempt < MAX_ATTEMPTS) {
|
|
55
|
+
if (response && retryableStatus.has(response.status)) {
|
|
56
|
+
const serverDelay = retryAfterMs(retryAfter, now());
|
|
57
|
+
const delay = serverDelay === null ? backoffMs(attempt)
|
|
58
|
+
: serverDelay <= MAX_RETRY_WAIT_MS ? serverDelay : null;
|
|
59
|
+
if (delay !== null) {
|
|
60
|
+
await sleepImpl(delay);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
throw new CloudError(`HTTP_${response.status}`, response.status, null,
|
|
64
|
+
'The server returned a temporary response and requested a longer wait. Retry after the stated interval.',
|
|
65
|
+
null, { retryAfter, requestId });
|
|
66
|
+
}
|
|
67
|
+
await sleepImpl(backoffMs(attempt));
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (response && !response.ok) {
|
|
71
|
+
throw new CloudError(`HTTP_${response.status}`, response.status, null,
|
|
72
|
+
'The server response could not be read completely. Check the request result before trying again.',
|
|
73
|
+
null, { retryAfter, requestId });
|
|
74
|
+
}
|
|
75
|
+
const timedOut = isTimeout(error);
|
|
76
|
+
const code = timedOut ? 'CLOUD_TIMEOUT' : 'CLOUD_NETWORK_ERROR';
|
|
77
|
+
const message = timedOut
|
|
78
|
+
? safeRead ? `The cloud read timed out after ${attempt} attempt${attempt === 1 ? '' : 's'}. Check the connection before trying again.` : 'The cloud request timed out. Check the operation result before sending it again.'
|
|
79
|
+
: safeRead ? `The cloud connection failed after ${attempt} read attempt${attempt === 1 ? '' : 's'}. Check the connection before trying again.` : 'The cloud connection failed before the request returned a response. Check the operation result before sending it again.';
|
|
80
|
+
throw new CloudError(code, 0, { attempts: attempt }, message, null, { requestId, retryAfter });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (safeRead && retryableStatus.has(response.status) && attempt < MAX_ATTEMPTS) {
|
|
84
|
+
const serverDelay = retryAfterMs(retryAfter, now());
|
|
85
|
+
const delay = serverDelay === null ? backoffMs(attempt)
|
|
86
|
+
: serverDelay <= MAX_RETRY_WAIT_MS ? serverDelay : null;
|
|
87
|
+
// A Retry-After longer than our bounded wait is never shortened into an early retry.
|
|
88
|
+
if (delay !== null) {
|
|
89
|
+
await sleepImpl(delay);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
33
95
|
let payload = null;
|
|
34
96
|
try { payload = text ? JSON.parse(text) : null; } catch { payload = null; }
|
|
35
97
|
if (!response.ok) {
|
|
36
98
|
// The error BODY is the useful part — a 410 carries the resync instructions, and losing
|
|
37
99
|
// them would turn a recoverable expiry into "the sync failed, try again forever".
|
|
38
|
-
|
|
100
|
+
const code = payload?.error?.code ?? `HTTP_${response.status}`;
|
|
101
|
+
const id = payload?.error?.requestId ?? requestId;
|
|
102
|
+
const envelope = payload?.error ? { ...payload.error } : null;
|
|
103
|
+
throw new CloudError(code, response.status, payload?.error?.details ?? null,
|
|
104
|
+
payload?.error?.message ?? null, envelope, { retryAfter, requestId: id });
|
|
39
105
|
}
|
|
40
106
|
const parsed=restResponseContract(path,method).safeParse(payload);
|
|
41
|
-
if(!parsed.success)throw new CloudError('INVALID_RESPONSE',502);
|
|
107
|
+
if(!parsed.success)throw new CloudError('INVALID_RESPONSE',502,null,null,null,{requestId});
|
|
42
108
|
return parsed.data;
|
|
43
109
|
}
|
|
44
110
|
return {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export class CloudError extends Error {
|
|
2
|
+
constructor(code, status, details = null, serverMessage = null, envelope = null, metadata = {}) {
|
|
3
|
+
super(`${code}${status ? ` (${status})` : ''}`);
|
|
4
|
+
this.name = 'CloudError'; this.code = code; this.status = status; this.details = details;
|
|
5
|
+
// DP-0063-T03: the server's message says what to fix; keep it for the CLI to print.
|
|
6
|
+
this.serverMessage = typeof serverMessage === 'string' ? serverMessage : null;
|
|
7
|
+
// Keep the public server envelope structured; the CLI must not stringify issues into a message.
|
|
8
|
+
this.retryAfter = metadata.retryAfter ?? null;
|
|
9
|
+
this.requestId = metadata.requestId ?? envelope?.requestId ?? null;
|
|
10
|
+
this.publicError = envelope ? { ...envelope,
|
|
11
|
+
...(this.requestId && !envelope.requestId ? { requestId: this.requestId } : {}),
|
|
12
|
+
...(this.retryAfter !== null ? { retryAfter: this.retryAfter } : {}),
|
|
13
|
+
} : this.requestId || this.retryAfter !== null || this.serverMessage ? {
|
|
14
|
+
code, ...(this.serverMessage ? { message: this.serverMessage } : {}),
|
|
15
|
+
...(this.details ? { details: this.details } : {}),
|
|
16
|
+
...(this.requestId ? { requestId: this.requestId } : {}),
|
|
17
|
+
...(this.retryAfter !== null ? { retryAfter: this.retryAfter } : {}),
|
|
18
|
+
} : null;
|
|
19
|
+
this.exitCode = 2;
|
|
20
|
+
}
|
|
21
|
+
}
|
package/cli/commands.js
CHANGED
|
@@ -7,17 +7,20 @@ import { parseArgs } from './args.js';
|
|
|
7
7
|
import { NUDGE } from './skill.js';
|
|
8
8
|
import { CATALOG_COMMANDS, CATALOG_TOOLSETS } from './catalog.js';
|
|
9
9
|
import { oneLiner } from '../src/tool-views.js';
|
|
10
|
+
import { resolveSchemaPath, listAvailablePaths } from '../shared/exec-grammar.js';
|
|
10
11
|
|
|
11
12
|
// DP-0036-T05: the CLI's three-level discovery over the cloud catalog — index (`tools`),
|
|
12
13
|
// describe (`describe NAME`) and call (`call NAME`). `tools` and `describe` answer from the
|
|
13
14
|
// bundled snapshot in cli/catalog.js with no account and no network; `--refresh` reads the
|
|
14
15
|
// live catalog instead. Output is JSON whenever stdout is not a terminal or --json is given,
|
|
15
|
-
// and
|
|
16
|
-
|
|
16
|
+
// and structured public errors. JSON preserves the public envelope; terminal rendering can
|
|
17
|
+
// supply local guidance when the server has none.
|
|
18
|
+
const USAGE = 'tools [TOOLSET] [--json] [--refresh] | describe NAME [--examples] [--output-schema] [--json] [--refresh] | schema NAME [FIELD.PATH] [--json] | call NAME [--args JSON | --file FILE | --set PATH=VALUE ...] [--dry-run] [-y] [--json]';
|
|
17
19
|
const NAME = /^[a-z][a-z0-9_]*$/;
|
|
18
20
|
const NEXT = {
|
|
19
21
|
TOOL_NOT_FOUND: 'Run `agentlinkops tools` to list commands, or `agentlinkops tools TOOLSET` for one group.',
|
|
20
22
|
INVALID_INPUT: 'Run `agentlinkops describe NAME` for the exact input schema.',
|
|
23
|
+
SCHEMA_PATH_NOT_FOUND: 'Run `agentlinkops schema NAME` for the top level, or drill a listed child path.',
|
|
21
24
|
INSUFFICIENT_SCOPE: 'This credential lacks a scope the command needs; request it through your connection or a new project-scoped key.',
|
|
22
25
|
NO_CLOUD_ORIGIN: 'Run `agentlinkops connect` or set AGENTLINKOPS_API_URL.',
|
|
23
26
|
INVALID_CLOUD_ORIGIN: 'Cloud commands need an HTTPS origin without a path, for example https://app.agentlinkops.com.',
|
|
@@ -102,8 +105,20 @@ export async function commandsMain(argv, { cwd = process.cwd(), env = process.en
|
|
|
102
105
|
const [command, name] = args._;
|
|
103
106
|
const json = args.json === true || !isTTY;
|
|
104
107
|
const fail = (code, message) => { if (json) err(errorJson(code, message, explain(code))); else err(`${message}\n${explain(code)}`); return 2; };
|
|
105
|
-
const
|
|
106
|
-
|
|
108
|
+
const cloudFail = (error, context) => {
|
|
109
|
+
if (!error.publicError) return fail(error.code, `${context}: ${error.code}${error.status ? ` (${error.status})` : ''}`);
|
|
110
|
+
if (json) {
|
|
111
|
+
err(JSON.stringify({ error: error.publicError }));
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const next = [error.publicError.next, error.details?.next].find(value => typeof value === 'string' && value.trim()) ?? explain(error.code);
|
|
115
|
+
err(`${context}: ${error.code}${error.status ? ` (${error.status})` : ''}\n${error.serverMessage ?? error.code}\n${next}`);
|
|
116
|
+
}
|
|
117
|
+
return 2;
|
|
118
|
+
};
|
|
119
|
+
const allowed = { tools: ['_', 'json', 'refresh'], describe: ['_', 'json', 'refresh', 'examples', 'output-schema'], schema: ['_', 'json'], call: ['_', 'args', 'file', 'set', 'dry-run', 'json'] };
|
|
120
|
+
const positionalCount = command === 'tools' ? args._.length > 2 : command === 'schema' ? args._.length !== 2 && args._.length !== 3 : args._.length !== 2;
|
|
121
|
+
if (!allowed[command] || Object.keys(args).some(key => !allowed[command].includes(key)) || positionalCount || (command !== 'tools' && !NAME.test(name))) throw new ConfigError(USAGE);
|
|
107
122
|
if (args.set?.length && (args.set.some(v => typeof v !== 'string') || args.args !== undefined || args.file !== undefined)) throw new ConfigError('--set cannot be combined with --args or --file');
|
|
108
123
|
if (args.args !== undefined && args.file !== undefined) throw new ConfigError(USAGE);
|
|
109
124
|
|
|
@@ -139,7 +154,7 @@ export async function commandsMain(argv, { cwd = process.cwd(), env = process.en
|
|
|
139
154
|
const live = await client.listCommands();
|
|
140
155
|
commands = live.items.map(item => ({ ...item, readOnly: item.annotations?.readOnlyHint === true, admissionGated: false }));
|
|
141
156
|
} catch (error) {
|
|
142
|
-
if (error instanceof CloudError) return
|
|
157
|
+
if (error instanceof CloudError) return cloudFail(error, 'catalog refresh');
|
|
143
158
|
throw error;
|
|
144
159
|
}
|
|
145
160
|
}
|
|
@@ -160,6 +175,22 @@ export async function commandsMain(argv, { cwd = process.cwd(), env = process.en
|
|
|
160
175
|
out(renderDescription(found, name, { examples: args.examples === true, outputSchema: args['output-schema'] === true, json }));
|
|
161
176
|
return 0;
|
|
162
177
|
}
|
|
178
|
+
if (command === 'schema') {
|
|
179
|
+
// DP-0059: the same drill the MCP exec tool serves, over the same bundled schema, so one
|
|
180
|
+
// grammar holds across surfaces: `schema monitor_link scope.target` prints that field.
|
|
181
|
+
const found = resolveName(commands, name);
|
|
182
|
+
if (!found) return fail('TOOL_NOT_FOUND', `No command named ${name}.`);
|
|
183
|
+
const path = args._[2] ?? null;
|
|
184
|
+
if (!path) { out(JSON.stringify({ name: found.name, schema: found.inputSchema }, null, 2)); return 0; }
|
|
185
|
+
const subschema = resolveSchemaPath(found.inputSchema, path);
|
|
186
|
+
if (!subschema) {
|
|
187
|
+
const parent = resolveSchemaPath(found.inputSchema, path.split('.').slice(0, -1).join('.')) ?? found.inputSchema;
|
|
188
|
+
const available = listAvailablePaths(parent);
|
|
189
|
+
return fail('SCHEMA_PATH_NOT_FOUND', `"${path}" is not a path in ${found.name}'s schema.${available.length ? ` Available here: ${available.join(', ')}.` : ''}`);
|
|
190
|
+
}
|
|
191
|
+
out(JSON.stringify({ name: found.name, path, schema: subschema }, null, 2));
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
163
194
|
|
|
164
195
|
// call
|
|
165
196
|
const found = resolveName(commands, name);
|
|
@@ -177,7 +208,7 @@ export async function commandsMain(argv, { cwd = process.cwd(), env = process.en
|
|
|
177
208
|
out(JSON.stringify(result, null, 2));
|
|
178
209
|
return 0;
|
|
179
210
|
} catch (error) {
|
|
180
|
-
if (error instanceof CloudError) return
|
|
211
|
+
if (error instanceof CloudError) return cloudFail(error, canonical);
|
|
181
212
|
throw error;
|
|
182
213
|
}
|
|
183
214
|
}
|
package/cli/connection.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { acquireSyncLock } from './sync-lock.js';
|
|
1
2
|
import { readFile, writeFile, rename, unlink, open } from 'node:fs/promises';
|
|
2
3
|
import { join, resolve } from 'node:path';
|
|
3
4
|
import { loadConfig, ConfigError } from './config.js';
|
|
4
5
|
import { createClient } from './client.js';
|
|
5
6
|
import { readLedger } from './ledger.js';
|
|
6
7
|
import { resolveEnv } from './env.js';
|
|
8
|
+
import { createOAuthCredentialStore } from './oauth-credentials.js';
|
|
9
|
+
import { discoverOAuth, receiveOAuthConsent, exchangeOAuthCode } from './oauth-flow.js';
|
|
7
10
|
|
|
8
11
|
export const SYNC_SCOPES = ['projects:read', 'watches:read', 'watches:write', 'events:read', 'exports:create'];
|
|
9
12
|
|
|
@@ -19,18 +22,33 @@ export function cloudConnection(config, env = process.env) {
|
|
|
19
22
|
throw new ConfigError(`${sources.API_URL} disagrees with the saved cloud origin.`);
|
|
20
23
|
if (new Set([config.cloud?.workspaceId, config.cloud?.workspace_id].filter(Boolean)).size > 1 || new Set([config.project?.id, config.cloud?.projectId, config.cloud?.project_id].filter(Boolean)).size > 1)
|
|
21
24
|
throw new ConfigError('Saved cloud identity fields disagree.');
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
const origin = values.API_URL || config.cloud?.origin;
|
|
26
|
+
const token = values.TOKEN || values.API_KEY || config.cloud?.token;
|
|
27
|
+
const auth = config.cloud?.auth;
|
|
28
|
+
if (auth !== undefined) {
|
|
29
|
+
if (!auth || auth.kind !== 'oauth' || token || Object.keys(auth).sort().join(',') !== 'clientId,credentialRef,issuer,kind,resource')
|
|
30
|
+
throw new ConfigError('Saved OAuth connection conflicts with an API key or has an invalid authentication mode.');
|
|
31
|
+
let resource, issuer;
|
|
32
|
+
try { resource = new URL(auth.resource); issuer = new URL(auth.issuer); } catch { throw new ConfigError('Saved OAuth binding is invalid.'); }
|
|
33
|
+
if (resource.href !== `${origin}/mcp` || issuer.protocol !== 'https:' || auth.issuer !== issuer.origin
|
|
34
|
+
|| typeof auth.clientId !== 'string' || !auth.clientId || auth.clientId.length > 2048 || /[\s\x00-\x1f\x7f]/.test(auth.clientId)
|
|
35
|
+
|| !/^oauth_[a-f0-9]{32}$/.test(auth.credentialRef ?? ''))
|
|
36
|
+
throw new ConfigError('Saved OAuth binding is invalid.');
|
|
37
|
+
}
|
|
38
|
+
return { origin, ...(auth ? { auth: { ...auth } } : {}),
|
|
39
|
+
token,
|
|
24
40
|
tokenSource: values.TOKEN ? sources.TOKEN : values.API_KEY ? sources.API_KEY : null,
|
|
25
41
|
workspaceId: config.cloud?.workspaceId || config.cloud?.workspace_id, projectId: config.project?.id || config.cloud?.projectId || config.cloud?.project_id };
|
|
26
42
|
}
|
|
27
43
|
|
|
28
44
|
export function keySetup(origin) {
|
|
29
|
-
return `Open ${origin}/app, select the workspace, then Agent access > Create API key. Select the project and scopes ${SYNC_SCOPES.join(', ')}. Supply the key through AGENTLINKOPS_TOKEN (AGENTLINKOPS_API_KEY also works; the LINKTRAIL_ spellings remain accepted during the pilot compatibility window), then rerun connect.
|
|
45
|
+
return `Open ${origin}/app, select the workspace, then Agent access > Create API key. Select the project and scopes ${SYNC_SCOPES.join(', ')}. Supply the key through AGENTLINKOPS_TOKEN (AGENTLINKOPS_API_KEY also works; the LINKTRAIL_ spellings remain accepted during the pilot compatibility window), then rerun connect. For CLI OAuth on Linux, use connect --oauth with an existing public client; credentials stay in the user-owned POSIX-mode store. On other platforms, use OAuth in your MCP client. MCP clients manage their own credentials.`;
|
|
30
46
|
}
|
|
31
47
|
|
|
32
48
|
// Verify an existing project before saving only non-secret connection metadata.
|
|
33
|
-
export async function connectMain(args, { cwd, env = process.env, out = console.log, fetchImpl = globalThis.fetch
|
|
49
|
+
export async function connectMain(args, { cwd, env = process.env, out = console.log, fetchImpl = globalThis.fetch,
|
|
50
|
+
credentialStore = createOAuthCredentialStore(), oauth = { discoverOAuth, receiveOAuthConsent, exchangeOAuthCode },
|
|
51
|
+
onAuthorization = ({ authorizationUrl }) => out(`Authorize the connection: ${authorizationUrl}`), clientFactory = createClient } = {}) {
|
|
34
52
|
const config = await loadConfig({ cwd });
|
|
35
53
|
const connection = cloudConnection(config, env);
|
|
36
54
|
const origin = args.origin || connection.origin || 'https://app.agentlinkops.com';
|
|
@@ -40,8 +58,13 @@ export async function connectMain(args, { cwd, env = process.env, out = console.
|
|
|
40
58
|
try { parsed = new URL(origin); } catch { throw new ConfigError('connect --origin requires an HTTPS origin.'); }
|
|
41
59
|
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.search || parsed.hash || parsed.pathname !== '/')
|
|
42
60
|
throw new ConfigError('connect --origin requires an HTTPS origin without a path.');
|
|
43
|
-
|
|
44
|
-
if (
|
|
61
|
+
const oauthRequested = args.oauth === true || connection.auth?.kind === 'oauth';
|
|
62
|
+
if (args.oauth !== undefined && args.oauth !== true) throw new ConfigError('--oauth does not take a value.');
|
|
63
|
+
if (!oauthRequested && ['issuer', 'client-id', 'credential-ref'].some(key => args[key] !== undefined))
|
|
64
|
+
throw new ConfigError('OAuth options require --oauth.');
|
|
65
|
+
if (oauthRequested && connection.token) throw new ConfigError('Remove API-key credentials before selecting OAuth.');
|
|
66
|
+
if (!oauthRequested && !connection.token) { out(keySetup(parsed.origin)); return 2; }
|
|
67
|
+
if (!oauthRequested && !/^lt_[0-9a-f]{64}$/.test(connection.token)) throw new ConfigError('Supply a complete API key through AGENTLINKOPS_TOKEN or AGENTLINKOPS_API_KEY.');
|
|
45
68
|
if (typeof workspaceId !== 'string' || !workspaceId || typeof projectId !== 'string' || !projectId)
|
|
46
69
|
throw new ConfigError('connect requires --workspace ID and --project-id ID from the existing cloud project.');
|
|
47
70
|
if (config.cloud?.origin && new URL(config.cloud.origin).origin !== parsed.origin || connection.workspaceId && connection.workspaceId !== workspaceId || connection.projectId && connection.projectId !== projectId)
|
|
@@ -56,35 +79,74 @@ export async function connectMain(args, { cwd, env = process.env, out = console.
|
|
|
56
79
|
throw new ConfigError('Selection must name existing ledger IDs in a valid ledger.');
|
|
57
80
|
ledgerIds = [...new Set(ledgerIds)];
|
|
58
81
|
}
|
|
59
|
-
|
|
82
|
+
let auth = connection.auth, createdRef, committed = false;
|
|
83
|
+
if (oauthRequested) {
|
|
84
|
+
try { await credentialStore.checkAvailability?.(); }
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error?.code !== 'OAUTH_CREDENTIAL_STORAGE_UNSUPPORTED') throw error;
|
|
87
|
+
throw Object.assign(new ConfigError('CLI OAuth credential storage is not supported on this platform. Use an API-key connection or OAuth in your MCP client until native custody is verified.'), {code: error.code});
|
|
88
|
+
}
|
|
89
|
+
const issuer = args.issuer || auth?.issuer, clientId = args['client-id'] || auth?.clientId;
|
|
90
|
+
if (!issuer || !clientId) throw new ConfigError('OAuth requires --issuer URL and --client-id ID for an existing public client.');
|
|
91
|
+
const binding = { issuer, resource: `${parsed.origin}/mcp`, clientId };
|
|
92
|
+
if (auth && (auth.issuer !== issuer || auth.clientId !== clientId || auth.resource !== binding.resource
|
|
93
|
+
|| args['credential-ref'] && args['credential-ref'] !== auth.credentialRef))
|
|
94
|
+
throw new ConfigError('OAuth binding changed; use a separate ledger.');
|
|
95
|
+
const credentialRef = args['credential-ref'] || auth?.credentialRef;
|
|
96
|
+
if (credentialRef) {
|
|
97
|
+
await credentialStore.read(credentialRef, binding);
|
|
98
|
+
auth = { kind: 'oauth', ...binding, credentialRef };
|
|
99
|
+
} else {
|
|
100
|
+
const scopes = [...SYNC_SCOPES, 'offline_access'];
|
|
101
|
+
const plan = await oauth.discoverOAuth({ origin: parsed.origin, issuer, clientId, scopes, fetchImpl });
|
|
102
|
+
const authorization = await oauth.receiveOAuthConsent({ plan, scopes, onAuthorization });
|
|
103
|
+
const tokens = await oauth.exchangeOAuthCode({ plan, authorization, scopes, fetchImpl });
|
|
104
|
+
createdRef = await credentialStore.create(binding, tokens);
|
|
105
|
+
auth = { kind: 'oauth', ...binding, credentialRef: createdRef };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const client = clientFactory({ origin: parsed.origin, token: connection.token, ...(auth ? {auth, credentialStore} : {}), workspaceId, projectId, fetchImpl });
|
|
60
110
|
const account = await client.getWorkspace();
|
|
61
111
|
if (account.workspace?.id !== workspaceId) throw new ConfigError('Cloud workspace does not match the selected workspace.');
|
|
62
|
-
|
|
112
|
+
// MCP readback does not expose an authoritative effective write grant.
|
|
113
|
+
const missing = auth ? [] : SYNC_SCOPES.filter(scope => !account.access?.scopes?.includes(scope));
|
|
63
114
|
if (missing.length) throw new ConfigError(`Key lacks sync scopes: ${missing.join(', ')}.`);
|
|
64
115
|
const project = await client.getProject(projectId);
|
|
65
116
|
if (project.id !== projectId || project.workspace_id !== workspaceId) throw new ConfigError('Cloud project does not match the selected workspace.');
|
|
66
|
-
const
|
|
67
|
-
const lock = await open(lockPath, 'wx');
|
|
117
|
+
const lock = await acquireSyncLock(config.dir, 'connect');
|
|
68
118
|
const file = join(config.dir, 'config.json'), temporary = join(config.dir, `.connect-${crypto.randomUUID()}.tmp`);
|
|
69
119
|
try {
|
|
70
120
|
const before = await readFile(file, 'utf8');
|
|
71
121
|
const saved = JSON.parse(before);
|
|
72
122
|
const savedIdentity = cloudConnection(saved, {});
|
|
123
|
+
if (JSON.stringify(savedIdentity.auth ?? null) !== JSON.stringify(connection.auth ?? null))
|
|
124
|
+
throw new ConfigError('OAuth connection changed during verification; retry without replacing its credential binding.');
|
|
73
125
|
if (savedIdentity.workspaceId && savedIdentity.workspaceId !== workspaceId || savedIdentity.projectId && savedIdentity.projectId !== projectId)
|
|
74
126
|
throw new ConfigError('This ledger is connected to another workspace or project; use a separate ledger to preserve its cursors and mapping.');
|
|
75
127
|
if (saved.cloud?.origin && new URL(saved.cloud.origin).origin !== parsed.origin)
|
|
76
128
|
throw new ConfigError('This ledger is connected to another origin; use a separate ledger to preserve its cursors and mapping.');
|
|
77
129
|
saved.cloud = { ...saved.cloud, origin: parsed.origin, workspaceId };
|
|
130
|
+
if (auth) saved.cloud.auth = auth;
|
|
131
|
+
else delete saved.cloud.auth;
|
|
78
132
|
if (ledgerIds !== undefined) saved.cloud.ledgerIds = ledgerIds;
|
|
79
133
|
delete saved.cloud.token;
|
|
80
134
|
saved.project = { ...saved.project, id: projectId };
|
|
81
135
|
await writeFile(temporary, JSON.stringify(saved, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
|
|
82
136
|
if (await readFile(file, 'utf8') !== before) throw new ConfigError('Connection configuration changed; retry.');
|
|
83
137
|
await rename(temporary, file);
|
|
138
|
+
committed = true;
|
|
84
139
|
} finally {
|
|
85
140
|
await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; });
|
|
86
|
-
await lock.
|
|
141
|
+
await lock.release();
|
|
87
142
|
}
|
|
88
|
-
out(
|
|
143
|
+
out(auth
|
|
144
|
+
? `Verified workspace ${workspaceId}, project ${projectId}. Saved OAuth credential reference; write permission remains unverified. Run agentlinkops sync --pull-only to retrieve history.`
|
|
145
|
+
: `Verified workspace ${workspaceId}, project ${projectId}. Saved connection metadata; keep the key in the environment. Run agentlinkops sync --dry-run to review uploads, or agentlinkops sync --pull-only to retrieve history.`);
|
|
89
146
|
return 0;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
// A failed connection must not strand a newly created local secret.
|
|
149
|
+
if (createdRef && !committed) await credentialStore.remove(createdRef, {issuer: auth.issuer, resource: auth.resource, clientId: auth.clientId}).catch(() => {});
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
90
152
|
}
|
package/cli/doctor.js
CHANGED
|
@@ -19,6 +19,7 @@ import { loadConfig, setNoticeSink } from './config.js';
|
|
|
19
19
|
import { readLedger } from './ledger.js';
|
|
20
20
|
import { readReceipts } from './receipts.js';
|
|
21
21
|
import { readState, writeState } from './state.js';
|
|
22
|
+
import { acquireSyncLock } from './sync-lock.js';
|
|
22
23
|
import { createClient, CloudError } from './client.js';
|
|
23
24
|
import { cloudConnection } from './connection.js';
|
|
24
25
|
import { validatePublicUrl, parseLinks, transitionState, checkObservationKind } from '../src/verifier/index.js';
|
|
@@ -112,9 +113,9 @@ export async function probeCloud({ origin, fetchImpl, timeoutMs = 5000 }) {
|
|
|
112
113
|
* uses. Scope is a server-side fact, so this read (a plain watch listing, metered as none) is
|
|
113
114
|
* the only honest answer to "is this token good for anything here".
|
|
114
115
|
*/
|
|
115
|
-
export async function probeToken({ origin, token, workspaceId, fetchImpl }) {
|
|
116
|
+
export async function probeToken({ origin, token, auth, workspaceId, projectId, fetchImpl }) {
|
|
116
117
|
try {
|
|
117
|
-
await createClient({ origin, token, workspaceId, fetchImpl }).listWatches({ limit: 1 });
|
|
118
|
+
await createClient({ origin, token, auth, workspaceId, projectId, fetchImpl }).listWatches({ limit: 1 });
|
|
118
119
|
return { verified: true };
|
|
119
120
|
} catch (error) {
|
|
120
121
|
if (error instanceof CloudError) return { verified: false, code: error.code, status: error.status, scope: error.details?.scope ?? null };
|
|
@@ -214,15 +215,15 @@ export async function doctorMain(argv = [], { cwd = process.cwd(), out = console
|
|
|
214
215
|
// Token: presence, shape, and — only when the cloud just proved reachable — whether the
|
|
215
216
|
// cloud accepts it. The value itself is never printed, hashed or measured here.
|
|
216
217
|
const token = connection.token || null;
|
|
217
|
-
const source = connection.tokenSource ? `the environment (${connection.tokenSource})` : `${config.dirName}/config.json (cloud.token)`;
|
|
218
|
-
if (!token) {
|
|
218
|
+
const source = connection.auth ? 'the private OAuth credential store' : connection.tokenSource ? `the environment (${connection.tokenSource})` : `${config.dirName}/config.json (cloud.token)`;
|
|
219
|
+
if (!token && !connection.auth) {
|
|
219
220
|
if (origin) {
|
|
220
221
|
checks.push({ status: 'fail', name: 'token', detail: 'no token is set; sync and receive need one',
|
|
221
222
|
fix: 'run agentlinkops connect for the app API-key setup path; supply AGENTLINKOPS_TOKEN or AGENTLINKOPS_API_KEY in the environment' });
|
|
222
223
|
} else {
|
|
223
224
|
checks.push({ status: 'skip', name: 'token', detail: 'no token (and no cloud configured)' });
|
|
224
225
|
}
|
|
225
|
-
} else if (!TOKEN_SHAPE.test(token)) {
|
|
226
|
+
} else if (!connection.auth && !TOKEN_SHAPE.test(token)) {
|
|
226
227
|
checks.push({ status: 'fail', name: 'token', detail: `set in ${source} but it does not look like an AgentLinkOps API key (lt_ plus 64 hex characters)`,
|
|
227
228
|
fix: 'copy the full key without truncation or line breaks; the key itself is never printed here' });
|
|
228
229
|
} else if (!origin) {
|
|
@@ -230,15 +231,20 @@ export async function doctorMain(argv = [], { cwd = process.cwd(), out = console
|
|
|
230
231
|
} else {
|
|
231
232
|
const cloudLine = checks.find(check => check.name === 'cloud');
|
|
232
233
|
if (cloudLine?.status === 'ok') {
|
|
233
|
-
const probed = await probeToken({
|
|
234
|
+
const probed = await probeToken({ ...connection, fetchImpl });
|
|
234
235
|
if (probed.verified) {
|
|
235
236
|
record.token = { ok: true };
|
|
236
237
|
checks.push({ status: 'ok', name: 'token', detail: `set in ${source}; the cloud accepted it (watches:read works)` });
|
|
237
238
|
} else if (probed.verified === false) {
|
|
238
239
|
record.token = { ok: false, code: probed.code };
|
|
239
|
-
const fix = probed.code === 'INSUFFICIENT_SCOPE' ?
|
|
240
|
+
const fix = probed.code === 'INSUFFICIENT_SCOPE' ? connection.auth
|
|
241
|
+
? `authorize ${probed.scope ?? 'watches:read'} through the configured OAuth client and workspace grant`
|
|
242
|
+
: `issue a key carrying ${probed.scope ?? 'watches:read'}`
|
|
240
243
|
: probed.code === 'WORKSPACE_DENIED' ? `check cloud.workspaceId in ${config.dirName}/config.json against the workspace the key belongs to`
|
|
241
|
-
: probed.code === 'UNAUTHORIZED' ?
|
|
244
|
+
: probed.code === 'UNAUTHORIZED' ? connection.auth
|
|
245
|
+
? 'reconnect with the configured OAuth issuer and public client; do not repeat an unresolved write'
|
|
246
|
+
: 'issue a fresh key (this one is invalid or expired) and update where it is set'
|
|
247
|
+
: probed.code === 'OAUTH_CREDENTIAL_STORAGE_UNSUPPORTED' ? 'use an API-key connection or OAuth in your MCP client until native credential custody is verified'
|
|
242
248
|
: 'sync will fail the same way; act on the code above';
|
|
243
249
|
const detail = probed.code === 'INSUFFICIENT_SCOPE' ? `the cloud accepted it but scope ${probed.scope ?? 'watches:read'} is missing`
|
|
244
250
|
: probed.code === 'WORKSPACE_DENIED' ? 'the credential belongs to a different workspace'
|
|
@@ -287,12 +293,21 @@ export async function doctorMain(argv = [], { cwd = process.cwd(), out = console
|
|
|
287
293
|
// of failing doctor twice.
|
|
288
294
|
if (probed) {
|
|
289
295
|
const cloudLine = checks.find(check => check.name === 'cloud');
|
|
296
|
+
let writer = null;
|
|
290
297
|
try {
|
|
291
|
-
|
|
298
|
+
writer = await acquireSyncLock(config.dir, 'doctor');
|
|
299
|
+
// Diagnostics may await the network while sync commits. Merge only our record
|
|
300
|
+
// into fresh state under the same writer gate; the early snapshot is stale.
|
|
301
|
+
const current = await readState(config.paths.state);
|
|
292
302
|
await writeState(config.paths.state, { ...current, doctor: record });
|
|
293
303
|
if (cloudLine) cloudLine.detail += ' (recorded in state.json)';
|
|
294
304
|
} catch (error) {
|
|
295
305
|
if (cloudLine) cloudLine.detail += ` (result NOT recorded: ${error.message})`;
|
|
306
|
+
} finally {
|
|
307
|
+
if (writer) {
|
|
308
|
+
try { await writer.release(); }
|
|
309
|
+
catch (error) { if (cloudLine) cloudLine.detail += ` (writer release failed: ${error.message})`; }
|
|
310
|
+
}
|
|
296
311
|
}
|
|
297
312
|
}
|
|
298
313
|
|
package/cli/gsc-browser.js
CHANGED
|
@@ -15,7 +15,7 @@ Files must be new. CSV exports are limited to 20 MiB and include a receipt.
|
|
|
15
15
|
Review column mapping before importing. The export does not prove links are live.`;
|
|
16
16
|
|
|
17
17
|
// This factory also runs in Playwriter's CommonJS sandbox. Keep dependencies explicit.
|
|
18
|
-
export function createGscBrowserExporter({ fs, path, createHash, parseCsv }) {
|
|
18
|
+
export function createGscBrowserExporter({ fs, path, createHash, parseCsv, platform = process.platform }) {
|
|
19
19
|
const limit = 20 * 1024 * 1024;
|
|
20
20
|
const fail = (code, message) => Object.assign(new Error(message), { code, exitCode: 2 });
|
|
21
21
|
async function bounded(promise, milliseconds) {
|
|
@@ -82,7 +82,7 @@ export function createGscBrowserExporter({ fs, path, createHash, parseCsv }) {
|
|
|
82
82
|
const report = new URL('https://search.google.com/search-console/links');
|
|
83
83
|
report.searchParams.set('resource_id', property);
|
|
84
84
|
report.searchParams.set('hl', 'en');
|
|
85
|
-
let download, ownedOutput = false, ownedReceipt = false;
|
|
85
|
+
let download, outputHandle, receiptHandle, ownedOutput = false, ownedReceipt = false;
|
|
86
86
|
const receive = item => { if (!download) download = item; };
|
|
87
87
|
try {
|
|
88
88
|
await page.goto(report.href, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
@@ -190,14 +190,27 @@ export function createGscBrowserExporter({ fs, path, createHash, parseCsv }) {
|
|
|
190
190
|
source_column: sourceHeader, target_mapping_required: true,
|
|
191
191
|
sampled: true, current_link_status: 'unverified',
|
|
192
192
|
};
|
|
193
|
-
await fs.
|
|
194
|
-
await fs.
|
|
193
|
+
outputHandle = await fs.open(outputPath, 'wx', 0o600); ownedOutput = true;
|
|
194
|
+
receiptHandle = await fs.open(receiptPath, 'wx', 0o600); ownedReceipt = true;
|
|
195
|
+
for (const handle of [outputHandle, receiptHandle]) {
|
|
196
|
+
const info = await handle.stat();
|
|
197
|
+
if (!info.isFile() || (platform !== 'win32' && (info.mode & 0o7777) !== 0o600)) {
|
|
198
|
+
throw fail('private_storage_required', 'GSC exports require private files with mode 0600. Choose a filesystem that enforces private permissions.');
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
await outputHandle.writeFile(Buffer.concat(chunks));
|
|
202
|
+
await receiptHandle.writeFile(`${JSON.stringify(receipt, null, 2)}\n`);
|
|
203
|
+
await outputHandle.close(); outputHandle = undefined;
|
|
204
|
+
await receiptHandle.close(); receiptHandle = undefined;
|
|
195
205
|
return receipt;
|
|
196
206
|
} catch (error) {
|
|
197
207
|
await download?.cancel?.().catch(() => {});
|
|
208
|
+
await outputHandle?.close().catch(() => {});
|
|
209
|
+
await receiptHandle?.close().catch(() => {});
|
|
198
210
|
if (ownedOutput) await fs.unlink(outputPath).catch(() => {});
|
|
199
211
|
if (ownedReceipt) await fs.unlink(receiptPath).catch(() => {});
|
|
200
212
|
if (error.exitCode === 2) throw error;
|
|
213
|
+
if (error.code === 'EEXIST') throw fail('output_exists', 'The output or receipt file already exists. Choose a new path.');
|
|
201
214
|
throw fail('browser_export_failed', 'GSC export failed. Check your browser session and retry.');
|
|
202
215
|
} finally {
|
|
203
216
|
page.off('download', receive);
|
|
@@ -206,7 +219,7 @@ export function createGscBrowserExporter({ fs, path, createHash, parseCsv }) {
|
|
|
206
219
|
};
|
|
207
220
|
}
|
|
208
221
|
|
|
209
|
-
export const exportGscLinks = createGscBrowserExporter({ fs, path, createHash, parseCsv });
|
|
222
|
+
export const exportGscLinks = createGscBrowserExporter({ fs, path, createHash, parseCsv, platform: process.platform });
|
|
210
223
|
|
|
211
224
|
export async function gscLinksMain(argv, { cwd = process.cwd(), out = console.log, execFileImpl = promisify(execFile) } = {}) {
|
|
212
225
|
const args = parseArgs(argv);
|
|
@@ -221,7 +234,7 @@ export async function gscLinksMain(argv, { cwd = process.cwd(), out = console.lo
|
|
|
221
234
|
const scratch = await fs.mkdtemp(path.join(tmpdir(), 'agentlinkops-gsc-'));
|
|
222
235
|
try {
|
|
223
236
|
// Pass source/config as a file argument, never interpolate a shell command.
|
|
224
|
-
const script = `const CsvError = ${CsvError.toString()};\nconst parseCsv = ${parseCsv.toString()};\nconst exportLinks = (${createGscBrowserExporter.toString()})({fs:require('node:fs').promises,path:require('node:path'),createHash:require('node:crypto').createHash,parseCsv});\nconst config = ${JSON.stringify({ property: args.property, outputPath, kind: args.kind ?? 'latest' })};\nconst exportPage = await context.newPage();\ntry { const receipt = await exportLinks({...config,page:exportPage}); console.log('AGENTLINKOPS_GSC_RECEIPT '+JSON.stringify(receipt)); } catch(error) { console.log('AGENTLINKOPS_GSC_ERROR '+JSON.stringify({code:error.code||'browser_export_failed',message:error.message})); } finally { await exportPage.close(); }\n`;
|
|
237
|
+
const script = `const CsvError = ${CsvError.toString()};\nconst parseCsv = ${parseCsv.toString()};\nconst exportLinks = (${createGscBrowserExporter.toString()})({fs:require('node:fs').promises,path:require('node:path'),createHash:require('node:crypto').createHash,parseCsv,platform:require('node:os').platform()});\nconst config = ${JSON.stringify({ property: args.property, outputPath, kind: args.kind ?? 'latest' })};\nconst exportPage = await context.newPage();\ntry { const receipt = await exportLinks({...config,page:exportPage}); console.log('AGENTLINKOPS_GSC_RECEIPT '+JSON.stringify(receipt)); } catch(error) { console.log('AGENTLINKOPS_GSC_ERROR '+JSON.stringify({code:error.code||'browser_export_failed',message:error.message})); } finally { await exportPage.close(); }\n`;
|
|
225
238
|
const scriptPath = path.join(scratch, 'export.js');
|
|
226
239
|
await fs.writeFile(scriptPath, script, { mode: 0o600 });
|
|
227
240
|
let stdout;
|
package/cli/ledger.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { acquireSyncLock } from './sync-lock.js';
|
|
1
2
|
// The ledger: intent, human-owned, one JSON object per line.
|
|
2
3
|
//
|
|
3
4
|
// Reading is deliberately forgiving of everything except silence. A line that does not parse,
|
|
@@ -5,7 +6,7 @@
|
|
|
5
6
|
// set — never dropped quietly. A tool that discards a customer's row and carries on produces a
|
|
6
7
|
// number that is wrong in a way they discover months later.
|
|
7
8
|
import { readFile, writeFile, mkdir, rename, open, unlink } from 'node:fs/promises';
|
|
8
|
-
import { dirname, join } from 'node:path';
|
|
9
|
+
import { basename, dirname, join } from 'node:path';
|
|
9
10
|
import {lifecycleDeal,emptyDeal} from '../shared/lifecycle-contract.js';
|
|
10
11
|
|
|
11
12
|
export const INTENTS = Object.freeze(['wanted', 'expected', 'retired']);
|
|
@@ -189,11 +190,7 @@ export async function writeLedger(path, entries) {
|
|
|
189
190
|
|
|
190
191
|
/** Read, validate and replace under one lock. A malformed row prevents every mutation. */
|
|
191
192
|
export async function mutateLedger(path, update) {
|
|
192
|
-
const
|
|
193
|
-
const lock = await open(lockPath, 'wx').catch(error => {
|
|
194
|
-
if (error.code === 'EEXIST') throw new Error('Ledger is being updated by another process.');
|
|
195
|
-
throw error;
|
|
196
|
-
});
|
|
193
|
+
const lock = await acquireSyncLock(dirname(path), 'ledger-update', {markerName: `${basename(path)}.lock`});
|
|
197
194
|
try {
|
|
198
195
|
const ledger = await readLedger(path);
|
|
199
196
|
if (ledger.missing) throw new Error('Ledger does not exist.');
|
|
@@ -201,7 +198,7 @@ export async function mutateLedger(path, update) {
|
|
|
201
198
|
const result = await update(ledger.entries);
|
|
202
199
|
await writeLedger(path, result);
|
|
203
200
|
return result;
|
|
204
|
-
} finally { await lock.
|
|
201
|
+
} finally { await lock.release(); }
|
|
205
202
|
}
|
|
206
203
|
|
|
207
204
|
/**
|
package/cli/lifecycle.js
CHANGED
|
@@ -13,7 +13,7 @@ agentlinkops lifecycle report [--from YYYY-MM-DD] [--to YYYY-MM-DD]
|
|
|
13
13
|
Uses the connected project. Deal costs are integer currency minor units. Null clears a field; costMinor and currency must be set or cleared together. Clear removes deal metadata, not the watch. Renew retains due events without sending a message. --decimal-cost reads cost_amount as an exact decimal string and cost_currency using the supported currency scale. sync --lifecycle pulls a deal mirror without editing the ledger.`;
|
|
14
14
|
const fail=message=>{throw new ConfigError(message);};
|
|
15
15
|
const integer=(value,name)=>{if(typeof value!=='string'||!/^\d+$/.test(value)||!Number.isSafeInteger(Number(value)))fail(`${name} requires a nonnegative integer`);return Number(value);};
|
|
16
|
-
export async function lifecycleMain(args,{cwd=process.cwd(),env=process.env,out=console.log,fetchImpl=globalThis.fetch}={}){
|
|
16
|
+
export async function lifecycleMain(args,{cwd=process.cwd(),env=process.env,out=console.log,err=console.error,fetchImpl=globalThis.fetch}={}){
|
|
17
17
|
if(args.help){out(LIFECYCLE_CLI_HELP);return 0;}
|
|
18
18
|
const action=args._?.[1],allowed={get:[],update:['revision','deal','decimal-cost'],clear:['revision'],renew:['limit'],report:['from','to']}[action];
|
|
19
19
|
if(!allowed||args.tag?.length||Object.keys(args).some(key=>!['_','tag',...allowed].includes(key)))fail(LIFECYCLE_CLI_HELP);
|
|
@@ -36,5 +36,8 @@ export async function lifecycleMain(args,{cwd=process.cwd(),env=process.env,out=
|
|
|
36
36
|
if(action==='renew'){name='record_lifecycle_renewals';schema=C.lifecycleRenewInput;output=C.lifecycleRenewOutput;if(args.limit!==undefined)input.limit=integer(args.limit,'--limit');}
|
|
37
37
|
if(action==='report'){name='get_lifecycle_report';schema=C.lifecycleReportInput;output=C.lifecycleReportOutput;for(const key of ['from','to'])if(args[key]!==undefined)input[key]=args[key];}
|
|
38
38
|
const parsed=schema.safeParse(input);if(!parsed.success)fail('Invalid lifecycle dates, fields or bounds');
|
|
39
|
-
|
|
39
|
+
let result;
|
|
40
|
+
try{result=await createClient({...connection,fetchImpl}).callCommand(name,parsed.data);}
|
|
41
|
+
catch(error){if(!error.publicError)throw error;err(JSON.stringify({error:error.publicError}));return 2;}
|
|
42
|
+
const checked=output.safeParse(result);if(!checked.success)fail('Invalid lifecycle service response');out(JSON.stringify(checked.data,null,2));return 0;
|
|
40
43
|
}
|