@tomflow/proflow-platform-cli 0.1.0
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 +37 -0
- package/conformance.json +1 -0
- package/dist/deployment/adapter.d.ts +57 -0
- package/dist/deployment/adapter.js +25 -0
- package/dist/deployment/descriptor.d.ts +39 -0
- package/dist/deployment/descriptor.js +41 -0
- package/dist/src/apply/apply.d.ts +24 -0
- package/dist/src/apply/apply.js +271 -0
- package/dist/src/apply/cleanup.d.ts +12 -0
- package/dist/src/apply/cleanup.js +37 -0
- package/dist/src/apply/current.d.ts +4 -0
- package/dist/src/apply/current.js +73 -0
- package/dist/src/apply/driver.d.ts +23 -0
- package/dist/src/apply/driver.js +154 -0
- package/dist/src/apply/execute.d.ts +36 -0
- package/dist/src/apply/execute.js +124 -0
- package/dist/src/apply/index.d.ts +8 -0
- package/dist/src/apply/index.js +4 -0
- package/dist/src/apply/reality.d.ts +20 -0
- package/dist/src/apply/reality.js +93 -0
- package/dist/src/binding/production-bindings.d.ts +45 -0
- package/dist/src/binding/production-bindings.js +102 -0
- package/dist/src/cli.d.ts +12 -0
- package/dist/src/cli.js +712 -0
- package/dist/src/contracts.d.ts +113 -0
- package/dist/src/contracts.js +1 -0
- package/dist/src/discovery/catalog.d.ts +10 -0
- package/dist/src/discovery/catalog.js +76 -0
- package/dist/src/discovery/discover.d.ts +19 -0
- package/dist/src/discovery/discover.js +124 -0
- package/dist/src/discovery/index.d.ts +5 -0
- package/dist/src/discovery/index.js +4 -0
- package/dist/src/discovery/installed.d.ts +21 -0
- package/dist/src/discovery/installed.js +130 -0
- package/dist/src/discovery/workspace.d.ts +10 -0
- package/dist/src/discovery/workspace.js +155 -0
- package/dist/src/docs/docs.d.ts +47 -0
- package/dist/src/docs/docs.js +129 -0
- package/dist/src/docs/index.d.ts +1 -0
- package/dist/src/docs/index.js +1 -0
- package/dist/src/doctor/doctor.d.ts +41 -0
- package/dist/src/doctor/doctor.js +80 -0
- package/dist/src/doctor/index.d.ts +2 -0
- package/dist/src/doctor/index.js +1 -0
- package/dist/src/errors.d.ts +6 -0
- package/dist/src/errors.js +35 -0
- package/dist/src/graph/graph.d.ts +23 -0
- package/dist/src/graph/graph.js +132 -0
- package/dist/src/graph/index.d.ts +2 -0
- package/dist/src/graph/index.js +1 -0
- package/dist/src/index.d.ts +28 -0
- package/dist/src/index.js +18 -0
- package/dist/src/install/bootstrap.d.ts +10 -0
- package/dist/src/install/bootstrap.js +79 -0
- package/dist/src/install/environment.d.ts +29 -0
- package/dist/src/install/environment.js +226 -0
- package/dist/src/install/index.d.ts +6 -0
- package/dist/src/install/index.js +3 -0
- package/dist/src/install/install.d.ts +19 -0
- package/dist/src/install/install.js +131 -0
- package/dist/src/install/package-manager.d.ts +11 -0
- package/dist/src/install/package-manager.js +58 -0
- package/dist/src/lifecycle/dispatch.d.ts +47 -0
- package/dist/src/lifecycle/dispatch.js +142 -0
- package/dist/src/lifecycle/index.d.ts +3 -0
- package/dist/src/lifecycle/index.js +2 -0
- package/dist/src/lifecycle/service-process.d.ts +11 -0
- package/dist/src/lifecycle/service-process.js +326 -0
- package/dist/src/manifest/index.d.ts +2 -0
- package/dist/src/manifest/index.js +1 -0
- package/dist/src/manifest/manifest.d.ts +61 -0
- package/dist/src/manifest/manifest.js +160 -0
- package/dist/src/modules.d.ts +11 -0
- package/dist/src/modules.js +55 -0
- package/dist/src/observer/deployment-summary.d.ts +31 -0
- package/dist/src/observer/deployment-summary.js +34 -0
- package/dist/src/paths.d.ts +21 -0
- package/dist/src/paths.js +56 -0
- package/dist/src/persistence/atomic.d.ts +2 -0
- package/dist/src/persistence/atomic.js +16 -0
- package/dist/src/persistence/config.d.ts +12 -0
- package/dist/src/persistence/config.js +43 -0
- package/dist/src/persistence/generated.d.ts +2 -0
- package/dist/src/persistence/generated.js +4 -0
- package/dist/src/persistence/guards.d.ts +7 -0
- package/dist/src/persistence/guards.js +120 -0
- package/dist/src/persistence/index.d.ts +6 -0
- package/dist/src/persistence/index.js +5 -0
- package/dist/src/persistence/plans.d.ts +5 -0
- package/dist/src/persistence/plans.js +34 -0
- package/dist/src/persistence/state.d.ts +7 -0
- package/dist/src/persistence/state.js +34 -0
- package/dist/src/persistence/verification.d.ts +5 -0
- package/dist/src/persistence/verification.js +47 -0
- package/dist/src/planner/assemble.d.ts +16 -0
- package/dist/src/planner/assemble.js +107 -0
- package/dist/src/planner/check.d.ts +20 -0
- package/dist/src/planner/check.js +96 -0
- package/dist/src/planner/fingerprint.d.ts +12 -0
- package/dist/src/planner/fingerprint.js +164 -0
- package/dist/src/planner/index.d.ts +13 -0
- package/dist/src/planner/index.js +7 -0
- package/dist/src/planner/plan.d.ts +14 -0
- package/dist/src/planner/plan.js +156 -0
- package/dist/src/planner/repair.d.ts +28 -0
- package/dist/src/planner/repair.js +131 -0
- package/dist/src/planner/stale.d.ts +7 -0
- package/dist/src/planner/stale.js +23 -0
- package/dist/src/planner/steps.d.ts +37 -0
- package/dist/src/planner/steps.js +165 -0
- package/dist/src/planner/target-catalog.d.ts +13 -0
- package/dist/src/planner/target-catalog.js +48 -0
- package/dist/src/planner/upgrade.d.ts +18 -0
- package/dist/src/planner/upgrade.js +105 -0
- package/dist/src/preflight/config.d.ts +10 -0
- package/dist/src/preflight/config.js +45 -0
- package/dist/src/preflight/findings.d.ts +26 -0
- package/dist/src/preflight/findings.js +1 -0
- package/dist/src/preflight/index.d.ts +7 -0
- package/dist/src/preflight/index.js +3 -0
- package/dist/src/preflight/preflight.d.ts +8 -0
- package/dist/src/preflight/preflight.js +174 -0
- package/dist/src/preflight/requirements.d.ts +11 -0
- package/dist/src/preflight/requirements.js +224 -0
- package/dist/src/ready/index.d.ts +2 -0
- package/dist/src/ready/index.js +1 -0
- package/dist/src/ready/ready.d.ts +40 -0
- package/dist/src/ready/ready.js +201 -0
- package/dist/src/registry/index.d.ts +2 -0
- package/dist/src/registry/index.js +1 -0
- package/dist/src/registry/npm-registry.d.ts +51 -0
- package/dist/src/registry/npm-registry.js +341 -0
- package/dist/src/security/index.d.ts +3 -0
- package/dist/src/security/index.js +2 -0
- package/dist/src/security/lock.d.ts +9 -0
- package/dist/src/security/lock.js +89 -0
- package/dist/src/security/redact.d.ts +6 -0
- package/dist/src/security/redact.js +48 -0
- package/dist/src/verification/index.d.ts +2 -0
- package/dist/src/verification/index.js +1 -0
- package/dist/src/verification/verify.d.ts +33 -0
- package/dist/src/verification/verify.js +114 -0
- package/package.json +49 -0
- package/proflow.module.json +45 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { gunzipSync } from "node:zlib";
|
|
7
|
+
import { parseModuleDescriptor, proflowPackageMetadataSchema, } from "@tomflow/proflow-module-contract";
|
|
8
|
+
import { PlatformError } from "../errors.js";
|
|
9
|
+
import { versionSatisfies } from "../modules.js";
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
export const PRO_FLOW_SCOPE = "@tomflow";
|
|
12
|
+
export const PRO_FLOW_PACKAGE_PREFIX = "@tomflow/proflow-";
|
|
13
|
+
export function systemNpmRunner() {
|
|
14
|
+
return {
|
|
15
|
+
async run(args, cwd) {
|
|
16
|
+
try {
|
|
17
|
+
const result = await execFileAsync("npm", [...args], {
|
|
18
|
+
cwd,
|
|
19
|
+
encoding: "utf8",
|
|
20
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
21
|
+
});
|
|
22
|
+
return { stdout: result.stdout, stderr: result.stderr };
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
throw npmFailure(error);
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export async function resolveScopeRegistry(workspaceRoot, runner = systemNpmRunner()) {
|
|
31
|
+
const scoped = await runner.run(["config", "get", `${PRO_FLOW_SCOPE}:registry`], workspaceRoot);
|
|
32
|
+
const scopedValue = normalizeConfigValue(scoped.stdout);
|
|
33
|
+
if (scopedValue !== undefined)
|
|
34
|
+
return scopedValue;
|
|
35
|
+
const fallback = await runner.run(["config", "get", "registry"], workspaceRoot);
|
|
36
|
+
const registry = normalizeConfigValue(fallback.stdout);
|
|
37
|
+
if (registry === undefined) {
|
|
38
|
+
throw new PlatformError("REGISTRY_UNAVAILABLE", "npm registry is not configured");
|
|
39
|
+
}
|
|
40
|
+
return registry;
|
|
41
|
+
}
|
|
42
|
+
export async function discoverRegistryModules(options) {
|
|
43
|
+
const runner = options.runner ?? systemNpmRunner();
|
|
44
|
+
const registry = await resolveScopeRegistry(options.workspaceRoot, runner);
|
|
45
|
+
const names = options.packageName === undefined
|
|
46
|
+
? await searchPackageNames(options.workspaceRoot, registry, runner)
|
|
47
|
+
: [validateRequestedPackageName(options.packageName)];
|
|
48
|
+
const candidates = [];
|
|
49
|
+
const rejected = [];
|
|
50
|
+
for (const packageName of names) {
|
|
51
|
+
const manifest = await viewManifest(options.workspaceRoot, registry, packageName, runner);
|
|
52
|
+
const assessed = assessManifest(manifest, registry, options.nodeVersion ?? process.versions.node);
|
|
53
|
+
if ("candidate" in assessed)
|
|
54
|
+
candidates.push(assessed.candidate);
|
|
55
|
+
else
|
|
56
|
+
rejected.push(assessed.rejected);
|
|
57
|
+
}
|
|
58
|
+
candidates.sort((left, right) => left.packageName.localeCompare(right.packageName));
|
|
59
|
+
rejected.sort((left, right) => left.packageName.localeCompare(right.packageName));
|
|
60
|
+
return { registry, candidates, rejected };
|
|
61
|
+
}
|
|
62
|
+
export async function loadRegistryModuleDescriptor(options) {
|
|
63
|
+
const runner = options.runner ?? systemNpmRunner();
|
|
64
|
+
const tempRoot = await mkdtemp(join(tmpdir(), "proflow-registry-manifest-"));
|
|
65
|
+
try {
|
|
66
|
+
const packed = await runner.run([
|
|
67
|
+
"pack",
|
|
68
|
+
`${options.candidate.packageName}@${options.candidate.moduleVersion}`,
|
|
69
|
+
"--json",
|
|
70
|
+
"--ignore-scripts",
|
|
71
|
+
`--pack-destination=${tempRoot}`,
|
|
72
|
+
`--registry=${options.candidate.registry}`,
|
|
73
|
+
], options.workspaceRoot);
|
|
74
|
+
const filename = packFilename(packed.stdout, options.candidate.packageName);
|
|
75
|
+
const archive = await readFile(join(tempRoot, filename));
|
|
76
|
+
const manifestPath = `package/${options.candidate.manifest.replace(/^\.\//, "")}`;
|
|
77
|
+
const rawManifest = readTarEntry(archive, manifestPath);
|
|
78
|
+
const descriptor = parseModuleDescriptor(parseJson(rawManifest.toString("utf8"), `${options.candidate.packageName} module manifest`));
|
|
79
|
+
if (descriptor.packageName !== options.candidate.packageName ||
|
|
80
|
+
descriptor.moduleVersion !== options.candidate.moduleVersion) {
|
|
81
|
+
throw new PlatformError("REGISTRY_RESPONSE_INVALID", `Registry manifest identity/version mismatch for ${options.candidate.packageName}@${options.candidate.moduleVersion}`);
|
|
82
|
+
}
|
|
83
|
+
return descriptor;
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function packFilename(stdout, packageName) {
|
|
90
|
+
const parsed = parseJson(stdout, `npm pack ${packageName}`);
|
|
91
|
+
if (!Array.isArray(parsed) || parsed.length !== 1 || !isRecord(parsed[0])) {
|
|
92
|
+
throw new PlatformError("REGISTRY_RESPONSE_INVALID", `npm pack response is invalid for ${packageName}`);
|
|
93
|
+
}
|
|
94
|
+
const filename = parsed[0].filename;
|
|
95
|
+
if (typeof filename !== "string" ||
|
|
96
|
+
filename === "" ||
|
|
97
|
+
filename.includes("/") ||
|
|
98
|
+
filename.includes("\\")) {
|
|
99
|
+
throw new PlatformError("REGISTRY_RESPONSE_INVALID", `npm pack filename is invalid for ${packageName}`);
|
|
100
|
+
}
|
|
101
|
+
return filename;
|
|
102
|
+
}
|
|
103
|
+
function readTarEntry(tgz, targetName) {
|
|
104
|
+
let tar;
|
|
105
|
+
try {
|
|
106
|
+
tar = gunzipSync(tgz);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
throw new PlatformError("REGISTRY_RESPONSE_INVALID", "npm package tarball is not valid gzip");
|
|
110
|
+
}
|
|
111
|
+
for (let offset = 0; offset + 512 <= tar.length;) {
|
|
112
|
+
const header = tar.subarray(offset, offset + 512);
|
|
113
|
+
if (header.every((byte) => byte === 0))
|
|
114
|
+
break;
|
|
115
|
+
const name = tarString(header, 0, 100);
|
|
116
|
+
const prefix = tarString(header, 345, 155);
|
|
117
|
+
const entryName = prefix === "" ? name : `${prefix}/${name}`;
|
|
118
|
+
const sizeRaw = tarString(header, 124, 12).trim();
|
|
119
|
+
const size = sizeRaw === "" ? 0 : Number.parseInt(sizeRaw, 8);
|
|
120
|
+
if (!Number.isSafeInteger(size) || size < 0) {
|
|
121
|
+
throw new PlatformError("REGISTRY_RESPONSE_INVALID", "npm package tarball has an invalid entry size");
|
|
122
|
+
}
|
|
123
|
+
const dataStart = offset + 512;
|
|
124
|
+
const dataEnd = dataStart + size;
|
|
125
|
+
if (dataEnd > tar.length) {
|
|
126
|
+
throw new PlatformError("REGISTRY_RESPONSE_INVALID", "npm package tarball is truncated");
|
|
127
|
+
}
|
|
128
|
+
if (entryName === targetName) {
|
|
129
|
+
if (size > 1024 * 1024) {
|
|
130
|
+
throw new PlatformError("REGISTRY_RESPONSE_INVALID", "ProFlow module manifest exceeds 1 MiB");
|
|
131
|
+
}
|
|
132
|
+
return tar.subarray(dataStart, dataEnd);
|
|
133
|
+
}
|
|
134
|
+
offset = dataStart + Math.ceil(size / 512) * 512;
|
|
135
|
+
}
|
|
136
|
+
throw new PlatformError("REGISTRY_RESPONSE_INVALID", `npm package tarball does not contain ${targetName}`);
|
|
137
|
+
}
|
|
138
|
+
function tarString(header, start, length) {
|
|
139
|
+
const field = header.subarray(start, start + length);
|
|
140
|
+
const end = field.indexOf(0);
|
|
141
|
+
return field.subarray(0, end < 0 ? field.length : end).toString("utf8");
|
|
142
|
+
}
|
|
143
|
+
export async function discoverRegistryInstallClosure(options) {
|
|
144
|
+
const runner = options.runner ?? systemNpmRunner();
|
|
145
|
+
const queue = [validateRequestedPackageName(options.packageName)];
|
|
146
|
+
const seen = new Set();
|
|
147
|
+
const candidates = new Map();
|
|
148
|
+
const rejected = new Map();
|
|
149
|
+
let registry;
|
|
150
|
+
while (queue.length > 0) {
|
|
151
|
+
const packageName = queue.shift();
|
|
152
|
+
if (!packageName)
|
|
153
|
+
continue;
|
|
154
|
+
if (seen.has(packageName))
|
|
155
|
+
continue;
|
|
156
|
+
seen.add(packageName);
|
|
157
|
+
const discovered = await discoverRegistryModules({
|
|
158
|
+
workspaceRoot: options.workspaceRoot,
|
|
159
|
+
packageName,
|
|
160
|
+
runner,
|
|
161
|
+
...(options.nodeVersion === undefined
|
|
162
|
+
? {}
|
|
163
|
+
: { nodeVersion: options.nodeVersion }),
|
|
164
|
+
});
|
|
165
|
+
registry ??= discovered.registry;
|
|
166
|
+
for (const item of discovered.rejected)
|
|
167
|
+
rejected.set(item.packageName, item);
|
|
168
|
+
const candidate = discovered.candidates[0];
|
|
169
|
+
if (candidate === undefined) {
|
|
170
|
+
throw new PlatformError("PACKAGE_NOT_PROFLOW", `package ${packageName} is not an installable ProFlow module`);
|
|
171
|
+
}
|
|
172
|
+
candidates.set(candidate.packageName, candidate);
|
|
173
|
+
for (const dependency of candidate.installRequires) {
|
|
174
|
+
if (!seen.has(dependency))
|
|
175
|
+
queue.push(dependency);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (registry === undefined) {
|
|
179
|
+
registry = await resolveScopeRegistry(options.workspaceRoot, runner);
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
registry,
|
|
183
|
+
candidates: [...candidates.values()].sort((a, b) => a.packageName.localeCompare(b.packageName)),
|
|
184
|
+
rejected: [...rejected.values()].sort((a, b) => a.packageName.localeCompare(b.packageName)),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
async function searchPackageNames(workspaceRoot, registry, runner) {
|
|
188
|
+
const result = await runner.run([
|
|
189
|
+
"search",
|
|
190
|
+
PRO_FLOW_PACKAGE_PREFIX,
|
|
191
|
+
"--json",
|
|
192
|
+
"--searchlimit=250",
|
|
193
|
+
"--prefer-online",
|
|
194
|
+
`--registry=${registry}`,
|
|
195
|
+
], workspaceRoot);
|
|
196
|
+
const parsed = parseJson(result.stdout, "npm search response");
|
|
197
|
+
if (!Array.isArray(parsed)) {
|
|
198
|
+
throw new PlatformError("REGISTRY_RESPONSE_INVALID", "npm search response is not an array");
|
|
199
|
+
}
|
|
200
|
+
const names = new Set();
|
|
201
|
+
for (const raw of parsed) {
|
|
202
|
+
if (typeof raw?.name !== "string")
|
|
203
|
+
continue;
|
|
204
|
+
if (!raw.name.startsWith(PRO_FLOW_PACKAGE_PREFIX))
|
|
205
|
+
continue;
|
|
206
|
+
names.add(raw.name);
|
|
207
|
+
}
|
|
208
|
+
return [...names].sort();
|
|
209
|
+
}
|
|
210
|
+
async function viewManifest(workspaceRoot, registry, packageName, runner) {
|
|
211
|
+
const result = await runner.run([
|
|
212
|
+
"view",
|
|
213
|
+
packageName,
|
|
214
|
+
"--json",
|
|
215
|
+
"--prefer-online",
|
|
216
|
+
`--registry=${registry}`,
|
|
217
|
+
], workspaceRoot);
|
|
218
|
+
const parsed = parseJson(result.stdout, `npm view ${packageName}`);
|
|
219
|
+
if (!isRecord(parsed)) {
|
|
220
|
+
throw new PlatformError("REGISTRY_RESPONSE_INVALID", `npm view response is not an object for ${packageName}`);
|
|
221
|
+
}
|
|
222
|
+
return parsed;
|
|
223
|
+
}
|
|
224
|
+
function assessManifest(manifest, registry, nodeVersion) {
|
|
225
|
+
if (typeof manifest.name !== "string" ||
|
|
226
|
+
!manifest.name.startsWith(PRO_FLOW_PACKAGE_PREFIX)) {
|
|
227
|
+
return rejectedManifest(manifest, "NOT_PROFLOW_MODULE", "package name is outside the ProFlow scope prefix");
|
|
228
|
+
}
|
|
229
|
+
const packageName = manifest.name;
|
|
230
|
+
const moduleVersion = typeof manifest.version === "string" ? manifest.version : undefined;
|
|
231
|
+
if (moduleVersion === undefined) {
|
|
232
|
+
return rejectedManifest(manifest, "METADATA_INVALID", "package version is missing");
|
|
233
|
+
}
|
|
234
|
+
if (typeof manifest.deprecated === "string" &&
|
|
235
|
+
manifest.deprecated.trim() !== "") {
|
|
236
|
+
return {
|
|
237
|
+
rejected: {
|
|
238
|
+
packageName,
|
|
239
|
+
moduleVersion,
|
|
240
|
+
reason: "DEPRECATED",
|
|
241
|
+
message: manifest.deprecated,
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
const metadata = proflowPackageMetadataSchema.safeParse(manifest.proflow);
|
|
246
|
+
if (!metadata.success) {
|
|
247
|
+
return {
|
|
248
|
+
rejected: {
|
|
249
|
+
packageName,
|
|
250
|
+
moduleVersion,
|
|
251
|
+
reason: "METADATA_INVALID",
|
|
252
|
+
message: "package.json.proflow does not satisfy the ProFlow Module Contract",
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const nodeVersionRange = nodeRangeOf(manifest.engines);
|
|
257
|
+
if (nodeVersionRange !== undefined &&
|
|
258
|
+
!versionSatisfies(nodeVersion, nodeVersionRange)) {
|
|
259
|
+
return {
|
|
260
|
+
rejected: {
|
|
261
|
+
packageName,
|
|
262
|
+
moduleVersion,
|
|
263
|
+
reason: "NODE_INCOMPATIBLE",
|
|
264
|
+
message: `node ${nodeVersion} does not satisfy ${nodeVersionRange}`,
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
candidate: {
|
|
270
|
+
packageName,
|
|
271
|
+
moduleVersion,
|
|
272
|
+
...(typeof manifest.description === "string"
|
|
273
|
+
? { description: manifest.description }
|
|
274
|
+
: {}),
|
|
275
|
+
installClass: metadata.data.installClass,
|
|
276
|
+
descriptor: metadata.data.descriptor,
|
|
277
|
+
manifest: metadata.data.manifest,
|
|
278
|
+
installRequires: [...metadata.data.installRequires],
|
|
279
|
+
...(nodeVersionRange === undefined ? {} : { nodeVersionRange }),
|
|
280
|
+
registry,
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function validateRequestedPackageName(packageName) {
|
|
285
|
+
if (!packageName.startsWith(PRO_FLOW_PACKAGE_PREFIX)) {
|
|
286
|
+
throw new PlatformError("PACKAGE_NOT_PROFLOW", `package ${packageName} is outside ${PRO_FLOW_PACKAGE_PREFIX}*`);
|
|
287
|
+
}
|
|
288
|
+
return packageName;
|
|
289
|
+
}
|
|
290
|
+
function nodeRangeOf(value) {
|
|
291
|
+
if (!isRecord(value))
|
|
292
|
+
return undefined;
|
|
293
|
+
return typeof value.node === "string" ? value.node : undefined;
|
|
294
|
+
}
|
|
295
|
+
function rejectedManifest(manifest, reason, message) {
|
|
296
|
+
return {
|
|
297
|
+
rejected: {
|
|
298
|
+
packageName: typeof manifest.name === "string" ? manifest.name : "<unknown>",
|
|
299
|
+
...(typeof manifest.version === "string"
|
|
300
|
+
? { moduleVersion: manifest.version }
|
|
301
|
+
: {}),
|
|
302
|
+
reason,
|
|
303
|
+
message,
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
function normalizeConfigValue(value) {
|
|
308
|
+
const trimmed = value.trim();
|
|
309
|
+
if (trimmed === "" || trimmed === "undefined" || trimmed === "null") {
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
return trimmed;
|
|
313
|
+
}
|
|
314
|
+
function parseJson(value, label) {
|
|
315
|
+
try {
|
|
316
|
+
return JSON.parse(value);
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
throw new PlatformError("REGISTRY_RESPONSE_INVALID", `${label} is not valid JSON`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function isRecord(value) {
|
|
323
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
324
|
+
}
|
|
325
|
+
function npmFailure(error) {
|
|
326
|
+
const detail = npmErrorText(error);
|
|
327
|
+
if (/E401|E403|ENEEDAUTH|authentication|authorized/i.test(detail)) {
|
|
328
|
+
return new PlatformError("REGISTRY_AUTH_REQUIRED", `npm registry authentication is required: ${detail}`);
|
|
329
|
+
}
|
|
330
|
+
if (/E404|not found/i.test(detail)) {
|
|
331
|
+
return new PlatformError("PACKAGE_NOT_FOUND", detail);
|
|
332
|
+
}
|
|
333
|
+
return new PlatformError("REGISTRY_UNAVAILABLE", detail);
|
|
334
|
+
}
|
|
335
|
+
function npmErrorText(error) {
|
|
336
|
+
if (!isRecord(error))
|
|
337
|
+
return String(error);
|
|
338
|
+
const stderr = typeof error.stderr === "string" ? error.stderr.trim() : "";
|
|
339
|
+
const message = typeof error.message === "string" ? error.message : String(error);
|
|
340
|
+
return stderr === "" ? message : stderr;
|
|
341
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { LockRecord } from "../contracts.ts";
|
|
2
|
+
import type { WorkspacePaths } from "../paths.ts";
|
|
3
|
+
export interface WorkspaceLockHandle {
|
|
4
|
+
record: LockRecord;
|
|
5
|
+
release(): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export declare function workspaceLockPath(paths: WorkspacePaths): string;
|
|
8
|
+
export declare function readWorkspaceLock(paths: WorkspacePaths): Promise<LockRecord | undefined>;
|
|
9
|
+
export declare function acquireWorkspaceLock(paths: WorkspacePaths, planRef: string): Promise<WorkspaceLockHandle>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile, realpath, unlink, } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { PlatformError } from "../errors.js";
|
|
5
|
+
import { isLockRecord } from "../persistence/guards.js";
|
|
6
|
+
export function workspaceLockPath(paths) {
|
|
7
|
+
return join(paths.runtime, "apply.lock");
|
|
8
|
+
}
|
|
9
|
+
function errorHasCode(error, code) {
|
|
10
|
+
return (typeof error === "object" &&
|
|
11
|
+
error !== null &&
|
|
12
|
+
"code" in error &&
|
|
13
|
+
error.code === code);
|
|
14
|
+
}
|
|
15
|
+
async function computeFingerprint(root) {
|
|
16
|
+
let canonical = root;
|
|
17
|
+
try {
|
|
18
|
+
canonical = await realpath(root);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
canonical = resolve(root);
|
|
22
|
+
}
|
|
23
|
+
return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
|
|
24
|
+
}
|
|
25
|
+
export async function readWorkspaceLock(paths) {
|
|
26
|
+
let raw;
|
|
27
|
+
try {
|
|
28
|
+
raw = await readFile(workspaceLockPath(paths), "utf8");
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(raw);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
return isLockRecord(parsed) ? parsed : undefined;
|
|
41
|
+
}
|
|
42
|
+
function lockedError(existing) {
|
|
43
|
+
const details = existing === undefined
|
|
44
|
+
? "existing lock is unreadable"
|
|
45
|
+
: `pid=${existing.pid} planRef=${existing.planRef} since=${existing.createdAt} fingerprint=${existing.workspaceFingerprint}`;
|
|
46
|
+
return new PlatformError("WORKSPACE_LOCKED", `workspace apply already in progress (${details})`);
|
|
47
|
+
}
|
|
48
|
+
// v1 single-process exclusive lock: O_EXCL create, no distributed coordination,
|
|
49
|
+
// and no reclaim of a lock that cannot be proven stale.
|
|
50
|
+
export async function acquireWorkspaceLock(paths, planRef) {
|
|
51
|
+
const file = workspaceLockPath(paths);
|
|
52
|
+
await mkdir(paths.runtime, { recursive: true });
|
|
53
|
+
const record = {
|
|
54
|
+
pid: process.pid,
|
|
55
|
+
createdAt: new Date().toISOString(),
|
|
56
|
+
planRef,
|
|
57
|
+
workspaceFingerprint: await computeFingerprint(paths.root),
|
|
58
|
+
};
|
|
59
|
+
let handle;
|
|
60
|
+
try {
|
|
61
|
+
handle = await open(file, "wx", 0o644);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (errorHasCode(error, "EEXIST")) {
|
|
65
|
+
throw lockedError(await readWorkspaceLock(paths));
|
|
66
|
+
}
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
await handle.writeFile(`${JSON.stringify(record, null, 2)}\n`, {
|
|
71
|
+
encoding: "utf8",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
await handle.close();
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
record,
|
|
79
|
+
async release() {
|
|
80
|
+
try {
|
|
81
|
+
await unlink(file);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (!errorHasCode(error, "ENOENT"))
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { DeploymentPlan } from "../contracts.ts";
|
|
2
|
+
export declare const SECRET_REDACTED = "<redacted>";
|
|
3
|
+
export declare function isValidSecretRef(value: string): boolean;
|
|
4
|
+
export declare function redactSecretValues(values: Record<string, string>, _secretRefs: readonly string[]): Record<string, string>;
|
|
5
|
+
export declare function redactDeep(value: unknown, rawSecrets: readonly string[]): unknown;
|
|
6
|
+
export declare function redactPlanSecrets(plan: DeploymentPlan): DeploymentPlan;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export const SECRET_REDACTED = "<redacted>";
|
|
2
|
+
const SECRET_REF_PATTERN = /^(?:secret:\/\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*|credential-ref:[A-Za-z0-9._-]+)$/;
|
|
3
|
+
export function isValidSecretRef(value) {
|
|
4
|
+
return SECRET_REF_PATTERN.test(value);
|
|
5
|
+
}
|
|
6
|
+
// secretRef slot values are opaque reference identities (e.g.
|
|
7
|
+
// `secret://model-provider/default`, `credential-ref:local-platform`), NOT raw
|
|
8
|
+
// tokens/passwords. They are safe to persist and display verbatim, so this
|
|
9
|
+
// function preserves them instead of substituting a redaction marker. Raw
|
|
10
|
+
// secret leak scanning is `redactDeep`'s job, not this function's.
|
|
11
|
+
export function redactSecretValues(values, _secretRefs) {
|
|
12
|
+
return { ...values };
|
|
13
|
+
}
|
|
14
|
+
function redactString(value, secrets) {
|
|
15
|
+
let out = value;
|
|
16
|
+
for (const secret of secrets) {
|
|
17
|
+
if (secret.length === 0)
|
|
18
|
+
continue;
|
|
19
|
+
out = out.split(secret).join(SECRET_REDACTED);
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
// Deep-structural redaction: returns a clone with every occurrence of a raw
|
|
24
|
+
// secret value replaced, in any nested string position. This is the leak-scan
|
|
25
|
+
// layer for genuine raw secret values (test sentinels or future non-secretRef
|
|
26
|
+
// sensitive values). secretRef references are NOT raw secrets and must never be
|
|
27
|
+
// passed here as raw secrets.
|
|
28
|
+
export function redactDeep(value, rawSecrets) {
|
|
29
|
+
const secrets = rawSecrets.filter((secret) => secret.length > 0);
|
|
30
|
+
if (typeof value === "string")
|
|
31
|
+
return redactString(value, secrets);
|
|
32
|
+
if (Array.isArray(value))
|
|
33
|
+
return value.map((item) => redactDeep(item, secrets));
|
|
34
|
+
if (typeof value === "object" && value !== null) {
|
|
35
|
+
const out = {};
|
|
36
|
+
for (const [key, item] of Object.entries(value)) {
|
|
37
|
+
out[key] = redactDeep(item, secrets);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
// Plans are public DTOs whose secretRef config values are opaque reference
|
|
44
|
+
// identities, not raw secrets. This returns a non-mutating clone that preserves
|
|
45
|
+
// secretRef references verbatim (no `<redacted>` substitution).
|
|
46
|
+
export function redactPlanSecrets(plan) {
|
|
47
|
+
return structuredClone(plan);
|
|
48
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { configFingerprint, verificationRefOf, verifyModule, verifyModules, } from "./verify.js";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ModuleOperationResult } from "@tomflow/proflow-module-contract";
|
|
2
|
+
import type { ResolvedModule, VerificationRecord } from "../contracts.ts";
|
|
3
|
+
import type { ModuleCatalog } from "../modules.ts";
|
|
4
|
+
import type { WorkspacePaths } from "../paths.ts";
|
|
5
|
+
export interface VerifyResult {
|
|
6
|
+
moduleRef: string;
|
|
7
|
+
record: VerificationRecord;
|
|
8
|
+
result: ModuleOperationResult;
|
|
9
|
+
observedEffects: string[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Derives a stable verificationRef from the record's own identity fields. It is
|
|
13
|
+
* deterministic (no randomness) yet unique per verification event, because the
|
|
14
|
+
* recorded `verifiedAt` timestamp is part of the canonical input.
|
|
15
|
+
*/
|
|
16
|
+
export declare function verificationRefOf(record: Omit<VerificationRecord, "verificationRef">): string;
|
|
17
|
+
/**
|
|
18
|
+
* Fingerprints the identity-defining config of an external resource. Key order
|
|
19
|
+
* is normalized so the fingerprint is stable regardless of insertion order.
|
|
20
|
+
*/
|
|
21
|
+
export declare function configFingerprint(values: Record<string, string>, secretRefs?: readonly string[]): string;
|
|
22
|
+
/**
|
|
23
|
+
* Verifies a single module by dispatching its public `verify` primitive through
|
|
24
|
+
* the lifecycle boundary, then appending a Version Verification Record. The
|
|
25
|
+
* verdict always comes from the live adapter result, never from persisted
|
|
26
|
+
* history. Records are appended, never overwritten.
|
|
27
|
+
*/
|
|
28
|
+
export declare function verifyModule(catalog: ModuleCatalog, module: ResolvedModule, paths: WorkspacePaths): Promise<VerifyResult>;
|
|
29
|
+
/**
|
|
30
|
+
* Verifies every module that declares the `verify` primitive; modules without
|
|
31
|
+
* it are skipped (the descriptor is the truth for what is verifiable).
|
|
32
|
+
*/
|
|
33
|
+
export declare function verifyModules(catalog: ModuleCatalog, modules: readonly ResolvedModule[], paths: WorkspacePaths): Promise<VerifyResult[]>;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { dispatchLifecycle } from "../lifecycle/index.js";
|
|
3
|
+
import { appendVerification, loadConfig } from "../persistence/index.js";
|
|
4
|
+
/**
|
|
5
|
+
* Derives a stable verificationRef from the record's own identity fields. It is
|
|
6
|
+
* deterministic (no randomness) yet unique per verification event, because the
|
|
7
|
+
* recorded `verifiedAt` timestamp is part of the canonical input.
|
|
8
|
+
*/
|
|
9
|
+
export function verificationRefOf(record) {
|
|
10
|
+
const canonical = JSON.stringify([
|
|
11
|
+
record.moduleRef,
|
|
12
|
+
record.moduleVersion,
|
|
13
|
+
record.resourceIdentity ?? null,
|
|
14
|
+
record.resourceVersion ?? null,
|
|
15
|
+
record.result,
|
|
16
|
+
record.verifiedAt,
|
|
17
|
+
]);
|
|
18
|
+
const digest = createHash("sha256").update(canonical).digest("hex");
|
|
19
|
+
return `verify-${record.moduleRef}-${digest.slice(0, 16)}`;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Fingerprints the identity-defining config of an external resource. Key order
|
|
23
|
+
* is normalized so the fingerprint is stable regardless of insertion order.
|
|
24
|
+
*/
|
|
25
|
+
export function configFingerprint(values, secretRefs = []) {
|
|
26
|
+
const entries = [
|
|
27
|
+
...Object.entries(values)
|
|
28
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
29
|
+
.map(([key, value]) => `${key}=${value}`),
|
|
30
|
+
...[...secretRefs].sort().map((key) => `secretRef=${key}`),
|
|
31
|
+
];
|
|
32
|
+
const digest = createHash("sha256").update(entries.join("\n")).digest("hex");
|
|
33
|
+
return `fp-${digest.slice(0, 16)}`;
|
|
34
|
+
}
|
|
35
|
+
// A verification record is a binary verdict: only an explicit SUCCEEDED maps to
|
|
36
|
+
// PASS. BLOCKED / ACTION_REQUIRED / FAILED are all recorded as FAIL so a pending
|
|
37
|
+
// human action or blocked dependency can never masquerade as a passing verify.
|
|
38
|
+
function verdictOf(status) {
|
|
39
|
+
return status === "SUCCEEDED" ? "PASS" : "FAIL";
|
|
40
|
+
}
|
|
41
|
+
function summaryOf(result) {
|
|
42
|
+
if (result.status === "SUCCEEDED") {
|
|
43
|
+
const checks = result.checks ?? [];
|
|
44
|
+
if (checks.length === 0)
|
|
45
|
+
return "verify succeeded";
|
|
46
|
+
const passed = checks.filter((check) => check.status === "PASS").length;
|
|
47
|
+
return `verify succeeded (${passed}/${checks.length} checks passed)`;
|
|
48
|
+
}
|
|
49
|
+
if (result.status === "ACTION_REQUIRED" &&
|
|
50
|
+
result.actionRequired !== undefined) {
|
|
51
|
+
return `action required: ${result.actionRequired.action} — ${result.actionRequired.description}`;
|
|
52
|
+
}
|
|
53
|
+
if (result.error !== undefined) {
|
|
54
|
+
return `${result.error.code}: ${result.error.message}`;
|
|
55
|
+
}
|
|
56
|
+
return `verify ${result.status.toLowerCase()}`;
|
|
57
|
+
}
|
|
58
|
+
function evidenceRefsOf(result) {
|
|
59
|
+
return (result.checks ?? []).map((check) => `check:${check.id}:${check.status}`);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Verifies a single module by dispatching its public `verify` primitive through
|
|
63
|
+
* the lifecycle boundary, then appending a Version Verification Record. The
|
|
64
|
+
* verdict always comes from the live adapter result, never from persisted
|
|
65
|
+
* history. Records are appended, never overwritten.
|
|
66
|
+
*/
|
|
67
|
+
export async function verifyModule(catalog, module, paths) {
|
|
68
|
+
const dispatched = await dispatchLifecycle(catalog, module, "verify");
|
|
69
|
+
const result = dispatched.result;
|
|
70
|
+
let resourceIdentity;
|
|
71
|
+
let resourceVersion;
|
|
72
|
+
if (module.kind === "external-resource") {
|
|
73
|
+
const config = await loadConfig(paths, module.moduleRef);
|
|
74
|
+
const secretRefs = module.configSlots
|
|
75
|
+
.filter((slot) => slot.type === "secretRef")
|
|
76
|
+
.map((slot) => slot.key);
|
|
77
|
+
resourceIdentity = configFingerprint(config?.publicValues ?? {}, secretRefs);
|
|
78
|
+
resourceVersion = result.resourceVersion;
|
|
79
|
+
}
|
|
80
|
+
const base = {
|
|
81
|
+
moduleRef: module.moduleRef,
|
|
82
|
+
moduleVersion: module.moduleVersion,
|
|
83
|
+
result: verdictOf(result.status),
|
|
84
|
+
summary: summaryOf(result),
|
|
85
|
+
evidenceRefs: evidenceRefsOf(result),
|
|
86
|
+
verifiedAt: new Date().toISOString(),
|
|
87
|
+
...(resourceIdentity !== undefined ? { resourceIdentity } : {}),
|
|
88
|
+
...(resourceVersion !== undefined ? { resourceVersion } : {}),
|
|
89
|
+
};
|
|
90
|
+
const record = {
|
|
91
|
+
...base,
|
|
92
|
+
verificationRef: verificationRefOf(base),
|
|
93
|
+
};
|
|
94
|
+
await appendVerification(paths, record);
|
|
95
|
+
return {
|
|
96
|
+
moduleRef: module.moduleRef,
|
|
97
|
+
record,
|
|
98
|
+
result,
|
|
99
|
+
observedEffects: dispatched.observedEffects,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Verifies every module that declares the `verify` primitive; modules without
|
|
104
|
+
* it are skipped (the descriptor is the truth for what is verifiable).
|
|
105
|
+
*/
|
|
106
|
+
export async function verifyModules(catalog, modules, paths) {
|
|
107
|
+
const results = [];
|
|
108
|
+
for (const module of modules) {
|
|
109
|
+
if (!module.lifecycle.includes("verify"))
|
|
110
|
+
continue;
|
|
111
|
+
results.push(await verifyModule(catalog, module, paths));
|
|
112
|
+
}
|
|
113
|
+
return results;
|
|
114
|
+
}
|