@seamward/cli 0.1.0-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +31 -0
  2. package/README.md +428 -0
  3. package/dist/cli.d.ts +11 -0
  4. package/dist/cli.js +659 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/connection-key.d.ts +6 -0
  7. package/dist/connection-key.js +13 -0
  8. package/dist/connection-key.js.map +1 -0
  9. package/dist/contract-sync.d.ts +134 -0
  10. package/dist/contract-sync.js +603 -0
  11. package/dist/contract-sync.js.map +1 -0
  12. package/dist/discovery.d.ts +47 -0
  13. package/dist/discovery.js +496 -0
  14. package/dist/discovery.js.map +1 -0
  15. package/dist/existing-webhook-binding.d.ts +9 -0
  16. package/dist/existing-webhook-binding.js +196 -0
  17. package/dist/existing-webhook-binding.js.map +1 -0
  18. package/dist/index.d.ts +9 -0
  19. package/dist/index.js +10 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/observation-sync.d.ts +28 -0
  22. package/dist/observation-sync.js +101 -0
  23. package/dist/observation-sync.js.map +1 -0
  24. package/dist/project-metadata.d.ts +7 -0
  25. package/dist/project-metadata.js +104 -0
  26. package/dist/project-metadata.js.map +1 -0
  27. package/dist/remote-api.d.ts +10 -0
  28. package/dist/remote-api.js +61 -0
  29. package/dist/remote-api.js.map +1 -0
  30. package/dist/setup-engine.d.ts +127 -0
  31. package/dist/setup-engine.js +765 -0
  32. package/dist/setup-engine.js.map +1 -0
  33. package/dist/setup-plan.d.ts +197 -0
  34. package/dist/setup-plan.js +1240 -0
  35. package/dist/setup-plan.js.map +1 -0
  36. package/dist/source-instrumentation.d.ts +10 -0
  37. package/dist/source-instrumentation.js +291 -0
  38. package/dist/source-instrumentation.js.map +1 -0
  39. package/dist/version.d.ts +1 -0
  40. package/dist/version.js +8 -0
  41. package/dist/version.js.map +1 -0
  42. package/examples/node-service/README.md +42 -0
  43. package/examples/node-service/contracts/orders.openapi.yaml +18 -0
  44. package/examples/node-service/package.json +11 -0
  45. package/examples/node-service/src/server.ts +13 -0
  46. package/package.json +58 -0
@@ -0,0 +1,1240 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { lstat, link, mkdir, open, readFile, realpath, rename, stat, unlink, writeFile, } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { findExistingWebhookBinding } from "./existing-webhook-binding.js";
5
+ export function setupCompletionStateFingerprint(state) {
6
+ return sha256(JSON.stringify(state));
7
+ }
8
+ function hash(value) {
9
+ return createHash("sha256").update(value).digest("hex");
10
+ }
11
+ function sha256(value) {
12
+ return `sha256:${hash(value)}`;
13
+ }
14
+ function actionId(value) {
15
+ return `setup_${hash(JSON.stringify(value)).slice(0, 16)}`;
16
+ }
17
+ function adapterForFinding(finding) {
18
+ if (finding.language !== "typescript" && finding.language !== "javascript") {
19
+ return "http_ingest";
20
+ }
21
+ switch (finding.kind) {
22
+ case "http_client":
23
+ return "observeFetch";
24
+ case "webhook_handler":
25
+ return "observeWebhook";
26
+ case "http_server":
27
+ return "record";
28
+ case "queue_publish":
29
+ return "observeQueuePublish";
30
+ case "queue_consume":
31
+ return "observeQueueConsumer";
32
+ case "scheduled_feed":
33
+ return "observeScheduledFeed";
34
+ }
35
+ }
36
+ export const supportedCollectorVersion = "0.1.0-alpha.2";
37
+ function installCommand(packageManager) {
38
+ switch (packageManager) {
39
+ case "npm":
40
+ return `npm install @seamward/collector@${supportedCollectorVersion}`;
41
+ case "pnpm":
42
+ return `pnpm add @seamward/collector@${supportedCollectorVersion}`;
43
+ case "yarn":
44
+ return `yarn add @seamward/collector@${supportedCollectorVersion}`;
45
+ case "bun":
46
+ return `bun add @seamward/collector@${supportedCollectorVersion}`;
47
+ }
48
+ }
49
+ function integrationProtocol(finding) {
50
+ switch (finding.protocol) {
51
+ case "http":
52
+ return "http-api";
53
+ case "webhook":
54
+ return "http-webhook";
55
+ case "queue":
56
+ return "queue";
57
+ case "scheduled_feed":
58
+ return "scheduled-feed";
59
+ }
60
+ }
61
+ function scopeKey(scope) {
62
+ return `${scope.direction}:${scope.protocol}`;
63
+ }
64
+ export function discoverIntegrationScopes(discovery) {
65
+ const byKey = new Map();
66
+ for (const finding of discovery.findings) {
67
+ const scope = {
68
+ direction: finding.direction,
69
+ protocol: integrationProtocol(finding),
70
+ };
71
+ byKey.set(scopeKey(scope), scope);
72
+ }
73
+ return [...byKey.values()].sort((left, right) => scopeKey(left).localeCompare(scopeKey(right)));
74
+ }
75
+ const genericBoundaryNames = new Set([
76
+ "app",
77
+ "client",
78
+ "handler",
79
+ "index",
80
+ "integration",
81
+ "integrations",
82
+ "main",
83
+ "queue",
84
+ "routes",
85
+ "server",
86
+ "service",
87
+ "webhook",
88
+ "webhooks",
89
+ ]);
90
+ function boundaryStem(file, scope) {
91
+ const parts = file.replaceAll("\\", "/").split("/");
92
+ const raw = (parts.at(-1) ?? "integration")
93
+ .replace(/\.[^.]+$/, "")
94
+ .toLowerCase()
95
+ .replace(/(?:^|[-_.])(api|client|consumer|handler|integration|producer|publisher|queue|routes?|service|webhooks?)(?:$|[-_.])/g, "-")
96
+ .replace(/[^a-z0-9]+/g, "-")
97
+ .replace(/^-|-$/g, "");
98
+ if (raw && !genericBoundaryNames.has(raw))
99
+ return raw;
100
+ const parent = parts
101
+ .at(-2)
102
+ ?.toLowerCase()
103
+ .replace(/[^a-z0-9]+/g, "-");
104
+ if (parent && !genericBoundaryNames.has(parent) && parent !== "src") {
105
+ return parent;
106
+ }
107
+ return `${scope.direction}-${scope.protocol}`;
108
+ }
109
+ function readableBoundaryName(stem) {
110
+ return stem
111
+ .split("-")
112
+ .filter(Boolean)
113
+ .map((part) => `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`)
114
+ .join(" ");
115
+ }
116
+ function contractStem(file) {
117
+ return path.posix
118
+ .basename(file.replaceAll("\\", "/"))
119
+ .replace(/\.(?:json|ya?ml)$/i, "")
120
+ .replace(/(?:^|[-_.])(openapi|schema|contract|v\d+)(?:$|[-_.])/gi, "-")
121
+ .toLowerCase()
122
+ .replace(/[^a-z0-9]+/g, "-")
123
+ .replace(/^-|-$/g, "");
124
+ }
125
+ /**
126
+ * Groups discovered operations by business module as well as protocol scope.
127
+ * This deliberately avoids collapsing two providers that happen to use the
128
+ * same transport. Generic modules remain review-required instead of being
129
+ * silently merged.
130
+ */
131
+ export function discoverIntegrationGroups(discovery) {
132
+ const groups = new Map();
133
+ for (const finding of discovery.findings) {
134
+ const scope = {
135
+ direction: finding.direction,
136
+ protocol: integrationProtocol(finding),
137
+ };
138
+ const stem = boundaryStem(finding.file, scope);
139
+ const key = `${scopeKey(scope)}:${stem}`;
140
+ const current = groups.get(key) ?? { scope, stem, findings: [] };
141
+ current.findings.push(finding);
142
+ groups.set(key, current);
143
+ }
144
+ return [...groups.values()]
145
+ .map(({ scope, stem, findings }) => {
146
+ const setupId = `${stem}-${hash(scopeKey(scope)).slice(0, 8)}`.slice(0, 63);
147
+ const matchingContracts = discovery.contracts
148
+ .filter((contract) => {
149
+ const candidate = contractStem(contract.file);
150
+ return (candidate.length > 0 &&
151
+ (candidate.includes(stem) || stem.includes(candidate)));
152
+ })
153
+ .map(({ file }) => file)
154
+ .sort();
155
+ const generic = stem === `${scope.direction}-${scope.protocol}`;
156
+ return {
157
+ id: setupId,
158
+ name: readableBoundaryName(stem),
159
+ scope,
160
+ findingIds: findings.map(({ id }) => id).sort(),
161
+ files: [...new Set(findings.map(({ file }) => file))].sort(),
162
+ contractFiles: matchingContracts,
163
+ confidence: generic ? "review_required" : "high",
164
+ };
165
+ })
166
+ .sort((left, right) => left.id.localeCompare(right.id));
167
+ }
168
+ /**
169
+ * Automatic setup is intentionally narrower than repository discovery.
170
+ * Ordinary first-party HTTP routes remain visible for an explicit setup
171
+ * choice, while the one-line flow selects only provider calls and webhook
172
+ * receivers that the shipped source transformer can instrument safely.
173
+ */
174
+ export function isAutomaticIntegrationGroup(group) {
175
+ return (group.confidence === "high" &&
176
+ ((group.scope.direction === "outbound" &&
177
+ group.scope.protocol === "http-api") ||
178
+ (group.scope.direction === "inbound" &&
179
+ group.scope.protocol === "http-webhook")));
180
+ }
181
+ function findingMatchesScope(finding, scope) {
182
+ return (finding.direction === scope.direction &&
183
+ integrationProtocol(finding) === scope.protocol);
184
+ }
185
+ function normalizeContractFile(file) {
186
+ return path.posix.normalize(file.replaceAll("\\", "/")).replace(/^\.\//, "");
187
+ }
188
+ function validSetupId(value) {
189
+ return /^[a-z0-9][a-z0-9-]{0,62}$/.test(value);
190
+ }
191
+ export function setupPlanFile(plan) {
192
+ return plan.setupId
193
+ ? `.seamward/integrations/${plan.setupId}/setup-plan.json`
194
+ : ".seamward/setup-plan.json";
195
+ }
196
+ export function setupStateFile(plan) {
197
+ return plan.setupId
198
+ ? `.seamward/integrations/${plan.setupId}/setup-state.json`
199
+ : ".seamward/setup-state.json";
200
+ }
201
+ export function setupStateLockFile(plan) {
202
+ return plan.setupId
203
+ ? `.seamward/integrations/${plan.setupId}/setup-state.lock`
204
+ : ".seamward/setup-state.lock";
205
+ }
206
+ function unsignedPlan(plan) {
207
+ const { fingerprint: _fingerprint, ...unsigned } = plan;
208
+ return unsigned;
209
+ }
210
+ function fingerprintPlan(plan) {
211
+ return `sha256:${hash(JSON.stringify(plan))}`;
212
+ }
213
+ function nodeBootstrapPath(discovery, runtimeTarget, scope, setupId) {
214
+ const hasTypeScript = discovery.languages.includes("typescript");
215
+ const extension = hasTypeScript
216
+ ? "ts"
217
+ : runtimeTarget === "node-commonjs"
218
+ ? "mjs"
219
+ : "js";
220
+ const hasSourceDirectory = discovery.findings.some(({ file }) => file.startsWith("src/"));
221
+ const scopeSuffix = setupId
222
+ ? `.${setupId}`
223
+ : scope
224
+ ? `.${scope.direction}-${scope.protocol}`
225
+ : "";
226
+ return `${hasSourceDirectory ? "src/" : ""}seamward${scopeSuffix}.generated.${extension}`;
227
+ }
228
+ export function createSetupPlan(discovery, options) {
229
+ if (options.setupId && !validSetupId(options.setupId)) {
230
+ throw new Error("Setup ID must contain lowercase letters, numbers, and hyphens");
231
+ }
232
+ const availableScopes = discoverIntegrationScopes(discovery);
233
+ if (!options.integrationScope && availableScopes.length > 1) {
234
+ throw new Error("Discovery found multiple Integration scopes. Run setup with one explicit --direction and --protocol");
235
+ }
236
+ const selectedScope = options.integrationScope ??
237
+ (availableScopes.length === 1 ? (availableScopes[0] ?? null) : null);
238
+ if (selectedScope &&
239
+ availableScopes.length > 0 &&
240
+ !availableScopes.some((scope) => scopeKey(scope) === scopeKey(selectedScope))) {
241
+ throw new Error(`No discovered operations match ${scopeKey(selectedScope)}`);
242
+ }
243
+ const selectedFindingIds = options.findingIds
244
+ ? new Set(options.findingIds)
245
+ : null;
246
+ const selectedFindings = (selectedScope
247
+ ? discovery.findings.filter((finding) => findingMatchesScope(finding, selectedScope))
248
+ : discovery.findings).filter((finding) => selectedFindingIds ? selectedFindingIds.has(finding.id) : true);
249
+ if (selectedFindingIds &&
250
+ selectedFindings.length !== selectedFindingIds.size) {
251
+ throw new Error("One or more selected Integration operations are unavailable");
252
+ }
253
+ const requestedContractFiles = [
254
+ ...new Set((options.contractFiles ?? []).map(normalizeContractFile)),
255
+ ];
256
+ if (requestedContractFiles.length > 0 && !selectedScope) {
257
+ throw new Error("Contract assignment requires one explicit Integration direction and protocol");
258
+ }
259
+ const contractsByFile = new Map(discovery.contracts.map((contract) => [contract.file, contract]));
260
+ for (const file of requestedContractFiles) {
261
+ if (!contractsByFile.has(file)) {
262
+ throw new Error(`Contract was not discovered in this project: ${file}`);
263
+ }
264
+ }
265
+ const selectedContracts = requestedContractFiles.map((file) => contractsByFile.get(file));
266
+ if (selectedScope &&
267
+ selectedScope.protocol !== "http-api" &&
268
+ selectedScope.protocol !== "http-webhook" &&
269
+ selectedContracts.some(({ format }) => format === "openapi")) {
270
+ throw new Error("OpenAPI contracts require an HTTP API or webhook Integration scope. Use an operation-bound JSON Schema for queue or scheduled-feed contracts");
271
+ }
272
+ const selectedContractFiles = new Set(requestedContractFiles);
273
+ const unassignedContracts = discovery.contracts.filter(({ file }) => !selectedContractFiles.has(file));
274
+ const scopedDiscovery = {
275
+ ...discovery,
276
+ languages: [
277
+ ...new Set(selectedFindings.map(({ language }) => language)),
278
+ ].sort(),
279
+ findings: selectedFindings,
280
+ };
281
+ const excludedIntegrationScopes = availableScopes.filter((scope) => !selectedScope || scopeKey(scope) !== scopeKey(selectedScope));
282
+ const connectionKeyEnvironment = options.setupId
283
+ ? `SEAMWARD_CONNECTION_KEY_${options.setupId.replaceAll("-", "_")}`.toUpperCase()
284
+ : selectedScope
285
+ ? `SEAMWARD_CONNECTION_KEY_${selectedScope.direction}_${selectedScope.protocol}`
286
+ .replaceAll("-", "_")
287
+ .toUpperCase()
288
+ : "SEAMWARD_CONNECTION_KEY";
289
+ const ingestTokenEnvironment = options.setupId
290
+ ? `SEAMWARD_INGEST_TOKEN_${options.setupId.replaceAll("-", "_")}`.toUpperCase()
291
+ : "SEAMWARD_INGEST_TOKEN";
292
+ const hasNode = scopedDiscovery.languages.some((language) => language === "typescript" || language === "javascript");
293
+ const runtimeTarget = options.runtimeTarget ?? "unknown";
294
+ const hasNodeCollector = hasNode && runtimeTarget !== "unknown";
295
+ const packageManager = hasNodeCollector
296
+ ? (options.packageManager ?? "npm")
297
+ : null;
298
+ const generatedFiles = hasNodeCollector
299
+ ? [
300
+ {
301
+ path: nodeBootstrapPath(scopedDiscovery, runtimeTarget, selectedScope, options.setupId),
302
+ template: "node_collector_v2",
303
+ },
304
+ ]
305
+ : [];
306
+ const actions = [];
307
+ if (hasNodeCollector) {
308
+ const action = {
309
+ type: "create_bootstrap",
310
+ mode: "automatic",
311
+ adapter: "createSeamwardCollector",
312
+ };
313
+ actions.push({ id: actionId(action), ...action });
314
+ }
315
+ for (const finding of scopedDiscovery.findings) {
316
+ const action = {
317
+ type: "instrument_operation",
318
+ mode: hasNodeCollector &&
319
+ (finding.language === "typescript" || finding.language === "javascript")
320
+ ? "review_required"
321
+ : "manual",
322
+ adapter: hasNodeCollector ? adapterForFinding(finding) : "http_ingest",
323
+ language: finding.language,
324
+ findingId: finding.id,
325
+ file: finding.file,
326
+ line: finding.line,
327
+ direction: finding.direction,
328
+ protocol: finding.protocol,
329
+ operationKind: finding.kind,
330
+ targetHint: finding.targetHint,
331
+ confidence: finding.confidence,
332
+ };
333
+ actions.push({ id: actionId(action), ...action });
334
+ }
335
+ for (const contract of selectedContracts) {
336
+ const action = {
337
+ type: "register_contract",
338
+ mode: "review_required",
339
+ adapter: contract.format === "openapi" ? "openapi" : "json_schema",
340
+ file: contract.file,
341
+ contract,
342
+ integrationScope: selectedScope ?? undefined,
343
+ };
344
+ actions.push({ id: actionId(action), ...action });
345
+ }
346
+ const plan = {
347
+ schemaVersion: "0.2",
348
+ ...(options.setupId ? { setupId: options.setupId } : {}),
349
+ analysisFingerprint: discovery.repositoryFingerprint,
350
+ projectName: options.projectName,
351
+ integrationScope: selectedScope,
352
+ excludedIntegrationScopes,
353
+ unassignedContracts,
354
+ connectionKeyEnvironment,
355
+ runtimeTarget,
356
+ packageManager,
357
+ requiredEnvironment: [connectionKeyEnvironment, ingestTokenEnvironment],
358
+ install: hasNodeCollector && packageManager
359
+ ? {
360
+ package: "@seamward/collector",
361
+ command: installCommand(packageManager),
362
+ }
363
+ : null,
364
+ discovery: scopedDiscovery,
365
+ actions,
366
+ generatedFiles,
367
+ };
368
+ return { ...plan, fingerprint: fingerprintPlan(plan) };
369
+ }
370
+ function legacyBootstrapTemplate(language, connectionKeyEnvironment, ingestTokenEnvironment) {
371
+ const typeAnnotation = language === "typescript" ? ": string" : "";
372
+ return `// Generated by Seamward. This file contains no credentials.
373
+ import { createSeamwardCollector } from "@seamward/collector";
374
+
375
+ function required(name${typeAnnotation})${typeAnnotation} {
376
+ const value = process.env[name];
377
+ if (!value) throw new Error(\`\${name} is required\`);
378
+ return value;
379
+ }
380
+
381
+ export const seamward = createSeamwardCollector({
382
+ connectionKey: required("${connectionKeyEnvironment}"),
383
+ ingestToken: required("${ingestTokenEnvironment}"),
384
+ });
385
+ `;
386
+ }
387
+ function bootstrapTemplateV2(language, connectionKeyEnvironment, ingestTokenEnvironment) {
388
+ const isTypeScript = language === "typescript";
389
+ const collectorTypeImport = isTypeScript ? ", type ConnectedCollector" : "";
390
+ const collectorType = isTypeScript ? ": ConnectedCollector | null" : "";
391
+ const cachedCollectorType = isTypeScript
392
+ ? ": ConnectedCollector | undefined"
393
+ : "";
394
+ const observeFetchType = isTypeScript
395
+ ? ': ConnectedCollector["observeFetch"]'
396
+ : "";
397
+ const observeWebhookType = isTypeScript
398
+ ? ': ConnectedCollector["observeWebhook"]'
399
+ : "";
400
+ const seamwardType = isTypeScript
401
+ ? ': Pick<ConnectedCollector, "observeFetch" | "observeWebhook">'
402
+ : "";
403
+ return `// Generated by Seamward. This file contains no credentials.
404
+ import { createSeamwardCollector${collectorTypeImport} } from "@seamward/collector";
405
+
406
+ let activeCollector${cachedCollectorType};
407
+ const setupVerification =
408
+ process.env.SEAMWARD_INTERNAL_SETUP_VERIFICATION === "1" ||
409
+ process.env.NODE_ENV === "test";
410
+
411
+ function configuredCollector()${collectorType} {
412
+ if (activeCollector) return activeCollector;
413
+ const connectionKey = process.env["${connectionKeyEnvironment}"];
414
+ const ingestToken = process.env["${ingestTokenEnvironment}"];
415
+ if (!connectionKey || !ingestToken) {
416
+ if (setupVerification) return null;
417
+ throw new Error(
418
+ "Seamward runtime credentials are required. Configure the connection key and ingest token before starting observed traffic.",
419
+ );
420
+ }
421
+ try {
422
+ activeCollector = createSeamwardCollector({ connectionKey, ingestToken });
423
+ return activeCollector;
424
+ } catch (error) {
425
+ if (setupVerification) return null;
426
+ throw error;
427
+ }
428
+ }
429
+
430
+ const observeFetch${observeFetchType} = (meta, fetchFn) => {
431
+ const collector = configuredCollector();
432
+ return collector
433
+ ? collector.observeFetch(meta, fetchFn)
434
+ : (fetchFn ?? globalThis.fetch);
435
+ };
436
+
437
+ const observeWebhook${observeWebhookType} = (meta, handler) => {
438
+ const collector = configuredCollector();
439
+ return collector
440
+ ? collector.observeWebhook(meta, handler)
441
+ : async (payload) => handler(payload);
442
+ };
443
+
444
+ export const seamward${seamwardType} = { observeFetch, observeWebhook };
445
+ `;
446
+ }
447
+ function resolveWithinProject(root, relativePath) {
448
+ const normalized = relativePath.split("/").join(path.sep);
449
+ const resolved = path.resolve(root, normalized);
450
+ const relative = path.relative(root, resolved);
451
+ if (relative === "" ||
452
+ relative.startsWith(`..${path.sep}`) ||
453
+ relative === ".." ||
454
+ path.isAbsolute(relative)) {
455
+ throw new Error(`Generated path escapes the project boundary: ${relativePath}`);
456
+ }
457
+ return resolved;
458
+ }
459
+ async function preflightGuarded(file, content, force) {
460
+ try {
461
+ const existing = await readFile(file, "utf8");
462
+ if (existing === content)
463
+ return { status: "unchanged", existing };
464
+ if (!force) {
465
+ throw new Error(`Refusing to overwrite existing generated file: ${file}`);
466
+ }
467
+ return { status: "write", existing };
468
+ }
469
+ catch (error) {
470
+ const code = error.code;
471
+ if (code !== "ENOENT")
472
+ throw error;
473
+ }
474
+ return { status: "write", existing: null };
475
+ }
476
+ async function assertNoSymlinkPath(root, file) {
477
+ const relative = path.relative(root, file);
478
+ let current = root;
479
+ for (const segment of relative.split(path.sep)) {
480
+ current = path.join(current, segment);
481
+ try {
482
+ if ((await lstat(current)).isSymbolicLink()) {
483
+ throw new Error(`Refusing generated path through a symbolic link: ${file}`);
484
+ }
485
+ }
486
+ catch (error) {
487
+ if (error.code !== "ENOENT")
488
+ throw error;
489
+ return;
490
+ }
491
+ }
492
+ }
493
+ const staleLockFallbackMs = 15 * 60 * 1000;
494
+ async function processIsAlive(pid) {
495
+ try {
496
+ process.kill(pid, 0);
497
+ return true;
498
+ }
499
+ catch (error) {
500
+ return error.code === "EPERM";
501
+ }
502
+ }
503
+ async function lockIsStale(file) {
504
+ try {
505
+ const parsed = JSON.parse(await readFile(file, "utf8"));
506
+ if (typeof parsed.pid === "number" &&
507
+ Number.isInteger(parsed.pid) &&
508
+ parsed.pid > 0 &&
509
+ typeof parsed.createdAt === "string" &&
510
+ Number.isFinite(Date.parse(parsed.createdAt))) {
511
+ return !(await processIsAlive(parsed.pid));
512
+ }
513
+ }
514
+ catch {
515
+ // Fall back to age for malformed lock files.
516
+ }
517
+ try {
518
+ return Date.now() - (await stat(file)).mtimeMs > staleLockFallbackMs;
519
+ }
520
+ catch (error) {
521
+ return error.code === "ENOENT";
522
+ }
523
+ }
524
+ async function acquireProjectLock(root, relativeFile, conflictMessage) {
525
+ const file = resolveWithinProject(root, relativeFile);
526
+ await assertNoSymlinkPath(root, file);
527
+ await mkdir(path.dirname(file), { recursive: true });
528
+ const owner = randomUUID();
529
+ try {
530
+ const handle = await open(file, "wx", 0o600);
531
+ await handle.writeFile(`${JSON.stringify({ pid: process.pid, owner, createdAt: new Date().toISOString() })}\n`, "utf8");
532
+ return {
533
+ close: async () => {
534
+ await handle.close();
535
+ try {
536
+ const current = JSON.parse(await readFile(file, "utf8"));
537
+ if (current.owner === owner)
538
+ await unlink(file);
539
+ }
540
+ catch {
541
+ // Never delete a lock whose ownership cannot be verified.
542
+ }
543
+ },
544
+ };
545
+ }
546
+ catch (error) {
547
+ if (error.code !== "EEXIST")
548
+ throw error;
549
+ if (await lockIsStale(file)) {
550
+ throw new Error(`Stale setup lock requires manual recovery: ${relativeFile}`);
551
+ }
552
+ throw new Error(conflictMessage);
553
+ }
554
+ }
555
+ async function withProjectLock(root, relativeFile, conflictMessage, operation) {
556
+ const lock = await acquireProjectLock(root, relativeFile, conflictMessage);
557
+ try {
558
+ return await operation();
559
+ }
560
+ finally {
561
+ await lock.close();
562
+ }
563
+ }
564
+ export async function withSetupProjectLock(root, relativeFile, conflictMessage, operation) {
565
+ return withProjectLock(root, relativeFile, conflictMessage, operation);
566
+ }
567
+ function completableActions(plan) {
568
+ return plan.actions.filter(({ mode }) => mode !== "automatic");
569
+ }
570
+ export function operatorReviewableSetupActions(plan) {
571
+ return completableActions(plan).filter(({ type }) => type === "instrument_operation");
572
+ }
573
+ async function readCompletionState(plan, root) {
574
+ const file = resolveWithinProject(root, setupStateFile(plan));
575
+ await assertNoSymlinkPath(root, file);
576
+ try {
577
+ const parsed = JSON.parse(await readFile(file, "utf8"));
578
+ if (parsed.schemaVersion !== "0.2" ||
579
+ parsed.planFingerprint !== plan.fingerprint ||
580
+ !Array.isArray(parsed.completedActions) ||
581
+ parsed.completedActions.some((completion) => typeof completion !== "object" ||
582
+ completion === null ||
583
+ typeof completion.actionId !== "string" ||
584
+ typeof completion.completedAt !== "string" ||
585
+ !Number.isFinite(Date.parse(completion.completedAt)) ||
586
+ typeof completion.evidence !== "string" ||
587
+ completion.evidence.length < 1 ||
588
+ typeof completion.evidenceFingerprint !== "string" ||
589
+ !/^sha256:[a-f0-9]{64}$/.test(completion.evidenceFingerprint) ||
590
+ !Array.isArray(completion.implementationFiles) ||
591
+ completion.implementationFiles.some((file) => typeof file !== "object" ||
592
+ file === null ||
593
+ typeof file.path !== "string" ||
594
+ typeof file.fingerprint !== "string" ||
595
+ !/^sha256:[a-f0-9]{64}$/.test(file.fingerprint))) ||
596
+ (parsed.integrationBinding !== undefined &&
597
+ (typeof parsed.integrationBinding !== "object" ||
598
+ parsed.integrationBinding === null ||
599
+ typeof parsed.integrationBinding.integrationId !== "string" ||
600
+ parsed.integrationBinding.integrationId.length < 1 ||
601
+ !Number.isFinite(Date.parse(parsed.integrationBinding.recordedAt ?? "")))) ||
602
+ !validRemoteLifecycle(parsed.remoteLifecycle)) {
603
+ throw new Error("Setup completion state does not match the reviewed setup plan");
604
+ }
605
+ return {
606
+ schemaVersion: "0.2",
607
+ planFingerprint: plan.fingerprint,
608
+ completedActions: [...parsed.completedActions].sort((left, right) => left.actionId.localeCompare(right.actionId)),
609
+ ...(parsed.integrationBinding
610
+ ? { integrationBinding: parsed.integrationBinding }
611
+ : {}),
612
+ ...(parsed.remoteLifecycle
613
+ ? { remoteLifecycle: parsed.remoteLifecycle }
614
+ : {}),
615
+ };
616
+ }
617
+ catch (error) {
618
+ if (error.code !== "ENOENT")
619
+ throw error;
620
+ return {
621
+ schemaVersion: "0.2",
622
+ planFingerprint: plan.fingerprint,
623
+ completedActions: [],
624
+ };
625
+ }
626
+ }
627
+ function validRemoteLifecycle(value) {
628
+ if (value === undefined)
629
+ return true;
630
+ if (typeof value !== "object" || value === null)
631
+ return false;
632
+ const lifecycle = value;
633
+ if (lifecycle.activation !== undefined &&
634
+ (typeof lifecycle.activation !== "object" ||
635
+ lifecycle.activation === null ||
636
+ typeof lifecycle.activation.contractVersionId !== "string" ||
637
+ lifecycle.activation.contractVersionId.length < 1 ||
638
+ !Number.isFinite(Date.parse(lifecycle.activation.recordedAt)))) {
639
+ return false;
640
+ }
641
+ if (lifecycle.firstObservation !== undefined &&
642
+ (typeof lifecycle.firstObservation !== "object" ||
643
+ lifecycle.firstObservation === null ||
644
+ typeof lifecycle.firstObservation.integrationId !== "string" ||
645
+ typeof lifecycle.firstObservation.observationId !== "string" ||
646
+ !Number.isFinite(Date.parse(lifecycle.firstObservation.receivedAt)) ||
647
+ !Number.isFinite(Date.parse(lifecycle.firstObservation.recordedAt)))) {
648
+ return false;
649
+ }
650
+ return true;
651
+ }
652
+ async function writeCompletionState(plan, root, state) {
653
+ const file = resolveWithinProject(root, setupStateFile(plan));
654
+ await assertNoSymlinkPath(root, file);
655
+ await mkdir(path.dirname(file), { recursive: true });
656
+ const temporary = `${file}.${randomUUID()}.tmp`;
657
+ await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, {
658
+ encoding: "utf8",
659
+ mode: 0o600,
660
+ flag: "wx",
661
+ });
662
+ await assertNoSymlinkPath(root, file);
663
+ await rename(temporary, file);
664
+ }
665
+ async function withCompletionStateLock(plan, root, operation) {
666
+ return withProjectLock(root, setupStateLockFile(plan), "Setup state is being updated by another operation", operation);
667
+ }
668
+ export async function inspectSetupCompletionState(plan, rootDirectory) {
669
+ const root = await realpath(path.resolve(rootDirectory));
670
+ const state = await readCompletionState(plan, root);
671
+ return { state, fingerprint: setupCompletionStateFingerprint(state) };
672
+ }
673
+ export async function inspectSetupRemoteLifecycle(plan, rootDirectory) {
674
+ const root = await realpath(path.resolve(rootDirectory));
675
+ return (await readCompletionState(plan, root)).remoteLifecycle ?? {};
676
+ }
677
+ export async function inspectSetupIntegrationBinding(plan, rootDirectory) {
678
+ const root = await realpath(path.resolve(rootDirectory));
679
+ return ((await readCompletionState(plan, root)).integrationBinding?.integrationId ??
680
+ null);
681
+ }
682
+ export async function recordSetupIntegrationBinding(plan, rootDirectory, integrationId) {
683
+ if (!integrationId.trim())
684
+ throw new Error("Integration ID is required");
685
+ const root = await realpath(path.resolve(rootDirectory));
686
+ await withCompletionStateLock(plan, root, async () => {
687
+ const state = await readCompletionState(plan, root);
688
+ if (state.integrationBinding?.integrationId === integrationId)
689
+ return;
690
+ await writeCompletionState(plan, root, {
691
+ ...state,
692
+ integrationBinding: {
693
+ integrationId,
694
+ recordedAt: new Date().toISOString(),
695
+ },
696
+ });
697
+ });
698
+ }
699
+ export async function registeredContractVersionIdsForPlan(plan, rootDirectory) {
700
+ const root = await realpath(path.resolve(rootDirectory));
701
+ const state = await readCompletionState(plan, root);
702
+ const contractActionIds = new Set(plan.actions
703
+ .filter(({ type }) => type === "register_contract")
704
+ .map(({ id }) => id));
705
+ return state.completedActions.flatMap((completion) => {
706
+ if (!contractActionIds.has(completion.actionId))
707
+ return [];
708
+ const match = completion.evidence.match(/^registered_contract:(.+)$/);
709
+ return match?.[1] ? [match[1]] : [];
710
+ });
711
+ }
712
+ export async function registeredContractVersionIdForFile(plan, rootDirectory, contractFile) {
713
+ const contractAction = plan.actions.find((action) => action.type === "register_contract" && action.file === contractFile);
714
+ if (!contractAction)
715
+ return null;
716
+ const root = await realpath(path.resolve(rootDirectory));
717
+ const state = await readCompletionState(plan, root);
718
+ const completion = state.completedActions.find(({ actionId: completedActionId }) => completedActionId === contractAction.id);
719
+ const match = completion?.evidence.match(/^registered_contract:(.+)$/);
720
+ return match?.[1] ?? null;
721
+ }
722
+ export async function recordActivatedContractVersion(plan, rootDirectory, contractVersionId) {
723
+ const root = await realpath(path.resolve(rootDirectory));
724
+ await withCompletionStateLock(plan, root, async () => {
725
+ const state = await readCompletionState(plan, root);
726
+ await writeCompletionState(plan, root, {
727
+ ...state,
728
+ remoteLifecycle: {
729
+ activation: {
730
+ contractVersionId,
731
+ recordedAt: new Date().toISOString(),
732
+ },
733
+ },
734
+ });
735
+ });
736
+ }
737
+ export async function recordFirstSetupObservation(plan, rootDirectory, observation) {
738
+ const root = await realpath(path.resolve(rootDirectory));
739
+ await withCompletionStateLock(plan, root, async () => {
740
+ const state = await readCompletionState(plan, root);
741
+ if (state.remoteLifecycle?.firstObservation?.integrationId ===
742
+ observation.integrationId &&
743
+ state.remoteLifecycle.firstObservation.observationId ===
744
+ observation.observationId &&
745
+ state.remoteLifecycle.firstObservation.receivedAt ===
746
+ observation.receivedAt) {
747
+ return;
748
+ }
749
+ await writeCompletionState(plan, root, {
750
+ ...state,
751
+ remoteLifecycle: {
752
+ ...state.remoteLifecycle,
753
+ firstObservation: {
754
+ ...observation,
755
+ recordedAt: new Date().toISOString(),
756
+ },
757
+ },
758
+ });
759
+ });
760
+ }
761
+ export async function resetSetupCompletionState(plan, rootDirectory) {
762
+ const root = await realpath(path.resolve(rootDirectory));
763
+ await withCompletionStateLock(plan, root, () => writeCompletionState(plan, root, {
764
+ schemaVersion: "0.2",
765
+ planFingerprint: plan.fingerprint,
766
+ completedActions: [],
767
+ }));
768
+ }
769
+ async function completeSetupActions(plan, rootDirectory, actionIds, allowedActions, options, sourceBacked) {
770
+ const root = await realpath(path.resolve(rootDirectory));
771
+ return withCompletionStateLock(plan, root, () => completeSetupActionsUnlocked(plan, root, actionIds, allowedActions, options, sourceBacked));
772
+ }
773
+ async function completeSetupActionsUnlocked(plan, rootDirectory, actionIds, allowedActions, options, sourceBacked) {
774
+ await applySetupPlan(plan, rootDirectory);
775
+ const root = await realpath(path.resolve(rootDirectory));
776
+ const current = await readCompletionState(plan, root);
777
+ if (options.expectedStateFingerprint &&
778
+ setupCompletionStateFingerprint(current) !==
779
+ options.expectedStateFingerprint) {
780
+ throw new Error("Setup state fingerprint does not match current state");
781
+ }
782
+ return completeSetupActionsFromCurrent(plan, root, actionIds, allowedActions, options, sourceBacked, current);
783
+ }
784
+ async function completeSetupActionsFromCurrent(plan, root, actionIds, allowedActions, options, sourceBacked, current) {
785
+ const actions = completableActions(plan);
786
+ const valid = new Set(allowedActions.map(({ id }) => id));
787
+ const requested = [...new Set(actionIds)].sort();
788
+ if (requested.length === 0)
789
+ throw new Error("At least one action ID is required");
790
+ const unknown = requested.find((id) => !valid.has(id));
791
+ if (unknown)
792
+ throw new Error(`Setup action is not reviewable: ${unknown}`);
793
+ const evidence = options.evidence?.trim() || "operator_review";
794
+ if (evidence.length > 256 || /[\u0000-\u001f\u007f]/.test(evidence)) {
795
+ throw new Error("Setup review evidence is invalid");
796
+ }
797
+ const now = new Date().toISOString();
798
+ const actionById = new Map(actions.map((action) => [action.id, action]));
799
+ const evidenceByActionId = new Map();
800
+ for (const requestedActionId of requested) {
801
+ const action = actionById.get(requestedActionId);
802
+ if (!action)
803
+ throw new Error("Setup action no longer exists in the plan");
804
+ const implementationFiles = sourceBacked && action.type === "instrument_operation"
805
+ ? [await verifyInstrumentationSource(root, plan, action)]
806
+ : [];
807
+ const verification = sourceBacked && action.type === "instrument_operation"
808
+ ? options.verification
809
+ : undefined;
810
+ evidenceByActionId.set(requestedActionId, {
811
+ implementationFiles,
812
+ ...(verification ? { verification } : {}),
813
+ evidenceFingerprint: sha256(JSON.stringify({
814
+ planFingerprint: plan.fingerprint,
815
+ actionId: requestedActionId,
816
+ findingId: action.findingId ?? null,
817
+ adapter: action.adapter,
818
+ evidence,
819
+ implementationFiles,
820
+ verification: verification ?? null,
821
+ })),
822
+ });
823
+ }
824
+ const completionById = new Map(current.completedActions.map((completion) => [
825
+ completion.actionId,
826
+ completion,
827
+ ]));
828
+ let evidenceChanged = false;
829
+ for (const actionId of requested) {
830
+ const sourceEvidence = evidenceByActionId.get(actionId);
831
+ if (!sourceEvidence) {
832
+ throw new Error("Setup evidence could not be prepared");
833
+ }
834
+ const existing = completionById.get(actionId);
835
+ if (existing?.evidenceFingerprint !== sourceEvidence.evidenceFingerprint) {
836
+ evidenceChanged = true;
837
+ completionById.set(actionId, {
838
+ actionId,
839
+ completedAt: now,
840
+ evidence,
841
+ ...sourceEvidence,
842
+ });
843
+ }
844
+ }
845
+ const completedActions = [...completionById.values()].sort((left, right) => left.actionId.localeCompare(right.actionId));
846
+ const completedActionIds = completedActions.map(({ actionId }) => actionId);
847
+ const completed = new Set(completedActionIds);
848
+ const pendingActionIds = actions
849
+ .map(({ id }) => id)
850
+ .filter((id) => !completed.has(id));
851
+ if (options.write) {
852
+ const remoteLifecycle = sourceBacked && evidenceChanged
853
+ ? current.remoteLifecycle?.activation
854
+ ? { activation: current.remoteLifecycle.activation }
855
+ : undefined
856
+ : current.remoteLifecycle;
857
+ const { remoteLifecycle: _previousLifecycle, ...currentWithoutLifecycle } = current;
858
+ await writeCompletionState(plan, root, {
859
+ ...currentWithoutLifecycle,
860
+ completedActions,
861
+ ...(remoteLifecycle ? { remoteLifecycle } : {}),
862
+ });
863
+ }
864
+ return {
865
+ applied: options.write === true,
866
+ planFingerprint: plan.fingerprint,
867
+ completedActionIds,
868
+ pendingActionIds,
869
+ };
870
+ }
871
+ export async function completedSetupActionIds(plan, rootDirectory) {
872
+ if (fingerprintPlan(unsignedPlan(plan)) !== plan.fingerprint) {
873
+ throw new Error("Setup plan fingerprint does not match its contents");
874
+ }
875
+ const root = await realpath(path.resolve(rootDirectory));
876
+ const state = await readCompletionState(plan, root);
877
+ const valid = new Set(completableActions(plan).map(({ id }) => id));
878
+ if (state.completedActions.some(({ actionId }) => !valid.has(actionId))) {
879
+ throw new Error("Setup completion state contains an unknown action");
880
+ }
881
+ return new Set(state.completedActions.map(({ actionId }) => actionId));
882
+ }
883
+ export async function inspectSetupActionEvidence(plan, rootDirectory) {
884
+ if (fingerprintPlan(unsignedPlan(plan)) !== plan.fingerprint) {
885
+ throw new Error("Setup plan fingerprint does not match its contents");
886
+ }
887
+ const root = await realpath(path.resolve(rootDirectory));
888
+ const state = await readCompletionState(plan, root);
889
+ const completionById = new Map(state.completedActions.map((completion) => [
890
+ completion.actionId,
891
+ completion,
892
+ ]));
893
+ return Promise.all(completableActions(plan).map(async (action) => {
894
+ const completion = completionById.get(action.id);
895
+ if (!completion) {
896
+ return {
897
+ actionId: action.id,
898
+ actionType: action.type,
899
+ status: "pending",
900
+ };
901
+ }
902
+ if (action.type === "register_contract") {
903
+ return {
904
+ actionId: action.id,
905
+ actionType: action.type,
906
+ status: "registered",
907
+ evidenceFingerprint: completion.evidenceFingerprint,
908
+ recordedAt: completion.completedAt,
909
+ };
910
+ }
911
+ let status = completion.implementationFiles.length > 0 &&
912
+ (action.language === "typescript" || action.language === "javascript")
913
+ ? "source_verified"
914
+ : "attested_only";
915
+ for (const evidenceFile of completion.implementationFiles) {
916
+ const file = resolveWithinProject(root, evidenceFile.path);
917
+ await assertNoSymlinkPath(root, file);
918
+ try {
919
+ const currentFingerprint = sha256(await readFile(file, "utf8"));
920
+ if (currentFingerprint !== evidenceFile.fingerprint)
921
+ status = "stale";
922
+ }
923
+ catch (error) {
924
+ if (error.code === "ENOENT") {
925
+ status = "missing";
926
+ }
927
+ else {
928
+ throw error;
929
+ }
930
+ }
931
+ }
932
+ if (status === "source_verified") {
933
+ try {
934
+ await verifyInstrumentationSource(root, plan, action);
935
+ }
936
+ catch {
937
+ status = "stale";
938
+ }
939
+ }
940
+ return {
941
+ actionId: action.id,
942
+ actionType: action.type,
943
+ status,
944
+ evidenceFingerprint: completion.evidenceFingerprint,
945
+ recordedAt: completion.completedAt,
946
+ };
947
+ }));
948
+ }
949
+ export async function reviewSetupActions(plan, rootDirectory, actionIds, options = {}) {
950
+ return completeSetupActions(plan, rootDirectory, actionIds, operatorReviewableSetupActions(plan), options, false);
951
+ }
952
+ export async function recordSourceBackedSetupEvidence(plan, rootDirectory, actionIds, options = {}) {
953
+ return completeSetupActions(plan, rootDirectory, actionIds, operatorReviewableSetupActions(plan), options, true);
954
+ }
955
+ export async function recordRegisteredContractAction(plan, rootDirectory, actionId, contractVersionId) {
956
+ const contractAction = plan.actions.find((action) => action.id === actionId && action.type === "register_contract");
957
+ if (!contractAction) {
958
+ throw new Error(`Setup action is not a contract registration: ${actionId}`);
959
+ }
960
+ const normalizedContractVersionId = contractVersionId.trim();
961
+ if (normalizedContractVersionId.length < 1 ||
962
+ normalizedContractVersionId.length > 200 ||
963
+ /[\u0000-\u001f\u007f]/.test(normalizedContractVersionId)) {
964
+ throw new Error("Registered contract version ID is invalid");
965
+ }
966
+ return completeSetupActions(plan, rootDirectory, [actionId], [contractAction], {
967
+ write: true,
968
+ evidence: `registered_contract:${normalizedContractVersionId}`,
969
+ }, false);
970
+ }
971
+ function escapedRegularExpression(value) {
972
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
973
+ }
974
+ export function setupInstrumentationMarker(plan, action) {
975
+ if (!action.file || action.type !== "instrument_operation") {
976
+ throw new Error("Instrumentation markers require a source operation");
977
+ }
978
+ const operations = plan.actions
979
+ .filter((candidate) => candidate.type === "instrument_operation" &&
980
+ candidate.file === action.file)
981
+ .sort((left, right) => (left.line ?? Number.MAX_SAFE_INTEGER) -
982
+ (right.line ?? Number.MAX_SAFE_INTEGER) ||
983
+ (left.operationKind ?? "").localeCompare(right.operationKind ?? "") ||
984
+ left.adapter.localeCompare(right.adapter) ||
985
+ left.id.localeCompare(right.id));
986
+ const operationNumber = operations.findIndex((candidate) => candidate.id === action.id);
987
+ if (operationNumber < 0) {
988
+ throw new Error("Instrumentation action does not belong to this setup plan");
989
+ }
990
+ const kind = (action.operationKind ?? "operation").replaceAll("_", " ");
991
+ return `seamward-setup: operation ${operationNumber + 1}, ${kind}, ${action.adapter}, original line ${action.line ?? "unknown"}`;
992
+ }
993
+ async function verifyInstrumentationSource(root, plan, action) {
994
+ if (!action.file) {
995
+ throw new Error("Source-backed verification failed: action has no file");
996
+ }
997
+ const file = resolveWithinProject(root, action.file);
998
+ await assertNoSymlinkPath(root, file);
999
+ let content;
1000
+ try {
1001
+ content = await readFile(file, "utf8");
1002
+ }
1003
+ catch (error) {
1004
+ if (error.code === "ENOENT") {
1005
+ throw new Error(`Source-backed verification failed: implementation file is missing (${action.file})`);
1006
+ }
1007
+ throw error;
1008
+ }
1009
+ const marker = setupInstrumentationMarker(plan, action);
1010
+ const markerIndex = content.indexOf(marker);
1011
+ const actionsInFile = plan.actions.filter((candidate) => candidate.type === "instrument_operation" &&
1012
+ candidate.file === action.file);
1013
+ const supportsStaticVerification = action.language === "typescript" || action.language === "javascript";
1014
+ const adapterCall = new RegExp(`(?:\\.|\\b)${escapedRegularExpression(action.adapter)}\\s*\\(`, "g");
1015
+ const legacySingleOperationEvidence = markerIndex < 0 &&
1016
+ actionsInFile.length === 1 &&
1017
+ supportsStaticVerification &&
1018
+ [...content.matchAll(adapterCall)].length === 1;
1019
+ if (!legacySingleOperationEvidence &&
1020
+ (markerIndex < 0 ||
1021
+ content.indexOf(marker, markerIndex + marker.length) >= 0)) {
1022
+ throw new Error(`Source-backed verification failed: ${action.file} is missing the unique marker for operation ${actionsInFile.findIndex((candidate) => candidate.id === action.id) + 1}`);
1023
+ }
1024
+ const nextMarkerIndex = legacySingleOperationEvidence
1025
+ ? -1
1026
+ : content.indexOf("seamward-setup:", markerIndex + marker.length);
1027
+ const actionSegment = legacySingleOperationEvidence
1028
+ ? content
1029
+ : content.slice(markerIndex, nextMarkerIndex < 0 ? content.length : nextMarkerIndex);
1030
+ const adapterCallCount = supportsStaticVerification
1031
+ ? [...actionSegment.matchAll(adapterCall)].length
1032
+ : 0;
1033
+ const existingBinding = supportsStaticVerification &&
1034
+ action.adapter === "observeWebhook" &&
1035
+ action.file &&
1036
+ action.line
1037
+ ? findExistingWebhookBinding({
1038
+ file: action.file,
1039
+ content,
1040
+ line: markerIndex < 0
1041
+ ? action.line
1042
+ : content.slice(0, markerIndex).split("\n").length,
1043
+ ...(action.targetHint ? { targetHint: action.targetHint } : {}),
1044
+ })
1045
+ : null;
1046
+ const existingCollectorBacked = Boolean(existingBinding && actionSegment.includes(existingBinding.marker));
1047
+ if (supportsStaticVerification &&
1048
+ adapterCallCount < 1 &&
1049
+ !existingCollectorBacked) {
1050
+ throw new Error(`Source-backed verification failed: ${action.file} requires a call to ${action.adapter}`);
1051
+ }
1052
+ return { path: action.file, fingerprint: sha256(content) };
1053
+ }
1054
+ function plannedWrites(plan, root) {
1055
+ const nodeLanguage = plan.discovery.languages.includes("typescript")
1056
+ ? "typescript"
1057
+ : plan.discovery.languages.includes("javascript")
1058
+ ? "javascript"
1059
+ : undefined;
1060
+ const generated = plan.generatedFiles.map((file) => {
1061
+ const content = file.template === "node_collector"
1062
+ ? legacyBootstrapTemplate(nodeLanguage, plan.connectionKeyEnvironment, plan.requiredEnvironment[1])
1063
+ : file.template === "node_collector_v2"
1064
+ ? bootstrapTemplateV2(nodeLanguage, plan.connectionKeyEnvironment, plan.requiredEnvironment[1])
1065
+ : null;
1066
+ if (!content)
1067
+ throw new Error(`Unsupported generated template: ${String(file.template)}`);
1068
+ return {
1069
+ destination: resolveWithinProject(root, file.path),
1070
+ relative: file.path,
1071
+ content,
1072
+ };
1073
+ });
1074
+ return [
1075
+ {
1076
+ destination: resolveWithinProject(root, setupPlanFile(plan)),
1077
+ relative: setupPlanFile(plan),
1078
+ content: `${JSON.stringify(plan, null, 2)}\n`,
1079
+ },
1080
+ ...generated,
1081
+ ];
1082
+ }
1083
+ export async function inspectSetupPlanFiles(plan, rootDirectory) {
1084
+ if (fingerprintPlan(unsignedPlan(plan)) !== plan.fingerprint) {
1085
+ throw new Error("Setup plan fingerprint does not match its contents");
1086
+ }
1087
+ const root = await realpath(path.resolve(rootDirectory));
1088
+ return Promise.all(plannedWrites(plan, root).map(async ({ destination, relative, content }) => {
1089
+ await assertNoSymlinkPath(root, destination);
1090
+ try {
1091
+ return {
1092
+ file: relative,
1093
+ present: true,
1094
+ matches: (await readFile(destination, "utf8")) === content,
1095
+ };
1096
+ }
1097
+ catch (error) {
1098
+ if (error.code !== "ENOENT")
1099
+ throw error;
1100
+ return { file: relative, present: false, matches: false };
1101
+ }
1102
+ }));
1103
+ }
1104
+ export async function applySetupPlan(plan, rootDirectory, options = {}) {
1105
+ if (plan.schemaVersion !== "0.2") {
1106
+ throw new Error(`Unsupported setup plan version: ${String(plan.schemaVersion)}`);
1107
+ }
1108
+ const expectedFingerprint = fingerprintPlan(unsignedPlan(plan));
1109
+ if (expectedFingerprint !== plan.fingerprint) {
1110
+ throw new Error("Setup plan fingerprint does not match its contents");
1111
+ }
1112
+ const root = await realpath(path.resolve(rootDirectory));
1113
+ const relativeFiles = [
1114
+ setupPlanFile(plan),
1115
+ ...plan.generatedFiles.map(({ path: file }) => file),
1116
+ ];
1117
+ if (!options.write) {
1118
+ return {
1119
+ applied: false,
1120
+ fingerprint: plan.fingerprint,
1121
+ files: relativeFiles,
1122
+ };
1123
+ }
1124
+ await withProjectLock(root, ".seamward/setup-generated.lock", "Generated setup files are being updated by another operation", async () => {
1125
+ const writes = plannedWrites(plan, root).sort((left, right) => {
1126
+ if (left.relative === setupPlanFile(plan))
1127
+ return 1;
1128
+ if (right.relative === setupPlanFile(plan))
1129
+ return -1;
1130
+ return left.relative.localeCompare(right.relative);
1131
+ });
1132
+ const preflight = await Promise.all(writes.map(async ({ destination, content }) => {
1133
+ await assertNoSymlinkPath(root, destination);
1134
+ return preflightGuarded(destination, content, options.force === true);
1135
+ }));
1136
+ const staged = [];
1137
+ const committed = [];
1138
+ try {
1139
+ for (const [index, write] of writes.entries()) {
1140
+ const guard = preflight[index];
1141
+ if (!guard || guard.status !== "write")
1142
+ continue;
1143
+ await mkdir(path.dirname(write.destination), { recursive: true });
1144
+ const temporary = `${write.destination}.${randomUUID()}.tmp`;
1145
+ await writeFile(temporary, write.content, {
1146
+ encoding: "utf8",
1147
+ mode: 0o600,
1148
+ flag: "wx",
1149
+ });
1150
+ staged.push({
1151
+ destination: write.destination,
1152
+ content: write.content,
1153
+ existing: guard.existing,
1154
+ temporary,
1155
+ });
1156
+ }
1157
+ for (const write of staged) {
1158
+ await assertNoSymlinkPath(root, write.destination);
1159
+ let current = null;
1160
+ try {
1161
+ current = await readFile(write.destination, "utf8");
1162
+ }
1163
+ catch (error) {
1164
+ if (error.code !== "ENOENT")
1165
+ throw error;
1166
+ }
1167
+ if (current !== write.existing) {
1168
+ throw new Error(`Generated file changed after preflight: ${write.destination}`);
1169
+ }
1170
+ }
1171
+ for (const write of staged) {
1172
+ if (write.existing === null) {
1173
+ await link(write.temporary, write.destination);
1174
+ await unlink(write.temporary);
1175
+ committed.push({
1176
+ destination: write.destination,
1177
+ content: write.content,
1178
+ });
1179
+ continue;
1180
+ }
1181
+ const backup = `${write.destination}.${randomUUID()}.backup`;
1182
+ await rename(write.destination, backup);
1183
+ try {
1184
+ if ((await readFile(backup, "utf8")) !== write.existing) {
1185
+ throw new Error(`Generated file changed during replacement: ${write.destination}`);
1186
+ }
1187
+ await link(write.temporary, write.destination);
1188
+ await unlink(write.temporary);
1189
+ committed.push({
1190
+ destination: write.destination,
1191
+ content: write.content,
1192
+ backup,
1193
+ });
1194
+ }
1195
+ catch (error) {
1196
+ try {
1197
+ await rename(backup, write.destination);
1198
+ }
1199
+ catch {
1200
+ // Preserve the backup when an external writer claimed the path.
1201
+ }
1202
+ throw error;
1203
+ }
1204
+ }
1205
+ for (const write of committed) {
1206
+ if (write.backup)
1207
+ await unlink(write.backup).catch(() => undefined);
1208
+ }
1209
+ }
1210
+ catch (error) {
1211
+ for (const write of [...committed].reverse()) {
1212
+ let current = null;
1213
+ try {
1214
+ current = await readFile(write.destination, "utf8");
1215
+ }
1216
+ catch (readError) {
1217
+ if (readError.code !== "ENOENT") {
1218
+ continue;
1219
+ }
1220
+ }
1221
+ if (current !== write.content)
1222
+ continue;
1223
+ await unlink(write.destination).catch(() => undefined);
1224
+ if (write.backup) {
1225
+ await rename(write.backup, write.destination).catch(() => undefined);
1226
+ }
1227
+ }
1228
+ throw error;
1229
+ }
1230
+ finally {
1231
+ await Promise.all(staged.map(({ temporary }) => unlink(temporary).catch(() => undefined)));
1232
+ }
1233
+ });
1234
+ return {
1235
+ applied: true,
1236
+ fingerprint: plan.fingerprint,
1237
+ files: relativeFiles,
1238
+ };
1239
+ }
1240
+ //# sourceMappingURL=setup-plan.js.map