metergraph-cli 0.2.2 → 0.2.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/README.md +121 -13
- package/assets/skill/SKILL.md +124 -31
- package/assets/skill/manifest.json +3 -3
- package/assets/skills/manifest.json +31 -31
- package/assets/skills/metergraph-analyze/SKILL.md +161 -58
- package/assets/skills/metergraph-candidates/SKILL.md +50 -8
- package/assets/skills/metergraph-evals/SKILL.md +83 -16
- package/assets/skills/metergraph-investigate/SKILL.md +90 -34
- package/assets/skills/metergraph-iterate/SKILL.md +82 -12
- package/assets/skills/metergraph-local-bundle/SKILL.md +146 -128
- package/assets/skills/metergraph-model-swap/SKILL.md +142 -20
- package/assets/skills/metergraph-onboarding/SKILL.md +93 -49
- package/assets/skills/metergraph-report/SKILL.md +60 -15
- package/assets/skills/metergraph-workloads/SKILL.md +97 -13
- package/bin/metergraph.js +1 -1
- package/native/README.md +85 -0
- package/native/anchored-fs.c +243 -0
- package/native/bin/anchored-fs-darwin-arm64 +0 -0
- package/native/bin/anchored-fs-darwin-x64 +0 -0
- package/native/bin/anchored-fs-linux-arm64 +0 -0
- package/native/bin/anchored-fs-linux-x64 +0 -0
- package/native/build.mjs +55 -0
- package/native/manifest.json +9 -0
- package/native/targets.mjs +24 -0
- package/package.json +10 -3
- package/src/action-help.js +130 -0
- package/src/agent-key.js +22 -12
- package/src/anchored-fs.js +163 -0
- package/src/args.js +335 -31
- package/src/auth-binding.js +12 -7
- package/src/auth-callback.js +10 -2
- package/src/auth-login.js +111 -28
- package/src/auth-oauth.js +133 -36
- package/src/auth-session.js +19 -12
- package/src/auth-store.js +28 -7
- package/src/cli.js +147 -15
- package/src/constants.js +70 -1
- package/src/experiments-contract.js +462 -0
- package/src/experiments-output.js +190 -0
- package/src/experiments.js +406 -0
- package/src/output.js +374 -68
- package/src/pricing-args.js +163 -0
- package/src/pricing-output.js +206 -0
- package/src/pricing-reference.js +195 -0
- package/src/pricing.js +307 -0
- package/src/read-contract.js +64 -10
- package/src/read-output.js +66 -13
- package/src/read.js +105 -16
- package/src/repository-identity.js +2 -2
- package/src/setup-deployment.js +7 -0
- package/src/setup-env-git.js +1 -1
- package/src/setup-env.js +16 -9
- package/src/setup-interactive.js +356 -0
- package/src/setup-refusal.js +2 -2
- package/src/setup-state.js +9 -4
- package/src/setup.js +15 -3
- package/src/skill-bundle.js +2 -2
- package/src/skill-trusted.js +56 -0
- package/src/skill.js +28 -9
- package/src/style.js +33 -7
- package/src/tools-output.js +73 -0
- package/src/tools.js +249 -0
- package/src/trace-contract.js +1 -1
- package/src/transport.js +26 -3
- package/src/verify-output.js +2 -2
- package/src/verify.js +5 -2
package/src/read.js
CHANGED
|
@@ -2,12 +2,22 @@ import { agentKeySource, keySession, keyTarget, readAgentKey } from "./agent-key
|
|
|
2
2
|
import { readBinding, resolveProject } from "./auth-binding.js";
|
|
3
3
|
import { checkDeployment } from "./doctor.js";
|
|
4
4
|
import { sessionFor } from "./auth-session.js";
|
|
5
|
-
import { Stop, openStore,
|
|
6
|
-
import {
|
|
5
|
+
import { Stop, openStore, readProjectCredential, resolveConfigDir } from "./auth-store.js";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_ORIGIN,
|
|
8
|
+
MAX_BODY_BYTES,
|
|
9
|
+
READ_MAX_BYTES,
|
|
10
|
+
READ_MAX_LIMIT,
|
|
11
|
+
READ_PATHS,
|
|
12
|
+
WORKFLOW_DEPLOYMENT_CAPABILITY,
|
|
13
|
+
WORKFLOW_PERMISSIONS,
|
|
14
|
+
} from "./constants.js";
|
|
7
15
|
import { parseJsonObject } from "./http.js";
|
|
8
16
|
import {
|
|
9
17
|
readQuery,
|
|
10
18
|
groupedRequest,
|
|
19
|
+
workspaceLabel,
|
|
20
|
+
workspacePinned,
|
|
11
21
|
READ_ENVIRONMENTS,
|
|
12
22
|
CAPABILITY_NAMES,
|
|
13
23
|
capabilitySummary,
|
|
@@ -19,6 +29,8 @@ import {
|
|
|
19
29
|
} from "./read-contract.js";
|
|
20
30
|
import { compareReports, priorWindow, rankReport } from "./usage-aggregates.js";
|
|
21
31
|
import { deadline, failureOf, send, trapSignals } from "./transport.js";
|
|
32
|
+
import { experimentNextAction, experimentRead } from "./experiments.js";
|
|
33
|
+
import { readArguments, toolsNextAction, toolsRead } from "./tools.js";
|
|
22
34
|
import { agentKeyAction, classifyEnvironment, handoffPages } from "./environment.js";
|
|
23
35
|
|
|
24
36
|
// The read commands: status, context, capabilities, usage, routes and traces.
|
|
@@ -60,6 +72,11 @@ export async function runRead(options) {
|
|
|
60
72
|
healthy: null,
|
|
61
73
|
profileVerified: null,
|
|
62
74
|
authenticated: false,
|
|
75
|
+
// The credential's access ("metadata" or "debug") and scopes, once verified.
|
|
76
|
+
access: null,
|
|
77
|
+
scopes: [],
|
|
78
|
+
// The verified workspace's slug and name, for output. Never its ID.
|
|
79
|
+
workspaceLabel: { slug: null, name: null },
|
|
63
80
|
verifiedWorkspace: null,
|
|
64
81
|
capabilities: null,
|
|
65
82
|
};
|
|
@@ -81,13 +98,16 @@ export async function runRead(options) {
|
|
|
81
98
|
if (holdsKnown(data, known)) {
|
|
82
99
|
notices.clear();
|
|
83
100
|
ctx.reason = "credential_in_metadata_response";
|
|
101
|
+
// The workspace's name or slug may be what echoed the credential.
|
|
102
|
+
ctx.workspaceLabel = { slug: null, name: null };
|
|
84
103
|
return {
|
|
85
104
|
outcome: "verification_failed",
|
|
86
105
|
reason: "credential_in_metadata_response",
|
|
87
106
|
data: build("verification_failed", null),
|
|
107
|
+
validateOutput: (text) => !outputHoldsKnown(text, known),
|
|
88
108
|
};
|
|
89
109
|
}
|
|
90
|
-
return { outcome, reason, data };
|
|
110
|
+
return { outcome, reason, data, validateOutput: (text) => !outputHoldsKnown(text, known) };
|
|
91
111
|
};
|
|
92
112
|
// A deadline or Ctrl+C reported by a lower layer as a timeout or
|
|
93
113
|
// cancellation is reported here by what actually happened.
|
|
@@ -101,6 +121,11 @@ export async function runRead(options) {
|
|
|
101
121
|
};
|
|
102
122
|
|
|
103
123
|
try {
|
|
124
|
+
if (options.command === "tools" && options.action === "call") {
|
|
125
|
+
const args = readArguments(options.tool);
|
|
126
|
+
if (!args.ok) return end(args.outcome, args.reason);
|
|
127
|
+
options = { ...options, toolArguments: args };
|
|
128
|
+
}
|
|
104
129
|
const root = resolveProject(options.project);
|
|
105
130
|
const configDir = resolveConfigDir(options.configDir);
|
|
106
131
|
const found = readBinding(root);
|
|
@@ -115,7 +140,7 @@ export async function runRead(options) {
|
|
|
115
140
|
if (binding !== null) {
|
|
116
141
|
try {
|
|
117
142
|
store = openStore(configDir, { create: false });
|
|
118
|
-
const record = store === null ? null :
|
|
143
|
+
const record = store === null ? null : readProjectCredential(store, binding.credential_slot, root);
|
|
119
144
|
grantSaved = record !== null;
|
|
120
145
|
originConfirmed = record !== null && record.origin === binding.origin;
|
|
121
146
|
} catch (error) {
|
|
@@ -178,18 +203,26 @@ export async function runRead(options) {
|
|
|
178
203
|
}
|
|
179
204
|
|
|
180
205
|
const session = useKey
|
|
181
|
-
? await keySession({ target, source, env, signal: limit.signal, verifiedProfile })
|
|
182
|
-
: await sessionFor(binding, store, limit.signal);
|
|
206
|
+
? await keySession({ target, source, env, signal: limit.signal, verifiedProfile, access: "read" })
|
|
207
|
+
: await sessionFor(binding, store, limit.signal, root);
|
|
183
208
|
if (!session.ok) {
|
|
184
209
|
const settled = settle(session);
|
|
185
210
|
return end(settled.outcome, settled.reason);
|
|
186
211
|
}
|
|
187
212
|
ctx.profile = session.session.profile;
|
|
213
|
+
ctx.access = session.session.access;
|
|
214
|
+
ctx.scopes = session.session.scopes;
|
|
215
|
+
known = session.knownCredentials;
|
|
216
|
+
ctx.workspaceLabel = workspaceLabel(session.documents.workspace?.workspace);
|
|
217
|
+
// A workspace named by slug or name is checked against the verified one.
|
|
218
|
+
if (target.workspaceText !== null && !workspacePinned(session.documents.workspace?.workspace, target.workspaceText)) {
|
|
219
|
+
return end("verification_failed", "workspace_context_mismatch");
|
|
220
|
+
}
|
|
188
221
|
if (ctx.workspaceId === null) {
|
|
189
222
|
ctx.workspaceId = session.session.workspaceId;
|
|
190
|
-
notices.add("workspace_not_pinned");
|
|
223
|
+
if (target.workspaceText === null) notices.add("workspace_not_pinned");
|
|
224
|
+
else ctx.intendedWorkspace = ctx.workspaceId;
|
|
191
225
|
}
|
|
192
|
-
known = session.knownCredentials;
|
|
193
226
|
ctx.authenticated = true;
|
|
194
227
|
const { documents } = session;
|
|
195
228
|
|
|
@@ -211,6 +244,25 @@ export async function runRead(options) {
|
|
|
211
244
|
return end("ok", null, context.value);
|
|
212
245
|
}
|
|
213
246
|
if (options.command === "capabilities") return end("ok", null, capabilities.value);
|
|
247
|
+
if (options.command === "tools") {
|
|
248
|
+
const tools = await toolsRead(session.session, options, limit.signal);
|
|
249
|
+
if (!tools.ok) {
|
|
250
|
+
if (ctx.credential === "agent_key" && tools.reason === "access_revoked") tools.reason = "agent_key_rejected";
|
|
251
|
+
const settled = ["refused", "unconfirmed", "action_required"].includes(tools.outcome) ? tools : settle(tools);
|
|
252
|
+
return end(settled.outcome, settled.reason, tools.report);
|
|
253
|
+
}
|
|
254
|
+
return end("ok", null, tools.value);
|
|
255
|
+
}
|
|
256
|
+
if (options.command === "experiments") {
|
|
257
|
+
const read = await experimentRead(session.session, options, ctx, documents, limit.signal, notices);
|
|
258
|
+
if (!read.ok) {
|
|
259
|
+
const settled = read.outcome === "refused" ? read : settle(read);
|
|
260
|
+
const report = read.refusal ? { refusal: read.refusal }
|
|
261
|
+
: read.retryAfter !== undefined ? { retry_after_seconds: read.retryAfter } : null;
|
|
262
|
+
return end(settled.outcome, settled.reason, report);
|
|
263
|
+
}
|
|
264
|
+
return end("ok", null, read.value);
|
|
265
|
+
}
|
|
214
266
|
|
|
215
267
|
const read = grouped ? GROUPED_USAGE : READS[options.command];
|
|
216
268
|
const entry = capabilities.value.agent[read.capability];
|
|
@@ -328,7 +380,7 @@ const INSUFFICIENT_SCOPE = /(?:^|[\s,])error\s*=\s*"?insufficient_scope"?(?:$|[\
|
|
|
328
380
|
// True when any string in value (keys included) contains one of the known
|
|
329
381
|
// credential values. An exact containment check against values this run
|
|
330
382
|
// holds, not a guess at what looks secret.
|
|
331
|
-
function holdsKnown(value, known) {
|
|
383
|
+
export function holdsKnown(value, known) {
|
|
332
384
|
if (known.length === 0) return false;
|
|
333
385
|
if (typeof value === "string") return known.some((secret) => value.includes(secret));
|
|
334
386
|
if (value === null || typeof value !== "object") return false;
|
|
@@ -340,7 +392,7 @@ function holdsKnown(value, known) {
|
|
|
340
392
|
|
|
341
393
|
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
342
394
|
|
|
343
|
-
function retryAfterOf(response) {
|
|
395
|
+
export function retryAfterOf(response) {
|
|
344
396
|
const header = response.headers["retry-after"];
|
|
345
397
|
const seconds = typeof header === "string" && RETRY_AFTER.test(header.trim()) ? Number(header.trim()) : null;
|
|
346
398
|
return seconds !== null && seconds <= 86400 ? seconds : null;
|
|
@@ -394,15 +446,18 @@ async function fetchRead(session, path, query, signal) {
|
|
|
394
446
|
function readData(ctx, report, notices, outcome, options, extra = {}) {
|
|
395
447
|
return {
|
|
396
448
|
origin: ctx.origin,
|
|
397
|
-
workspace: ctx.workspaceId === null ? null : { id: ctx.workspaceId },
|
|
449
|
+
workspace: ctx.workspaceId === null ? null : { id: ctx.workspaceId, ...ctx.workspaceLabel },
|
|
398
450
|
deployment_profile: ctx.profile,
|
|
399
451
|
environment: ctx.environment.kind,
|
|
400
452
|
credential: ctx.credential,
|
|
401
453
|
authenticated: ctx.authenticated,
|
|
402
|
-
scopes: ctx.authenticated ?
|
|
454
|
+
scopes: ctx.authenticated ? ctx.scopes : [],
|
|
403
455
|
result: outcome === "ok" ? report : null,
|
|
404
456
|
retry_after_seconds: outcome === "rate_limited" || report?.retry_after_seconds !== undefined
|
|
405
457
|
? report?.retry_after_seconds ?? null : null,
|
|
458
|
+
...(options.command === "experiments" ? { refusal: outcome === "refused" ? report?.refusal ?? null : null } : {}),
|
|
459
|
+
// A tool's own error document, as the service returned it.
|
|
460
|
+
...(options.command === "tools" ? { tool_error: outcome === "refused" ? report?.tool_error ?? null : null } : {}),
|
|
406
461
|
notices: [...notices].sort(),
|
|
407
462
|
next_action: nextAction(outcome, ctx, options, extra),
|
|
408
463
|
};
|
|
@@ -434,17 +489,38 @@ function statusData(ctx, notices, outcome, options) {
|
|
|
434
489
|
ctx.verifiedWorkspace === null || ctx.intendedWorkspace === null
|
|
435
490
|
? null
|
|
436
491
|
: ctx.verifiedWorkspace === ctx.intendedWorkspace,
|
|
492
|
+
// The verified workspace's slug and name, which output shows instead
|
|
493
|
+
// of either ID.
|
|
494
|
+
...ctx.workspaceLabel,
|
|
437
495
|
},
|
|
438
|
-
scopes: ctx.authenticated ?
|
|
496
|
+
scopes: ctx.authenticated ? ctx.scopes : [],
|
|
439
497
|
capabilities:
|
|
440
498
|
agent === null ? null : Object.fromEntries(CAPABILITY_NAMES.map((name) => [name, agent[name]?.available ?? null])),
|
|
441
|
-
|
|
499
|
+
// Whether this credential may read content through Agent Access. The
|
|
500
|
+
// read commands never do, whatever it allows.
|
|
501
|
+
content_access: agent !== null && Object.values(agent).some((entry) => entry?.available === true && entry.content === true),
|
|
502
|
+
access: accessFor(ctx),
|
|
442
503
|
application_traffic_verified: false,
|
|
443
504
|
notices: [...notices].sort(),
|
|
444
505
|
next_action: nextAction(outcome, ctx, options),
|
|
445
506
|
};
|
|
446
507
|
}
|
|
447
508
|
|
|
509
|
+
// The credential's role and workflow permissions, by the names the Keys page
|
|
510
|
+
// uses. Either role reads. Writing and testing evaluations and starting
|
|
511
|
+
// analyses need Debug with all three permissions, on a deployment that offers
|
|
512
|
+
// managed evaluations: permissions_offered is false where it does not, and
|
|
513
|
+
// null when the capabilities were not read.
|
|
514
|
+
export function accessFor(ctx) {
|
|
515
|
+
if (!ctx.authenticated) return null;
|
|
516
|
+
const deployment = ctx.capabilities?.deployment_capabilities ?? null;
|
|
517
|
+
return {
|
|
518
|
+
role: ctx.access,
|
|
519
|
+
permissions: Object.fromEntries(WORKFLOW_PERMISSIONS.map(({ field, scope }) => [field, ctx.scopes.includes(scope)])),
|
|
520
|
+
permissions_offered: deployment === null ? null : deployment.includes(WORKFLOW_DEPLOYMENT_CAPABILITY),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
448
524
|
const NARROW =
|
|
449
525
|
"Narrow the read: a shorter --days or --since/--until window, exact --route, --provider or --model filters, " +
|
|
450
526
|
"fewer --group-by dimensions or a lower --limit.";
|
|
@@ -460,6 +536,10 @@ function nextAction(outcome, ctx, options, { grouped = false, report = null } =
|
|
|
460
536
|
const reason = ctx.reason;
|
|
461
537
|
const capabilities = ctx.capabilities;
|
|
462
538
|
if (outcome === "rate_limited") return { kind: "retry_later", message: "Wait, then run the same command again." };
|
|
539
|
+
const experiment = experimentNextAction(outcome, ctx, options, report);
|
|
540
|
+
if (experiment !== undefined) return experiment;
|
|
541
|
+
const tool = toolsNextAction(ctx, options);
|
|
542
|
+
if (tool !== undefined) return tool;
|
|
463
543
|
if (reason === "usage_aggregates_unavailable" || (grouped && reason === "endpoint_unavailable")) {
|
|
464
544
|
// Offer the route-by-day read only when this grant actually has it.
|
|
465
545
|
if (capabilities?.agent?.usage?.available !== true) return null;
|
|
@@ -513,8 +593,8 @@ function nextAction(outcome, ctx, options, { grouped = false, report = null } =
|
|
|
513
593
|
kind: "use_metadata_only_key",
|
|
514
594
|
url: handoffPages(origin).keys_url,
|
|
515
595
|
message:
|
|
516
|
-
"This key
|
|
517
|
-
"
|
|
596
|
+
"This key has Replay access or another scope the CLI does not use. Ask the person for a coding-agent " +
|
|
597
|
+
"key with the Metadata or Debug role, and to replace the secret.",
|
|
518
598
|
};
|
|
519
599
|
}
|
|
520
600
|
if (keyed && reason === "workspace_context_mismatch") {
|
|
@@ -561,3 +641,12 @@ function nextAction(outcome, ctx, options, { grouped = false, report = null } =
|
|
|
561
641
|
}
|
|
562
642
|
return null;
|
|
563
643
|
}
|
|
644
|
+
|
|
645
|
+
// The final renderer may join independent service fields or insert styling.
|
|
646
|
+
// Check exact emitted bytes and their terminal-visible equivalent.
|
|
647
|
+
export function outputHoldsKnown(text, known) {
|
|
648
|
+
const visible = (value) => value.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
|
|
649
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
650
|
+
.replace(/[\x00-\x1f\x7f-\x9f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "").normalize("NFKC");
|
|
651
|
+
return holdsKnown(text, known) || known.some((secret) => visible(text).includes(visible(secret)));
|
|
652
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import fs from "
|
|
2
|
+
import fs, { withRollback } from "./anchored-fs.js";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
// Repository identity for the SDKs: `.metergraph/config.json` with
|
|
@@ -157,7 +157,7 @@ function writeNewConfig(root, repository) {
|
|
|
157
157
|
try { fs.writeFileSync(fd, content); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
158
158
|
fs.linkSync(temp, dest);
|
|
159
159
|
} finally {
|
|
160
|
-
try { fs.unlinkSync(temp); } catch { /* already gone */ }
|
|
160
|
+
try { withRollback(() => fs.unlinkSync(temp)); } catch { /* already gone */ }
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
|
package/src/setup-deployment.js
CHANGED
|
@@ -64,6 +64,13 @@ const ACTION_GUIDE = Object.freeze({
|
|
|
64
64
|
retry: "Rerun the same command.",
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
+
// Each prerequisite a non-hosted route asserts, with its guidance, for a
|
|
68
|
+
// person confirming them at a terminal.
|
|
69
|
+
export function prerequisitesFor(deployment) {
|
|
70
|
+
const profile = EXPECTED_PROFILE[deployment];
|
|
71
|
+
return (PREREQUISITES[deployment] ?? []).map((name) => ({ name, guide: prerequisiteGuide(name, profile) }));
|
|
72
|
+
}
|
|
73
|
+
|
|
67
74
|
export function prerequisiteGuide(name, profile) {
|
|
68
75
|
if (name === "metadata_agent_credential") return METADATA_CREDENTIAL_GUIDE[profile] ?? null;
|
|
69
76
|
return PREREQUISITE_GUIDE[name] ?? null;
|
package/src/setup-env-git.js
CHANGED
package/src/setup-env.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import fs from "
|
|
2
|
+
import fs, { validatePath, withRollback } from "./anchored-fs.js";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
import { resolveProject } from "./auth-binding.js";
|
|
@@ -62,6 +62,8 @@ const PLANS = new WeakMap();
|
|
|
62
62
|
const HANDLES = new WeakMap();
|
|
63
63
|
|
|
64
64
|
export const ENV_MESSAGES = Object.freeze({
|
|
65
|
+
secure_writes_unavailable:
|
|
66
|
+
"Secure automatic file writes are unavailable on this platform or the bundled filesystem helper failed verification. Nothing was written.",
|
|
65
67
|
invalid_project: "--project must name an existing directory.",
|
|
66
68
|
env_path_invalid:
|
|
67
69
|
"The env file path is not allowed. Use a project-relative path with forward slashes to a file named .env, .env.<name> or <name>.env.",
|
|
@@ -193,12 +195,12 @@ export async function rollbackEnv(handle, { signal = null, waitMs = DEFAULT_WAIT
|
|
|
193
195
|
if (state === undefined) throw new Stop("internal_error", "invalid_plan");
|
|
194
196
|
checkCancel(signal);
|
|
195
197
|
if (state.journal.length > 0) {
|
|
196
|
-
const lock = await takeLock(state.lockDir, waitMs, signal);
|
|
198
|
+
const lock = await takeLock(state.lockDir, waitMs, signal, true);
|
|
197
199
|
try {
|
|
198
200
|
checkCancel(signal);
|
|
199
201
|
deferSignals(() => {
|
|
200
202
|
try {
|
|
201
|
-
undo(state.journal);
|
|
203
|
+
withRollback(() => undo(state.journal));
|
|
202
204
|
} catch (error) {
|
|
203
205
|
if (error instanceof Stop && error.outcome === "conflict") throw new Stop("conflict", "rollback_conflict");
|
|
204
206
|
throw new Stop("filesystem_error", "rollback_incomplete");
|
|
@@ -352,6 +354,8 @@ function apply(state, updates) {
|
|
|
352
354
|
}
|
|
353
355
|
}
|
|
354
356
|
|
|
357
|
+
validatePath(target.dir);
|
|
358
|
+
if (ignore !== null) validatePath(state.root);
|
|
355
359
|
const receipt = freeze({
|
|
356
360
|
path: target.rel,
|
|
357
361
|
location: target.location,
|
|
@@ -363,7 +367,7 @@ function apply(state, updates) {
|
|
|
363
367
|
return { receipt, journal };
|
|
364
368
|
} catch (error) {
|
|
365
369
|
try {
|
|
366
|
-
undo(journal);
|
|
370
|
+
withRollback(() => undo(journal));
|
|
367
371
|
} catch {
|
|
368
372
|
throw new Stop("filesystem_error", "rollback_incomplete");
|
|
369
373
|
}
|
|
@@ -575,7 +579,7 @@ function replaceFile(file, content, { before, mode, protect, reasons, changed })
|
|
|
575
579
|
} catch (error) {
|
|
576
580
|
if (created) {
|
|
577
581
|
try {
|
|
578
|
-
fs.unlinkSync(temp);
|
|
582
|
+
withRollback(() => fs.unlinkSync(temp));
|
|
579
583
|
} catch {
|
|
580
584
|
// Reported by the caller's failure; nothing else to do.
|
|
581
585
|
}
|
|
@@ -602,7 +606,7 @@ function syncDir(dir) {
|
|
|
602
606
|
// An exclusive lock file in the project (or, outside a project, in the env
|
|
603
607
|
// file's directory). A held lock is waited on for at most waitMs, then
|
|
604
608
|
// reported; it is never taken over or removed, even if it looks stale.
|
|
605
|
-
async function takeLock(dir, waitMs, signal) {
|
|
609
|
+
async function takeLock(dir, waitMs, signal, rollback = false) {
|
|
606
610
|
const file = path.join(dir, LOCK);
|
|
607
611
|
const wait = Number.isFinite(waitMs) ? Math.min(Math.max(waitMs, 0), MAX_WAIT_MS) : DEFAULT_WAIT_MS;
|
|
608
612
|
const until = Date.now() + wait;
|
|
@@ -610,7 +614,7 @@ async function takeLock(dir, waitMs, signal) {
|
|
|
610
614
|
checkCancel(signal);
|
|
611
615
|
let fd;
|
|
612
616
|
try {
|
|
613
|
-
fd = fs.openSync(file, "wx", 0o600);
|
|
617
|
+
fd = rollback ? withRollback(() => fs.openSync(file, "wx", 0o600)) : fs.openSync(file, "wx", 0o600);
|
|
614
618
|
} catch (error) {
|
|
615
619
|
if (error.code !== "EEXIST") throw fixed(error, "env_write_failed");
|
|
616
620
|
if (Date.now() >= until) throw new Stop("conflict", "env_locked");
|
|
@@ -629,8 +633,10 @@ async function takeLock(dir, waitMs, signal) {
|
|
|
629
633
|
// Removes the lock only if it is still the file this process created.
|
|
630
634
|
function dropLock(lock) {
|
|
631
635
|
try {
|
|
632
|
-
|
|
633
|
-
|
|
636
|
+
withRollback(() => {
|
|
637
|
+
const stat = fs.lstatSync(lock.file);
|
|
638
|
+
if (stat.dev === lock.dev && stat.ino === lock.ino) fs.unlinkSync(lock.file);
|
|
639
|
+
});
|
|
634
640
|
} catch {
|
|
635
641
|
// Reported by the next run as a held lock.
|
|
636
642
|
}
|
|
@@ -670,6 +676,7 @@ function deferSignals(work) {
|
|
|
670
676
|
// error, which can hold a path, is never passed on.
|
|
671
677
|
function fixed(error, reason) {
|
|
672
678
|
if (error instanceof Stop) return error;
|
|
679
|
+
if (error?.code === "ENOTSUP") return new Stop("unsupported", "secure_writes_unavailable");
|
|
673
680
|
if (DENIED.has(error?.code)) {
|
|
674
681
|
return new Stop("filesystem_error", reason === "env_read_failed" ? "env_read_denied" : "env_write_denied");
|
|
675
682
|
}
|