@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.
- package/LICENSE +31 -0
- package/README.md +428 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +659 -0
- package/dist/cli.js.map +1 -0
- package/dist/connection-key.d.ts +6 -0
- package/dist/connection-key.js +13 -0
- package/dist/connection-key.js.map +1 -0
- package/dist/contract-sync.d.ts +134 -0
- package/dist/contract-sync.js +603 -0
- package/dist/contract-sync.js.map +1 -0
- package/dist/discovery.d.ts +47 -0
- package/dist/discovery.js +496 -0
- package/dist/discovery.js.map +1 -0
- package/dist/existing-webhook-binding.d.ts +9 -0
- package/dist/existing-webhook-binding.js +196 -0
- package/dist/existing-webhook-binding.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/observation-sync.d.ts +28 -0
- package/dist/observation-sync.js +101 -0
- package/dist/observation-sync.js.map +1 -0
- package/dist/project-metadata.d.ts +7 -0
- package/dist/project-metadata.js +104 -0
- package/dist/project-metadata.js.map +1 -0
- package/dist/remote-api.d.ts +10 -0
- package/dist/remote-api.js +61 -0
- package/dist/remote-api.js.map +1 -0
- package/dist/setup-engine.d.ts +127 -0
- package/dist/setup-engine.js +765 -0
- package/dist/setup-engine.js.map +1 -0
- package/dist/setup-plan.d.ts +197 -0
- package/dist/setup-plan.js +1240 -0
- package/dist/setup-plan.js.map +1 -0
- package/dist/source-instrumentation.d.ts +10 -0
- package/dist/source-instrumentation.js +291 -0
- package/dist/source-instrumentation.js.map +1 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/examples/node-service/README.md +42 -0
- package/examples/node-service/contracts/orders.openapi.yaml +18 -0
- package/examples/node-service/package.json +11 -0
- package/examples/node-service/src/server.ts +13 -0
- package/package.json +58 -0
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdir, readdir, readFile, realpath, rm, rmdir, stat, writeFile, } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { scanProject } from "./discovery.js";
|
|
6
|
+
import { readProjectMetadata, } from "./project-metadata.js";
|
|
7
|
+
import { applySetupPlan, createSetupPlan, discoverIntegrationScopes, inspectSetupActionEvidence, inspectSetupCompletionState, inspectSetupPlanFiles, inspectSetupRemoteLifecycle, operatorReviewableSetupActions, recordSourceBackedSetupEvidence, resetSetupCompletionState, setupPlanFile, setupStateLockFile, setupStateFile, supportedCollectorVersion, withSetupProjectLock, } from "./setup-plan.js";
|
|
8
|
+
import { applyPlannedSourceInstrumentation } from "./source-instrumentation.js";
|
|
9
|
+
function normalizedSelectionContracts(files) {
|
|
10
|
+
return [...new Set(files ?? [])]
|
|
11
|
+
.map((file) => file.split(path.sep).join("/").replace(/^\.\//, ""))
|
|
12
|
+
.sort();
|
|
13
|
+
}
|
|
14
|
+
function planMatchesSelection(plan, selection) {
|
|
15
|
+
if (plan.integrationScope?.direction !== selection.direction ||
|
|
16
|
+
plan.integrationScope.protocol !== selection.protocol) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
if (selection.findingIds) {
|
|
20
|
+
const planned = plan.actions
|
|
21
|
+
.flatMap((action) => action.type === "instrument_operation" && action.findingId
|
|
22
|
+
? [action.findingId]
|
|
23
|
+
: [])
|
|
24
|
+
.sort();
|
|
25
|
+
if (JSON.stringify(planned) !==
|
|
26
|
+
JSON.stringify([...selection.findingIds].sort())) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const registeredContracts = plan.actions
|
|
31
|
+
.filter((action) => action.type === "register_contract" && typeof action.file === "string")
|
|
32
|
+
.map(({ file }) => file)
|
|
33
|
+
.sort();
|
|
34
|
+
return (JSON.stringify(registeredContracts) ===
|
|
35
|
+
JSON.stringify(normalizedSelectionContracts(selection.contractFiles)));
|
|
36
|
+
}
|
|
37
|
+
export class LocalSetupEngineError extends Error {
|
|
38
|
+
code;
|
|
39
|
+
constructor(code, message, options) {
|
|
40
|
+
super(message, options);
|
|
41
|
+
this.code = code;
|
|
42
|
+
this.name = "LocalSetupEngineError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const execFileAsync = promisify(execFile);
|
|
46
|
+
function projectPath(root, relativeFile) {
|
|
47
|
+
const file = path.resolve(root, relativeFile.split("/").join(path.sep));
|
|
48
|
+
const relative = path.relative(root, file);
|
|
49
|
+
if (relative === ".." ||
|
|
50
|
+
relative.startsWith(`..${path.sep}`) ||
|
|
51
|
+
path.isAbsolute(relative)) {
|
|
52
|
+
throw new LocalSetupEngineError("internal_error", "A setup-owned path escaped the project boundary");
|
|
53
|
+
}
|
|
54
|
+
return file;
|
|
55
|
+
}
|
|
56
|
+
async function snapshotFiles(root, relativeFiles) {
|
|
57
|
+
return Promise.all([...new Set(relativeFiles)].map(async (relativeFile) => {
|
|
58
|
+
const file = projectPath(root, relativeFile);
|
|
59
|
+
try {
|
|
60
|
+
const [content, metadata] = await Promise.all([
|
|
61
|
+
readFile(file),
|
|
62
|
+
stat(file),
|
|
63
|
+
]);
|
|
64
|
+
return { file, content, mode: metadata.mode };
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error.code === "ENOENT") {
|
|
68
|
+
return { file, content: null, mode: null };
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
async function restoreFiles(snapshots) {
|
|
75
|
+
for (const snapshot of snapshots) {
|
|
76
|
+
if (snapshot.content === null) {
|
|
77
|
+
await rm(snapshot.file, { force: true });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
await mkdir(path.dirname(snapshot.file), { recursive: true });
|
|
81
|
+
await writeFile(snapshot.file, snapshot.content, {
|
|
82
|
+
mode: snapshot.mode ?? undefined,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function setupMutationFiles(root, plan) {
|
|
87
|
+
let previousGeneratedFiles = [];
|
|
88
|
+
try {
|
|
89
|
+
previousGeneratedFiles = (await readSetupPlan(root, undefined, plan.setupId)).generatedFiles.map(({ path: file }) => file);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (!(error instanceof LocalSetupEngineError) ||
|
|
93
|
+
!["plan_not_found", "legacy_plan_requires_replan"].includes(error.code)) {
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return [
|
|
98
|
+
"package.json",
|
|
99
|
+
"pnpm-lock.yaml",
|
|
100
|
+
"package-lock.json",
|
|
101
|
+
"yarn.lock",
|
|
102
|
+
"bun.lock",
|
|
103
|
+
"bun.lockb",
|
|
104
|
+
setupPlanFile(plan),
|
|
105
|
+
setupStateFile(plan),
|
|
106
|
+
...previousGeneratedFiles,
|
|
107
|
+
...plan.generatedFiles.map(({ path: file }) => file),
|
|
108
|
+
...plan.actions.flatMap((action) => action.type === "instrument_operation" && action.file
|
|
109
|
+
? [action.file]
|
|
110
|
+
: []),
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
async function runProjectVerification(root, plan) {
|
|
114
|
+
const packageDocument = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
|
|
115
|
+
const scripts = packageDocument.scripts ?? {};
|
|
116
|
+
const selected = typeof scripts.verify === "string"
|
|
117
|
+
? ["verify"]
|
|
118
|
+
: ["test", "typecheck", "build"].filter((script) => typeof scripts[script] === "string");
|
|
119
|
+
if (selected.length === 0) {
|
|
120
|
+
throw new LocalSetupEngineError("verification_failed", "No verify, test, typecheck, or build script is available to validate the setup");
|
|
121
|
+
}
|
|
122
|
+
const packageManager = plan.packageManager ?? "npm";
|
|
123
|
+
for (const script of selected) {
|
|
124
|
+
try {
|
|
125
|
+
await execFileAsync(packageManager, ["run", script], {
|
|
126
|
+
cwd: root,
|
|
127
|
+
env: childEnvironment({ setupVerification: true }),
|
|
128
|
+
timeout: 300_000,
|
|
129
|
+
maxBuffer: 2_000_000,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
throw new LocalSetupEngineError("verification_failed", `Project verification failed while running ${packageManager} run ${script}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
command: selected
|
|
138
|
+
.map((script) => `${packageManager} run ${script}`)
|
|
139
|
+
.join(" && "),
|
|
140
|
+
exitCode: 0,
|
|
141
|
+
completedAt: new Date().toISOString(),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function childEnvironment(options) {
|
|
145
|
+
if (options?.setupVerification) {
|
|
146
|
+
const allowedNames = new Set([
|
|
147
|
+
"PATH",
|
|
148
|
+
"SHELL",
|
|
149
|
+
"TMPDIR",
|
|
150
|
+
"TMP",
|
|
151
|
+
"TEMP",
|
|
152
|
+
"LANG",
|
|
153
|
+
"LC_ALL",
|
|
154
|
+
"LC_CTYPE",
|
|
155
|
+
"TERM",
|
|
156
|
+
"CI",
|
|
157
|
+
"NO_COLOR",
|
|
158
|
+
"FORCE_COLOR",
|
|
159
|
+
"COLORTERM",
|
|
160
|
+
"SystemRoot",
|
|
161
|
+
"WINDIR",
|
|
162
|
+
]);
|
|
163
|
+
return {
|
|
164
|
+
...Object.fromEntries(Object.entries(process.env).filter(([name]) => allowedNames.has(name))),
|
|
165
|
+
SEAMWARD_INTERNAL_SETUP_VERIFICATION: "1",
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => !/(?:SEAMWARD|API_KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)/i.test(name)));
|
|
169
|
+
return environment;
|
|
170
|
+
}
|
|
171
|
+
async function installCollectorDependency(root, plan) {
|
|
172
|
+
if (!plan.install || !plan.packageManager)
|
|
173
|
+
return;
|
|
174
|
+
const packageSpec = `@seamward/collector@${supportedCollectorVersion}`;
|
|
175
|
+
const invocations = {
|
|
176
|
+
npm: [["npm", ["install", packageSpec]]],
|
|
177
|
+
pnpm: [
|
|
178
|
+
[
|
|
179
|
+
"pnpm",
|
|
180
|
+
[
|
|
181
|
+
"pkg",
|
|
182
|
+
"set",
|
|
183
|
+
`dependencies.@seamward/collector=${supportedCollectorVersion}`,
|
|
184
|
+
],
|
|
185
|
+
],
|
|
186
|
+
["pnpm", ["install", "--no-frozen-lockfile"]],
|
|
187
|
+
],
|
|
188
|
+
yarn: [["yarn", ["add", packageSpec]]],
|
|
189
|
+
bun: [["bun", ["add", packageSpec]]],
|
|
190
|
+
};
|
|
191
|
+
try {
|
|
192
|
+
for (const [command, arguments_] of invocations[plan.packageManager]) {
|
|
193
|
+
await execFileAsync(command, arguments_, {
|
|
194
|
+
cwd: root,
|
|
195
|
+
env: childEnvironment(),
|
|
196
|
+
timeout: 120_000,
|
|
197
|
+
maxBuffer: 1_000_000,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
throw new LocalSetupEngineError("internal_error", "The collector dependency could not be installed with the project's package manager");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async function readCollectorDependency(root) {
|
|
206
|
+
try {
|
|
207
|
+
const document = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
|
|
208
|
+
const value = document.dependencies?.["@seamward/collector"] ??
|
|
209
|
+
document.devDependencies?.["@seamward/collector"];
|
|
210
|
+
if (typeof value !== "string") {
|
|
211
|
+
return {
|
|
212
|
+
present: false,
|
|
213
|
+
compatible: false,
|
|
214
|
+
version: null,
|
|
215
|
+
minimumVersion: supportedCollectorVersion,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const version = await resolveCollectorVersion(root, value);
|
|
219
|
+
return {
|
|
220
|
+
present: true,
|
|
221
|
+
compatible: version !== null && versionAtLeast(version, supportedCollectorVersion),
|
|
222
|
+
version: version ?? value,
|
|
223
|
+
minimumVersion: supportedCollectorVersion,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return {
|
|
228
|
+
present: false,
|
|
229
|
+
compatible: false,
|
|
230
|
+
version: null,
|
|
231
|
+
minimumVersion: supportedCollectorVersion,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function parseVersion(value) {
|
|
236
|
+
const match = value.match(/(?:^|[~^<>=\s])v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
|
|
237
|
+
if (!match)
|
|
238
|
+
return null;
|
|
239
|
+
return {
|
|
240
|
+
release: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
241
|
+
prerelease: (match[4] ?? "")
|
|
242
|
+
.split(".")
|
|
243
|
+
.filter(Boolean)
|
|
244
|
+
.map((part) => (/^\d+$/.test(part) ? Number(part) : part)),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function versionAtLeast(value, minimum) {
|
|
248
|
+
const candidate = parseVersion(value);
|
|
249
|
+
const floor = parseVersion(minimum);
|
|
250
|
+
if (!candidate || !floor)
|
|
251
|
+
return false;
|
|
252
|
+
for (let index = 0; index < candidate.release.length; index += 1) {
|
|
253
|
+
if (candidate.release[index] > floor.release[index])
|
|
254
|
+
return true;
|
|
255
|
+
if (candidate.release[index] < floor.release[index])
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
if (candidate.prerelease.length === 0)
|
|
259
|
+
return true;
|
|
260
|
+
if (floor.prerelease.length === 0)
|
|
261
|
+
return false;
|
|
262
|
+
const length = Math.max(candidate.prerelease.length, floor.prerelease.length);
|
|
263
|
+
for (let index = 0; index < length; index += 1) {
|
|
264
|
+
const left = candidate.prerelease[index];
|
|
265
|
+
const right = floor.prerelease[index];
|
|
266
|
+
if (left === undefined)
|
|
267
|
+
return true;
|
|
268
|
+
if (right === undefined)
|
|
269
|
+
return false;
|
|
270
|
+
if (left === right)
|
|
271
|
+
continue;
|
|
272
|
+
if (typeof left === "number" && typeof right === "string")
|
|
273
|
+
return false;
|
|
274
|
+
if (typeof left === "string" && typeof right === "number")
|
|
275
|
+
return true;
|
|
276
|
+
return left > right;
|
|
277
|
+
}
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
async function readPackageVersion(file) {
|
|
281
|
+
try {
|
|
282
|
+
const document = JSON.parse(await readFile(file, "utf8"));
|
|
283
|
+
return typeof document.version === "string" ? document.version : null;
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
async function resolveCollectorVersion(root, declared) {
|
|
290
|
+
const declaredVersion = parseVersion(declared);
|
|
291
|
+
if (declaredVersion) {
|
|
292
|
+
return declared.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/)?.[0] ?? null;
|
|
293
|
+
}
|
|
294
|
+
const local = declared.match(/^(?:file|link):(.+)$/)?.[1];
|
|
295
|
+
if (local) {
|
|
296
|
+
const localVersion = await readPackageVersion(path.resolve(root, local, "package.json"));
|
|
297
|
+
if (localVersion)
|
|
298
|
+
return localVersion;
|
|
299
|
+
}
|
|
300
|
+
return readPackageVersion(path.join(root, "node_modules", "@seamward", "collector", "package.json"));
|
|
301
|
+
}
|
|
302
|
+
export async function readSetupPlan(root, expectedFingerprint, setupId) {
|
|
303
|
+
try {
|
|
304
|
+
const requested = setupId
|
|
305
|
+
? `.seamward/integrations/${setupId}/setup-plan.json`
|
|
306
|
+
: ".seamward/setup-plan.json";
|
|
307
|
+
const plan = JSON.parse(await readFile(path.join(root, ...requested.split("/")), "utf8"));
|
|
308
|
+
if (plan.schemaVersion !== "0.2") {
|
|
309
|
+
throw new LocalSetupEngineError("legacy_plan_requires_replan", "This setup plan predates source-backed evidence. Analyze and plan again");
|
|
310
|
+
}
|
|
311
|
+
if (expectedFingerprint && plan.fingerprint !== expectedFingerprint) {
|
|
312
|
+
throw new LocalSetupEngineError("plan_not_found", "The requested setup plan was not found for this project");
|
|
313
|
+
}
|
|
314
|
+
return plan;
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
if (error instanceof LocalSetupEngineError)
|
|
318
|
+
throw error;
|
|
319
|
+
throw new LocalSetupEngineError("plan_not_found", "The setup plan was not found. Analyze and plan this service first");
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
export async function readSetupPlans(root) {
|
|
323
|
+
const integrationDirectory = path.join(root, ".seamward", "integrations");
|
|
324
|
+
let entries;
|
|
325
|
+
try {
|
|
326
|
+
entries = await readdir(integrationDirectory, { withFileTypes: true });
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
if (error.code === "ENOENT")
|
|
330
|
+
return [];
|
|
331
|
+
throw error;
|
|
332
|
+
}
|
|
333
|
+
const setupIds = entries
|
|
334
|
+
.filter((entry) => entry.isDirectory() &&
|
|
335
|
+
!entry.isSymbolicLink() &&
|
|
336
|
+
/^[a-z0-9][a-z0-9-]{0,62}$/.test(entry.name))
|
|
337
|
+
.map(({ name }) => name)
|
|
338
|
+
.sort();
|
|
339
|
+
return Promise.all(setupIds.map((setupId) => readSetupPlan(root, undefined, setupId)));
|
|
340
|
+
}
|
|
341
|
+
export async function verifySetup(root, plan) {
|
|
342
|
+
await applySetupPlan(plan, root);
|
|
343
|
+
const inspected = await inspectSetupPlanFiles(plan, root);
|
|
344
|
+
const generatedFiles = inspected.filter(({ file }) => file !== setupPlanFile(plan));
|
|
345
|
+
const artifactsValid = inspected.every(({ present, matches }) => present && matches);
|
|
346
|
+
const collectorDependency = await readCollectorDependency(root);
|
|
347
|
+
const evidence = await inspectSetupActionEvidence(plan, root);
|
|
348
|
+
const instrumentationActions = evidence.filter(({ actionType }) => actionType === "instrument_operation");
|
|
349
|
+
const contractActions = evidence.filter(({ actionType }) => actionType === "register_contract");
|
|
350
|
+
const remoteLifecycle = await inspectSetupRemoteLifecycle(plan, root);
|
|
351
|
+
const { fingerprint: stateFingerprint } = await inspectSetupCompletionState(plan, root);
|
|
352
|
+
const localBlockers = [];
|
|
353
|
+
if (!artifactsValid)
|
|
354
|
+
localBlockers.push({ code: "generated_artifact_invalid" });
|
|
355
|
+
if (plan.install !== null && !collectorDependency.present) {
|
|
356
|
+
localBlockers.push({ code: "collector_dependency_missing" });
|
|
357
|
+
}
|
|
358
|
+
else if (collectorDependency.present && !collectorDependency.compatible) {
|
|
359
|
+
localBlockers.push({ code: "collector_dependency_incompatible" });
|
|
360
|
+
}
|
|
361
|
+
for (const action of instrumentationActions) {
|
|
362
|
+
if (action.status !== "source_verified") {
|
|
363
|
+
localBlockers.push({ code: `instrumentation_${action.status}` });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const lifecycleBlockers = [];
|
|
367
|
+
for (const action of contractActions) {
|
|
368
|
+
if (action.status !== "registered") {
|
|
369
|
+
lifecycleBlockers.push({ code: "contract_registration_pending" });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (contractActions.length > 0 && !remoteLifecycle.activation) {
|
|
373
|
+
lifecycleBlockers.push({ code: "contract_activation_pending" });
|
|
374
|
+
}
|
|
375
|
+
if (!remoteLifecycle.firstObservation) {
|
|
376
|
+
lifecycleBlockers.push({ code: "first_observation_pending" });
|
|
377
|
+
}
|
|
378
|
+
const blockers = [...localBlockers, ...lifecycleBlockers];
|
|
379
|
+
return {
|
|
380
|
+
planFingerprint: plan.fingerprint,
|
|
381
|
+
stateFingerprint,
|
|
382
|
+
artifactsValid,
|
|
383
|
+
collectorDependency,
|
|
384
|
+
generatedFiles,
|
|
385
|
+
instrumentationActions,
|
|
386
|
+
contractActions,
|
|
387
|
+
remoteLifecycle,
|
|
388
|
+
sourceBackedComplete: localBlockers.length === 0 &&
|
|
389
|
+
instrumentationActions.every(({ status }) => status === "source_verified"),
|
|
390
|
+
localSetupComplete: localBlockers.length === 0,
|
|
391
|
+
setupComplete: blockers.length === 0,
|
|
392
|
+
blockers,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
export async function applyLocalSetupBatch(rootDirectory, entries, retiredPlans = []) {
|
|
396
|
+
const root = await realpath(path.resolve(rootDirectory));
|
|
397
|
+
const mutationFiles = [
|
|
398
|
+
...(await Promise.all(entries.map(({ plan }) => setupMutationFiles(root, plan)))).flat(),
|
|
399
|
+
...retiredPlans.flatMap((plan) => retirementOwnedFiles(plan)),
|
|
400
|
+
];
|
|
401
|
+
const snapshots = await snapshotFiles(root, mutationFiles);
|
|
402
|
+
const results = [];
|
|
403
|
+
try {
|
|
404
|
+
for (const { engine, plan } of entries) {
|
|
405
|
+
const result = await engine.applyLocal({
|
|
406
|
+
planFingerprint: plan.fingerprint,
|
|
407
|
+
});
|
|
408
|
+
if (result.status !== "verified") {
|
|
409
|
+
throw new LocalSetupEngineError("verification_failed", `Automatic setup could not safely transform ${plan.setupId ?? "the selected Integration"}`);
|
|
410
|
+
}
|
|
411
|
+
results.push({
|
|
412
|
+
...(plan.setupId ? { setupId: plan.setupId } : {}),
|
|
413
|
+
result,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
const retired = await retireLocalSetupBindings(root, retiredPlans);
|
|
417
|
+
const verificationPlan = entries[0]?.plan ?? retiredPlans[0];
|
|
418
|
+
if (verificationPlan) {
|
|
419
|
+
const finalVerification = await runProjectVerification(root, verificationPlan);
|
|
420
|
+
for (const { plan } of entries) {
|
|
421
|
+
const actions = operatorReviewableSetupActions(plan);
|
|
422
|
+
if (actions.length === 0)
|
|
423
|
+
continue;
|
|
424
|
+
const completion = await inspectSetupCompletionState(plan, root);
|
|
425
|
+
await recordSourceBackedSetupEvidence(plan, root, actions.map(({ id }) => id), {
|
|
426
|
+
write: true,
|
|
427
|
+
expectedStateFingerprint: completion.fingerprint,
|
|
428
|
+
evidence: "coding_agent_reviewed_setup",
|
|
429
|
+
verification: finalVerification,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return {
|
|
434
|
+
applied: await Promise.all(results.map(async (applied, index) => ({
|
|
435
|
+
...applied,
|
|
436
|
+
result: applied.result.status === "verified"
|
|
437
|
+
? {
|
|
438
|
+
...applied.result,
|
|
439
|
+
verification: await verifySetup(root, entries[index].plan),
|
|
440
|
+
}
|
|
441
|
+
: applied.result,
|
|
442
|
+
}))),
|
|
443
|
+
retired,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
await restoreFiles(snapshots);
|
|
448
|
+
throw error;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
function retirementOwnedFiles(plan) {
|
|
452
|
+
return [
|
|
453
|
+
...plan.generatedFiles.map(({ path: file }) => file),
|
|
454
|
+
setupStateLockFile(plan),
|
|
455
|
+
setupStateFile(plan),
|
|
456
|
+
setupPlanFile(plan),
|
|
457
|
+
];
|
|
458
|
+
}
|
|
459
|
+
export async function retireLocalSetupBindings(rootDirectory, plans) {
|
|
460
|
+
const root = await realpath(path.resolve(rootDirectory));
|
|
461
|
+
const results = [];
|
|
462
|
+
for (const plan of plans) {
|
|
463
|
+
if (!plan.setupId) {
|
|
464
|
+
throw new LocalSetupEngineError("internal_error", "Only named Integration bindings can be retired");
|
|
465
|
+
}
|
|
466
|
+
const generatedInspection = (await inspectSetupPlanFiles(plan, root)).filter(({ file }) => plan.generatedFiles.some(({ path: generated }) => generated === file));
|
|
467
|
+
if (generatedInspection.some(({ present, matches }) => present && !matches)) {
|
|
468
|
+
throw new LocalSetupEngineError("generated_file_conflict", `A generated file for ${plan.setupId} changed and will not be removed`);
|
|
469
|
+
}
|
|
470
|
+
const ownedFiles = retirementOwnedFiles(plan);
|
|
471
|
+
const snapshots = await snapshotFiles(root, ownedFiles);
|
|
472
|
+
const lockSnapshot = snapshots.find(({ file }) => file === projectPath(root, setupStateLockFile(plan)));
|
|
473
|
+
if (lockSnapshot && lockSnapshot.content !== null) {
|
|
474
|
+
throw new LocalSetupEngineError("apply_in_progress", `The setup binding for ${plan.setupId} is currently being updated`);
|
|
475
|
+
}
|
|
476
|
+
const integrationDirectory = path.dirname(projectPath(root, setupPlanFile(plan)));
|
|
477
|
+
try {
|
|
478
|
+
const entries = await readdir(integrationDirectory);
|
|
479
|
+
const ownedNames = new Set(ownedFiles
|
|
480
|
+
.filter((file) => path.dirname(projectPath(root, file)) === integrationDirectory)
|
|
481
|
+
.map((file) => path.basename(file)));
|
|
482
|
+
if (entries.some((entry) => !ownedNames.has(entry))) {
|
|
483
|
+
throw new LocalSetupEngineError("generated_file_conflict", `The setup directory for ${plan.setupId} contains files Seamward does not own`);
|
|
484
|
+
}
|
|
485
|
+
for (const file of ownedFiles) {
|
|
486
|
+
await rm(projectPath(root, file), { force: true });
|
|
487
|
+
}
|
|
488
|
+
await rmdir(integrationDirectory);
|
|
489
|
+
results.push({
|
|
490
|
+
setupId: plan.setupId,
|
|
491
|
+
files: snapshots
|
|
492
|
+
.filter(({ content }) => content !== null)
|
|
493
|
+
.map(({ file }) => path.relative(root, file).split(path.sep).join("/")),
|
|
494
|
+
remoteAction: "left_active",
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
catch (error) {
|
|
498
|
+
await restoreFiles(snapshots);
|
|
499
|
+
throw error;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return results;
|
|
503
|
+
}
|
|
504
|
+
export async function createLocalSetupEngine(options) {
|
|
505
|
+
const root = await realpath(path.resolve(options.root));
|
|
506
|
+
const setupId = options.setupId;
|
|
507
|
+
const plans = new Map();
|
|
508
|
+
const rememberPlan = (plan) => {
|
|
509
|
+
plans.delete(plan.fingerprint);
|
|
510
|
+
plans.set(plan.fingerprint, plan);
|
|
511
|
+
while (plans.size > 20) {
|
|
512
|
+
const oldest = plans.keys().next().value;
|
|
513
|
+
if (!oldest)
|
|
514
|
+
break;
|
|
515
|
+
plans.delete(oldest);
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
const resolvePlan = async (fingerprint) => {
|
|
519
|
+
const cached = plans.get(fingerprint);
|
|
520
|
+
if (cached)
|
|
521
|
+
return cached;
|
|
522
|
+
const saved = await readSetupPlan(root, fingerprint, setupId);
|
|
523
|
+
rememberPlan(saved);
|
|
524
|
+
return saved;
|
|
525
|
+
};
|
|
526
|
+
const analyze = async () => {
|
|
527
|
+
const [project, discovery] = await Promise.all([
|
|
528
|
+
readProjectMetadata(root),
|
|
529
|
+
scanProject(root),
|
|
530
|
+
]);
|
|
531
|
+
return {
|
|
532
|
+
project,
|
|
533
|
+
discovery,
|
|
534
|
+
integrationScopes: discoverIntegrationScopes(discovery),
|
|
535
|
+
privacy: {
|
|
536
|
+
sourceContentReturned: false,
|
|
537
|
+
environmentFilesRead: false,
|
|
538
|
+
absolutePathsReturned: false,
|
|
539
|
+
credentialsReturned: false,
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
};
|
|
543
|
+
const applyReviewedPlan = async (plan) => {
|
|
544
|
+
let current = null;
|
|
545
|
+
try {
|
|
546
|
+
current = await readSetupPlan(root, undefined, plan.setupId);
|
|
547
|
+
}
|
|
548
|
+
catch (error) {
|
|
549
|
+
if (!(error instanceof LocalSetupEngineError) ||
|
|
550
|
+
!["plan_not_found", "legacy_plan_requires_replan"].includes(error.code)) {
|
|
551
|
+
throw error;
|
|
552
|
+
}
|
|
553
|
+
if (error.code === "legacy_plan_requires_replan") {
|
|
554
|
+
for (const generated of plan.generatedFiles) {
|
|
555
|
+
try {
|
|
556
|
+
await readFile(path.join(root, generated.path), "utf8");
|
|
557
|
+
throw new LocalSetupEngineError("generated_file_conflict", "An existing generated file requires manual review before migration");
|
|
558
|
+
}
|
|
559
|
+
catch (fileError) {
|
|
560
|
+
if (fileError instanceof LocalSetupEngineError)
|
|
561
|
+
throw fileError;
|
|
562
|
+
if (fileError.code !== "ENOENT") {
|
|
563
|
+
throw fileError;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (current && current.fingerprint !== plan.fingerprint) {
|
|
570
|
+
const inspected = await inspectSetupPlanFiles(current, root);
|
|
571
|
+
if (inspected.some(({ present, matches }) => !present || !matches)) {
|
|
572
|
+
throw new LocalSetupEngineError("generated_file_conflict", "An existing generated file changed and will not be overwritten");
|
|
573
|
+
}
|
|
574
|
+
const applied = await applySetupPlan(plan, root, {
|
|
575
|
+
write: true,
|
|
576
|
+
force: true,
|
|
577
|
+
});
|
|
578
|
+
await resetSetupCompletionState(plan, root);
|
|
579
|
+
return applied;
|
|
580
|
+
}
|
|
581
|
+
if (!current) {
|
|
582
|
+
const applied = await applySetupPlan(plan, root, {
|
|
583
|
+
write: true,
|
|
584
|
+
force: true,
|
|
585
|
+
});
|
|
586
|
+
await resetSetupCompletionState(plan, root);
|
|
587
|
+
return applied;
|
|
588
|
+
}
|
|
589
|
+
return applySetupPlan(plan, root, { write: true });
|
|
590
|
+
};
|
|
591
|
+
return {
|
|
592
|
+
analyze,
|
|
593
|
+
async plan(selection) {
|
|
594
|
+
try {
|
|
595
|
+
const saved = await readSetupPlan(root, undefined, setupId);
|
|
596
|
+
if (planMatchesSelection(saved, selection)) {
|
|
597
|
+
const verification = await verifySetup(root, saved);
|
|
598
|
+
if (verification.sourceBackedComplete) {
|
|
599
|
+
rememberPlan(saved);
|
|
600
|
+
return {
|
|
601
|
+
plan: saved,
|
|
602
|
+
applyPreview: await applySetupPlan(saved, root),
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
catch (error) {
|
|
608
|
+
if (!(error instanceof LocalSetupEngineError) ||
|
|
609
|
+
!["plan_not_found", "legacy_plan_requires_replan"].includes(error.code)) {
|
|
610
|
+
throw error;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const analysis = await analyze();
|
|
614
|
+
const plan = createSetupPlan(analysis.discovery, {
|
|
615
|
+
...analysis.project,
|
|
616
|
+
integrationScope: {
|
|
617
|
+
direction: selection.direction,
|
|
618
|
+
protocol: selection.protocol,
|
|
619
|
+
},
|
|
620
|
+
contractFiles: selection.contractFiles ?? [],
|
|
621
|
+
...(selection.findingIds ? { findingIds: selection.findingIds } : {}),
|
|
622
|
+
...(setupId ? { setupId } : {}),
|
|
623
|
+
});
|
|
624
|
+
rememberPlan(plan);
|
|
625
|
+
return { plan, applyPreview: await applySetupPlan(plan, root) };
|
|
626
|
+
},
|
|
627
|
+
async applyGenerated(input) {
|
|
628
|
+
if (input.confirmation !== "write_generated_setup") {
|
|
629
|
+
throw new LocalSetupEngineError("confirmation_required", "Exact generated-setup confirmation is required");
|
|
630
|
+
}
|
|
631
|
+
return applySetupPlan(await resolvePlan(input.planFingerprint), root, {
|
|
632
|
+
write: true,
|
|
633
|
+
});
|
|
634
|
+
},
|
|
635
|
+
async replaceGenerated(input) {
|
|
636
|
+
if (input.confirmation !== "replace_generated_setup") {
|
|
637
|
+
throw new LocalSetupEngineError("confirmation_required", "Exact replacement confirmation is required");
|
|
638
|
+
}
|
|
639
|
+
const previous = await readSetupPlan(root, input.previousPlanFingerprint, setupId);
|
|
640
|
+
const next = await resolvePlan(input.planFingerprint);
|
|
641
|
+
if (JSON.stringify(previous.generatedFiles.map(({ path: file }) => file)) !== JSON.stringify(next.generatedFiles.map(({ path: file }) => file))) {
|
|
642
|
+
throw new LocalSetupEngineError("generated_file_conflict", "A replacement cannot change generated file paths");
|
|
643
|
+
}
|
|
644
|
+
const previousFiles = await inspectSetupPlanFiles(previous, root);
|
|
645
|
+
if (previousFiles.some(({ present, matches }) => !present || !matches)) {
|
|
646
|
+
throw new LocalSetupEngineError("generated_file_conflict", "A generated file changed after the previous plan was applied");
|
|
647
|
+
}
|
|
648
|
+
const applied = await applySetupPlan(next, root, {
|
|
649
|
+
write: true,
|
|
650
|
+
force: true,
|
|
651
|
+
});
|
|
652
|
+
await resetSetupCompletionState(next, root);
|
|
653
|
+
return applied;
|
|
654
|
+
},
|
|
655
|
+
async recordEvidence(input) {
|
|
656
|
+
if (input.confirmation !== "record_source_evidence") {
|
|
657
|
+
throw new LocalSetupEngineError("confirmation_required", "Exact source-evidence confirmation is required");
|
|
658
|
+
}
|
|
659
|
+
const plan = await resolvePlan(input.planFingerprint);
|
|
660
|
+
const selectedFiles = new Set(input.implementationFiles.map((file) => file.split(path.sep).join("/").replace(/^\.\//, "")));
|
|
661
|
+
const actions = operatorReviewableSetupActions(plan).filter((action) => action.file && selectedFiles.has(action.file));
|
|
662
|
+
if (actions.length === 0) {
|
|
663
|
+
throw new LocalSetupEngineError("no_matching_actions", "No planned instrumentation action matches the implementation files");
|
|
664
|
+
}
|
|
665
|
+
await recordSourceBackedSetupEvidence(plan, root, actions.map(({ id }) => id), {
|
|
666
|
+
write: true,
|
|
667
|
+
expectedStateFingerprint: input.expectedStateFingerprint,
|
|
668
|
+
...(input.note ? { evidence: input.note } : {}),
|
|
669
|
+
});
|
|
670
|
+
return {
|
|
671
|
+
applied: true,
|
|
672
|
+
planFingerprint: plan.fingerprint,
|
|
673
|
+
recordedActions: actions.map(({ file }) => ({
|
|
674
|
+
file: file ?? null,
|
|
675
|
+
status: "recorded",
|
|
676
|
+
})),
|
|
677
|
+
};
|
|
678
|
+
},
|
|
679
|
+
async applyLocal(input) {
|
|
680
|
+
try {
|
|
681
|
+
return await withSetupProjectLock(root, ".seamward/setup-apply.lock", "Local setup apply is already in progress", async () => {
|
|
682
|
+
const plan = await resolvePlan(input.planFingerprint);
|
|
683
|
+
const actions = operatorReviewableSetupActions(plan);
|
|
684
|
+
let current = null;
|
|
685
|
+
try {
|
|
686
|
+
current = await readSetupPlan(root, undefined, setupId);
|
|
687
|
+
}
|
|
688
|
+
catch (error) {
|
|
689
|
+
if (!(error instanceof LocalSetupEngineError) ||
|
|
690
|
+
!["plan_not_found", "legacy_plan_requires_replan"].includes(error.code)) {
|
|
691
|
+
throw error;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
if (current?.fingerprint === plan.fingerprint) {
|
|
695
|
+
const existingVerification = await verifySetup(root, plan);
|
|
696
|
+
const existingState = await inspectSetupCompletionState(plan, root);
|
|
697
|
+
const actionIds = new Set(actions.map(({ id }) => id));
|
|
698
|
+
if (existingVerification.sourceBackedComplete &&
|
|
699
|
+
existingState.state.completedActions
|
|
700
|
+
.filter(({ actionId }) => actionIds.has(actionId))
|
|
701
|
+
.every(({ verification }) => verification?.exitCode === 0)) {
|
|
702
|
+
return {
|
|
703
|
+
status: "verified",
|
|
704
|
+
files: [],
|
|
705
|
+
verification: existingVerification,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
const snapshots = await snapshotFiles(root, await setupMutationFiles(root, plan));
|
|
710
|
+
try {
|
|
711
|
+
const dependency = await readCollectorDependency(root);
|
|
712
|
+
if (!dependency.present) {
|
|
713
|
+
await installCollectorDependency(root, plan);
|
|
714
|
+
}
|
|
715
|
+
const applied = await applyReviewedPlan(plan);
|
|
716
|
+
const source = await applyPlannedSourceInstrumentation(plan, root);
|
|
717
|
+
if (source.unsupported.length > 0) {
|
|
718
|
+
await restoreFiles(snapshots);
|
|
719
|
+
return {
|
|
720
|
+
status: "source_changes_required",
|
|
721
|
+
files: [],
|
|
722
|
+
sourceChanges: source.unsupported,
|
|
723
|
+
verification: await verifySetup(root, plan),
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
const projectVerification = await runProjectVerification(root, plan);
|
|
727
|
+
if (actions.length > 0) {
|
|
728
|
+
const completion = await inspectSetupCompletionState(plan, root);
|
|
729
|
+
await recordSourceBackedSetupEvidence(plan, root, actions.map(({ id }) => id), {
|
|
730
|
+
write: true,
|
|
731
|
+
expectedStateFingerprint: completion.fingerprint,
|
|
732
|
+
evidence: "coding_agent_reviewed_setup",
|
|
733
|
+
verification: projectVerification,
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
return {
|
|
737
|
+
status: "verified",
|
|
738
|
+
files: [...new Set([...applied.files, ...source.changedFiles])],
|
|
739
|
+
verification: await verifySetup(root, plan),
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
catch (error) {
|
|
743
|
+
await restoreFiles(snapshots);
|
|
744
|
+
if (error instanceof LocalSetupEngineError)
|
|
745
|
+
throw error;
|
|
746
|
+
throw new LocalSetupEngineError("verification_failed", "The local setup could not be verified and all project changes were restored", { cause: error });
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
catch (error) {
|
|
751
|
+
if (error instanceof Error &&
|
|
752
|
+
/local setup apply is already in progress/i.test(error.message)) {
|
|
753
|
+
throw new LocalSetupEngineError("apply_in_progress", "Another local setup apply is already in progress");
|
|
754
|
+
}
|
|
755
|
+
if (error instanceof LocalSetupEngineError)
|
|
756
|
+
throw error;
|
|
757
|
+
throw error;
|
|
758
|
+
}
|
|
759
|
+
},
|
|
760
|
+
async verify(input) {
|
|
761
|
+
return verifySetup(root, await resolvePlan(input.planFingerprint));
|
|
762
|
+
},
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
//# sourceMappingURL=setup-engine.js.map
|