@tomflow/proflow-platform-cli 0.1.1 → 0.1.3

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 (41) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +24 -3
  3. package/dist/deployment/adapter.d.ts +5 -5
  4. package/dist/deployment/descriptor.d.ts +1 -1
  5. package/dist/deployment/descriptor.js +1 -1
  6. package/dist/src/apply/apply.d.ts +2 -0
  7. package/dist/src/apply/apply.js +47 -4
  8. package/dist/src/apply/current.js +14 -0
  9. package/dist/src/apply/driver.d.ts +1 -0
  10. package/dist/src/apply/driver.js +40 -12
  11. package/dist/src/apply/execute.js +28 -0
  12. package/dist/src/binding/global-binding.d.ts +69 -0
  13. package/dist/src/binding/global-binding.js +349 -0
  14. package/dist/src/binding/production-bindings.d.ts +2 -12
  15. package/dist/src/binding/production-bindings.js +18 -4
  16. package/dist/src/cli.d.ts +13 -1
  17. package/dist/src/cli.js +661 -40
  18. package/dist/src/errors.d.ts +1 -1
  19. package/dist/src/errors.js +13 -0
  20. package/dist/src/install/environment.d.ts +2 -1
  21. package/dist/src/install/environment.js +34 -30
  22. package/dist/src/install/install.js +41 -0
  23. package/dist/src/install/package-manager.d.ts +4 -2
  24. package/dist/src/install/package-manager.js +73 -17
  25. package/dist/src/lifecycle/dispatch.js +52 -1
  26. package/dist/src/lifecycle/service-process.js +32 -2
  27. package/dist/src/persistence/guards.js +1 -0
  28. package/dist/src/planner/check.js +12 -1
  29. package/dist/src/planner/plan.d.ts +1 -0
  30. package/dist/src/planner/plan.js +14 -26
  31. package/dist/src/preflight/config.d.ts +1 -0
  32. package/dist/src/preflight/config.js +43 -0
  33. package/dist/src/preflight/preflight.d.ts +2 -0
  34. package/dist/src/preflight/preflight.js +18 -2
  35. package/dist/src/preflight/requirements.d.ts +2 -2
  36. package/dist/src/preflight/requirements.js +16 -9
  37. package/dist/src/registry/npm-registry.d.ts +1 -1
  38. package/dist/src/registry/npm-registry.js +2 -1
  39. package/dist/src/security/lock.js +40 -2
  40. package/package.json +4 -4
  41. package/proflow.module.json +1 -1
@@ -1,4 +1,4 @@
1
- export declare const platformErrorCodes: readonly ["INVALID_REQUEST", "DESCRIPTOR_INVALID", "DUPLICATE_IDENTITY", "DEPENDENCY_UNRESOLVED", "DEPENDENCY_INCOMPATIBLE", "DEPENDENCY_CYCLE", "CONFIG_MISSING", "SECRET_REF_INVALID", "PLAN_STALE", "PLAN_NOT_FOUND", "PLAN_INVALID", "WORKSPACE_LOCKED", "LIFECYCLE_UNSUPPORTED", "SECRET_LEAK", "APPLY_FAILED", "UPGRADE_FAILED", "CORE_PACKAGE_REQUIRED", "UNINSTALL_FAILED", "VERIFY_FAILED", "REGISTRY_UNAVAILABLE", "REGISTRY_AUTH_REQUIRED", "REGISTRY_RESPONSE_INVALID", "PACKAGE_NOT_FOUND", "PACKAGE_NOT_PROFLOW", "COMMAND_FAILED"];
1
+ export declare const platformErrorCodes: readonly ["INVALID_REQUEST", "DESCRIPTOR_INVALID", "DUPLICATE_IDENTITY", "DEPENDENCY_UNRESOLVED", "DEPENDENCY_INCOMPATIBLE", "DEPENDENCY_CYCLE", "CONFIG_MISSING", "CONFIG_INVALID", "SECRET_REF_INVALID", "PLAN_STALE", "PLAN_NOT_FOUND", "PLAN_INVALID", "WORKSPACE_LOCKED", "WORKSPACE_NOT_FOUND", "WORKSPACE_NOT_WRITABLE", "WORKSPACE_NOT_BOUND", "WORKSPACE_ALREADY_BOUND", "BOUND_WORKSPACE_MISSING", "WORKSPACE_INSTANCE_INVALID", "GLOBAL_BINDING_INVALID", "GLOBAL_BINDING_LOCKED", "GLOBAL_OPERATION_LOCKED", "PACKAGE_MANAGER_UNSUPPORTED", "PACKAGE_MANAGER_CONFLICT", "PACKAGE_MANAGER_UNAVAILABLE", "LIFECYCLE_UNSUPPORTED", "SECRET_LEAK", "APPLY_FAILED", "UPGRADE_FAILED", "CORE_PACKAGE_REQUIRED", "UNINSTALL_FAILED", "VERIFY_FAILED", "REGISTRY_UNAVAILABLE", "REGISTRY_AUTH_REQUIRED", "REGISTRY_RESPONSE_INVALID", "PACKAGE_NOT_FOUND", "PACKAGE_NOT_PROFLOW", "COMMAND_FAILED"];
2
2
  export type PlatformErrorCode = (typeof platformErrorCodes)[number];
3
3
  export declare class PlatformError extends Error {
4
4
  readonly code: PlatformErrorCode;
@@ -6,11 +6,24 @@ export const platformErrorCodes = [
6
6
  "DEPENDENCY_INCOMPATIBLE",
7
7
  "DEPENDENCY_CYCLE",
8
8
  "CONFIG_MISSING",
9
+ "CONFIG_INVALID",
9
10
  "SECRET_REF_INVALID",
10
11
  "PLAN_STALE",
11
12
  "PLAN_NOT_FOUND",
12
13
  "PLAN_INVALID",
13
14
  "WORKSPACE_LOCKED",
15
+ "WORKSPACE_NOT_FOUND",
16
+ "WORKSPACE_NOT_WRITABLE",
17
+ "WORKSPACE_NOT_BOUND",
18
+ "WORKSPACE_ALREADY_BOUND",
19
+ "BOUND_WORKSPACE_MISSING",
20
+ "WORKSPACE_INSTANCE_INVALID",
21
+ "GLOBAL_BINDING_INVALID",
22
+ "GLOBAL_BINDING_LOCKED",
23
+ "GLOBAL_OPERATION_LOCKED",
24
+ "PACKAGE_MANAGER_UNSUPPORTED",
25
+ "PACKAGE_MANAGER_CONFLICT",
26
+ "PACKAGE_MANAGER_UNAVAILABLE",
14
27
  "LIFECYCLE_UNSUPPORTED",
15
28
  "SECRET_LEAK",
16
29
  "APPLY_FAILED",
@@ -1,5 +1,5 @@
1
1
  import { type NpmCommandRunner } from "../registry/index.ts";
2
- import { type SupportedWorkspacePackageManager } from "./package-manager.ts";
2
+ import { type PackageManagerSelectionSource, type SupportedWorkspacePackageManager } from "./package-manager.ts";
3
3
  export declare const PLATFORM_INSTALL_NODE_RANGE = ">=24.19.0";
4
4
  export type InstallerPreflightStatus = "READY" | "ACTION_REQUIRED" | "BLOCKED";
5
5
  export type InstallerFindingSeverity = "info" | "action" | "error";
@@ -17,6 +17,7 @@ export interface InstallerEnvironmentPreflightResult {
17
17
  npmVersion?: string;
18
18
  packageManager?: {
19
19
  name: SupportedWorkspacePackageManager;
20
+ source: PackageManagerSelectionSource;
20
21
  declared?: string;
21
22
  version?: string;
22
23
  };
@@ -48,57 +48,61 @@ export async function preflightInstallerEnvironment(options) {
48
48
  findings.push(platformErrorFinding(error, "NPM_UNAVAILABLE"));
49
49
  }
50
50
  }
51
- const packageManager = await readWorkspacePackageManagerSelection(options.workspaceRoot);
51
+ let packageManager;
52
52
  let packageManagerVersion;
53
- if (packageManager === undefined) {
54
- findings.push({
55
- code: "PACKAGE_MANAGER_UNSUPPORTED",
56
- severity: "action",
57
- message: `unsupported packageManager declaration: ${workspace.packageManager ?? "<unknown>"}`,
58
- });
59
- }
60
- else if (packageManager.name === "pnpm") {
61
- if (!findExecutable("pnpm")) {
53
+ try {
54
+ packageManager = await readWorkspacePackageManagerSelection(options.workspaceRoot);
55
+ if (!findExecutable(packageManager.name)) {
62
56
  findings.push({
63
- code: "PNPM_REQUIRED",
57
+ code: "PACKAGE_MANAGER_UNAVAILABLE",
64
58
  severity: "action",
65
- message: `workspace declares ${packageManager.declared}; install/enable pnpm before applying package changes`,
59
+ message: `workspace selects ${packageManager.name}, but that executable is not available on PATH`,
66
60
  });
67
61
  }
62
+ else if (packageManager.name === "npm" && npmVersion !== undefined) {
63
+ packageManagerVersion = npmVersion;
64
+ }
68
65
  else {
69
66
  try {
70
- const result = (await systemPackageCommandRunner().run("pnpm", ["--version"], options.workspaceRoot)).trim();
71
- packageManagerVersion = result;
72
- findings.push({
73
- code: "PNPM_READY",
74
- severity: "info",
75
- message: `pnpm ${result} is available for this workspace`,
76
- });
67
+ packageManagerVersion = (await systemPackageCommandRunner().run(packageManager.name, ["--version"], options.workspaceRoot)).trim();
77
68
  }
78
69
  catch (error) {
79
70
  findings.push({
80
- code: "PNPM_REQUIRED",
71
+ code: "PACKAGE_MANAGER_UNAVAILABLE",
81
72
  severity: "action",
82
73
  message: error instanceof Error ? error.message : String(error),
83
74
  });
84
75
  }
85
76
  }
77
+ if (packageManagerVersion !== undefined) {
78
+ findings.push({
79
+ code: "PACKAGE_MANAGER_READY",
80
+ severity: "info",
81
+ message: `${packageManager.name} ${packageManagerVersion} is selected via ${packageManager.source}`,
82
+ });
83
+ }
86
84
  }
87
- else if (npmVersion !== undefined) {
88
- packageManagerVersion = npmVersion;
89
- findings.push({
90
- code: "PACKAGE_MANAGER_READY",
91
- severity: "info",
92
- message: packageManager.declared === undefined
93
- ? "workspace does not declare packageManager; npm will be used for bootstrap package operations"
94
- : `workspace declares ${packageManager.declared}`,
95
- });
85
+ catch (error) {
86
+ if (error instanceof PlatformError) {
87
+ findings.push({
88
+ code: error.code,
89
+ severity: error.code === "PACKAGE_MANAGER_CONFLICT" ? "error" : "action",
90
+ message: error.message,
91
+ });
92
+ }
93
+ else {
94
+ findings.push({
95
+ code: "PACKAGE_MANAGER_UNAVAILABLE",
96
+ severity: "action",
97
+ message: error instanceof Error ? error.message : String(error),
98
+ });
99
+ }
96
100
  }
97
101
  let registry;
98
102
  if (npmVersion !== undefined) {
99
103
  try {
100
104
  registry = await resolveScopeRegistry(options.workspaceRoot, runner);
101
- await runner.run(["ping", "--json", "--prefer-online", `--registry=${registry}`], options.workspaceRoot);
105
+ await runner.run(["ping", "--json", "--prefer-online", `--registry=${registry}`], options.workspaceRoot, 10_000);
102
106
  findings.push({
103
107
  code: "REGISTRY_READY",
104
108
  severity: "info",
@@ -111,6 +111,47 @@ export function renderInstallDoc(input) {
111
111
  }
112
112
  lines.push("");
113
113
  }
114
+ const missingRequiredConfig = {};
115
+ for (const module of modules) {
116
+ const resolved = resolveModuleConfig(module, input.config?.[module.moduleRef]);
117
+ for (const slot of module.configSlots) {
118
+ if (!slot.required || resolved.values[slot.key] !== undefined)
119
+ continue;
120
+ const placeholder = slot.type === "secretRef"
121
+ ? "secret://provider/name"
122
+ : slot.type === "url"
123
+ ? "https://example.invalid"
124
+ : slot.type === "path"
125
+ ? "/absolute/path"
126
+ : slot.type === "moduleRef"
127
+ ? "module-ref"
128
+ : "<value>";
129
+ const moduleConfig = missingRequiredConfig[module.moduleRef] ?? {};
130
+ moduleConfig[slot.key] = placeholder;
131
+ missingRequiredConfig[module.moduleRef] = moduleConfig;
132
+ }
133
+ }
134
+ lines.push("## Configure Before Start");
135
+ lines.push("");
136
+ if (Object.keys(missingRequiredConfig).length === 0) {
137
+ lines.push("No required configuration is currently missing. Re-run `platform preflight --intent start` before starting to confirm current runtime readiness.");
138
+ }
139
+ else {
140
+ lines.push("Create a JSON config file (for example `./proflow-config.json`) using the required missing slots below. Replace placeholders with real environment-specific values. `secretRef` values must remain opaque references such as `secret://provider/name`; do not place raw secrets in this file.");
141
+ lines.push("");
142
+ lines.push("```json");
143
+ lines.push(JSON.stringify({ modules: missingRequiredConfig }, null, 2));
144
+ lines.push("```");
145
+ lines.push("");
146
+ lines.push("Apply and re-check the configuration:");
147
+ lines.push("");
148
+ lines.push("1. `platform plan --intent configure --config ./proflow-config.json`");
149
+ lines.push("2. Copy the returned `planRef`.");
150
+ lines.push("3. `platform apply <planRef>`");
151
+ lines.push("4. `platform preflight --intent start`");
152
+ lines.push("5. Resolve any remaining human or external-resource actions reported by preflight, then run the same preflight command again until the Platform reports the expected readiness state.");
153
+ }
154
+ lines.push("");
114
155
  lines.push("## Verification Plan");
115
156
  lines.push("");
116
157
  for (const module of modules) {
@@ -1,11 +1,13 @@
1
- export type SupportedWorkspacePackageManager = "npm" | "pnpm";
1
+ export type SupportedWorkspacePackageManager = "npm" | "yarn" | "pnpm";
2
+ export type PackageManagerSelectionSource = "declared" | "lockfile" | "bootstrap-default";
2
3
  export interface WorkspacePackageManagerSelection {
3
4
  name: SupportedWorkspacePackageManager;
5
+ source: PackageManagerSelectionSource;
4
6
  declared?: string;
5
7
  }
6
8
  export interface PackageCommandRunner {
7
9
  run(command: string, args: readonly string[], cwd: string): Promise<string>;
8
10
  }
9
11
  export declare function systemPackageCommandRunner(): PackageCommandRunner;
10
- export declare function readWorkspacePackageManagerSelection(workspaceRoot: string): Promise<WorkspacePackageManagerSelection | undefined>;
12
+ export declare function readWorkspacePackageManagerSelection(workspaceRoot: string): Promise<WorkspacePackageManagerSelection>;
11
13
  export declare function findExecutable(command: string): boolean;
@@ -1,8 +1,9 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { accessSync, constants } from "node:fs";
3
- import { readFile } from "node:fs/promises";
3
+ import { access, readFile } from "node:fs/promises";
4
4
  import { delimiter, join } from "node:path";
5
5
  import { promisify } from "node:util";
6
+ import { PlatformError } from "../errors.js";
6
7
  const execFileAsync = promisify(execFile);
7
8
  export function systemPackageCommandRunner() {
8
9
  return {
@@ -17,25 +18,27 @@ export function systemPackageCommandRunner() {
17
18
  };
18
19
  }
19
20
  export async function readWorkspacePackageManagerSelection(workspaceRoot) {
20
- let declared;
21
- try {
22
- const raw = await readFile(join(workspaceRoot, "package.json"), "utf8");
23
- const parsed = JSON.parse(raw);
24
- if (isRecord(parsed) && typeof parsed.packageManager === "string") {
25
- declared = parsed.packageManager;
21
+ const declared = await readDeclaredPackageManager(workspaceRoot);
22
+ const lockManagers = await detectLockfileManagers(workspaceRoot);
23
+ if (declared !== undefined) {
24
+ const name = packageManagerName(declared);
25
+ if (name === undefined) {
26
+ throw new PlatformError("PACKAGE_MANAGER_UNSUPPORTED", `unsupported packageManager declaration: ${declared}; expected npm, yarn, or pnpm`);
27
+ }
28
+ const conflicts = [...lockManagers].filter((manager) => manager !== name);
29
+ if (conflicts.length > 0) {
30
+ throw new PlatformError("PACKAGE_MANAGER_CONFLICT", `packageManager declares ${declared}, but conflicting lockfiles indicate ${conflicts.join(", ")}`);
26
31
  }
32
+ return { name, source: "declared", declared };
27
33
  }
28
- catch {
29
- // A Fresh Workspace without package.json uses npm for bootstrap. Invalid
30
- // package.json is rejected earlier by installer preflight.
34
+ if (lockManagers.size > 1) {
35
+ throw new PlatformError("PACKAGE_MANAGER_CONFLICT", `multiple package-manager lockfiles are present: ${[...lockManagers].sort().join(", ")}`);
31
36
  }
32
- if (declared === undefined)
33
- return { name: "npm" };
34
- const separator = declared.lastIndexOf("@");
35
- const name = separator > 0 ? declared.slice(0, separator) : declared;
36
- if (name !== "npm" && name !== "pnpm")
37
- return undefined;
38
- return { name, declared };
37
+ const [fromLockfile] = lockManagers;
38
+ if (fromLockfile !== undefined) {
39
+ return { name: fromLockfile, source: "lockfile" };
40
+ }
41
+ return { name: "npm", source: "bootstrap-default" };
39
42
  }
40
43
  export function findExecutable(command) {
41
44
  const path = process.env.PATH ?? "";
@@ -53,6 +56,59 @@ export function findExecutable(command) {
53
56
  }
54
57
  return false;
55
58
  }
59
+ async function readDeclaredPackageManager(workspaceRoot) {
60
+ try {
61
+ const raw = await readFile(join(workspaceRoot, "package.json"), "utf8");
62
+ const parsed = JSON.parse(raw);
63
+ if (!isRecord(parsed)) {
64
+ throw new PlatformError("PACKAGE_MANAGER_CONFLICT", "workspace package.json root must be an object");
65
+ }
66
+ return typeof parsed.packageManager === "string"
67
+ ? parsed.packageManager
68
+ : undefined;
69
+ }
70
+ catch (error) {
71
+ if (isMissingFile(error))
72
+ return undefined;
73
+ if (error instanceof PlatformError)
74
+ throw error;
75
+ throw new PlatformError("PACKAGE_MANAGER_CONFLICT", `cannot inspect workspace package.json: ${errorMessage(error)}`);
76
+ }
77
+ }
78
+ async function detectLockfileManagers(workspaceRoot) {
79
+ const managers = new Set();
80
+ if ((await pathExists(join(workspaceRoot, "package-lock.json"))) ||
81
+ (await pathExists(join(workspaceRoot, "npm-shrinkwrap.json")))) {
82
+ managers.add("npm");
83
+ }
84
+ if (await pathExists(join(workspaceRoot, "yarn.lock")))
85
+ managers.add("yarn");
86
+ if (await pathExists(join(workspaceRoot, "pnpm-lock.yaml")))
87
+ managers.add("pnpm");
88
+ return managers;
89
+ }
90
+ function packageManagerName(declared) {
91
+ const separator = declared.lastIndexOf("@");
92
+ const name = separator > 0 ? declared.slice(0, separator) : declared;
93
+ return name === "npm" || name === "yarn" || name === "pnpm"
94
+ ? name
95
+ : undefined;
96
+ }
97
+ async function pathExists(path) {
98
+ try {
99
+ await access(path);
100
+ return true;
101
+ }
102
+ catch {
103
+ return false;
104
+ }
105
+ }
56
106
  function isRecord(value) {
57
107
  return typeof value === "object" && value !== null && !Array.isArray(value);
58
108
  }
109
+ function isMissingFile(error) {
110
+ return isRecord(error) && error.code === "ENOENT";
111
+ }
112
+ function errorMessage(error) {
113
+ return error instanceof Error ? error.message : String(error);
114
+ }
@@ -110,7 +110,58 @@ async function runInOrder(catalog, modules, order, primitive) {
110
110
  * `start` primitive only to modules that declare it.
111
111
  */
112
112
  export async function startModules(catalog, modules) {
113
- return runInOrder(catalog, modules, buildDependencyGraph(modules).order, "start");
113
+ const graph = buildDependencyGraph(modules);
114
+ const byRef = new Map(modules.map((module) => [module.moduleRef, module]));
115
+ const resultByRef = new Map();
116
+ const results = [];
117
+ for (const moduleRef of graph.order) {
118
+ const module = byRef.get(moduleRef);
119
+ if (module === undefined)
120
+ continue;
121
+ if (!module.lifecycle.includes("start")) {
122
+ const skipped = await runOne(catalog, module, "start");
123
+ results.push(skipped);
124
+ resultByRef.set(moduleRef, skipped);
125
+ continue;
126
+ }
127
+ const blockingDependency = graph.edges
128
+ .filter((edge) => edge.from === moduleRef)
129
+ .map((edge) => edge.to)
130
+ .find((dependencyRef) => {
131
+ const dependencyModule = byRef.get(dependencyRef);
132
+ if (!dependencyModule?.lifecycle.includes("start"))
133
+ return false;
134
+ const dependencyResult = resultByRef.get(dependencyRef);
135
+ return dependencyResult?.result?.status !== "SUCCEEDED";
136
+ });
137
+ if (blockingDependency !== undefined) {
138
+ const blocked = {
139
+ moduleRef,
140
+ primitive: "start",
141
+ status: "EXECUTED",
142
+ result: {
143
+ contract: "deployment.result.v1",
144
+ ok: false,
145
+ status: "BLOCKED",
146
+ moduleRef: module.moduleRef,
147
+ moduleVersion: module.moduleVersion,
148
+ error: {
149
+ code: "COMMAND_FAILED",
150
+ message: `dependency ${blockingDependency} did not start successfully; ${moduleRef} was not started`,
151
+ retryable: true,
152
+ },
153
+ },
154
+ observedEffects: [],
155
+ };
156
+ results.push(blocked);
157
+ resultByRef.set(moduleRef, blocked);
158
+ continue;
159
+ }
160
+ const result = await runOne(catalog, module, "start");
161
+ results.push(result);
162
+ resultByRef.set(moduleRef, result);
163
+ }
164
+ return results;
114
165
  }
115
166
  /**
116
167
  * Stops a module set in reverse dependency topological order, dispatching the
@@ -195,6 +195,15 @@ export async function managedServiceStatus(paths, module) {
195
195
  observedEffects: [],
196
196
  };
197
197
  }
198
+ async function waitForStartupStability(pid, milliseconds = 500) {
199
+ const deadline = Date.now() + milliseconds;
200
+ while (Date.now() < deadline) {
201
+ if (!isAlive(pid))
202
+ return false;
203
+ await new Promise((resolveWait) => setTimeout(resolveWait, 50));
204
+ }
205
+ return isAlive(pid);
206
+ }
198
207
  export async function startManagedService(paths, module, rawBinding) {
199
208
  const existing = await readRecord(paths, module.moduleRef);
200
209
  if (existing && (await isOwnedProcess(existing))) {
@@ -243,13 +252,20 @@ export async function startManagedService(paths, module, rawBinding) {
243
252
  result: failed(module, "service process spawned without pid"),
244
253
  observedEffects: [],
245
254
  };
255
+ const pid = child.pid;
256
+ if (!(await waitForStartupStability(pid))) {
257
+ return {
258
+ result: failed(module, `service process ${pid} exited during startup stabilization`),
259
+ observedEffects: [],
260
+ };
261
+ }
246
262
  child.unref();
247
263
  const record = {
248
264
  contract: "deployment.service-process-state.v1",
249
265
  moduleRef: module.moduleRef,
250
266
  packageName: module.packageName,
251
267
  moduleVersion: module.moduleVersion,
252
- pid: child.pid,
268
+ pid,
253
269
  startedAt: new Date().toISOString(),
254
270
  binPath,
255
271
  configPath,
@@ -257,8 +273,22 @@ export async function startManagedService(paths, module, rawBinding) {
257
273
  stderrPath,
258
274
  };
259
275
  await writeJsonAtomic(recordPath(paths, module.moduleRef), record, 0o600);
276
+ const observed = await managedServiceStatus(paths, module);
277
+ if (observed.result.status !== "SUCCEEDED") {
278
+ await removeRecord(paths, module.moduleRef);
279
+ try {
280
+ process.kill(pid, "SIGTERM");
281
+ }
282
+ catch {
283
+ // process already exited between observations
284
+ }
285
+ return {
286
+ result: failed(module, `service process ${pid} could not be observed as the owned RUNNING process after spawn`),
287
+ observedEffects: [],
288
+ };
289
+ }
260
290
  return {
261
- result: { ...base(module), data: { state: "RUNNING", pid: child.pid } },
291
+ ...observed,
262
292
  observedEffects: ["Manage the declared service process"],
263
293
  };
264
294
  }
@@ -19,6 +19,7 @@ function isDeploymentIntent(value) {
19
19
  return (value === "install" ||
20
20
  value === "configure" ||
21
21
  value === "upgrade" ||
22
+ value === "uninstall" ||
22
23
  value === "repair");
23
24
  }
24
25
  function isSelectedModuleFact(value) {
@@ -76,7 +76,18 @@ function checkConfig(step, plan, reality) {
76
76
  return notSatisfied(`required config ${slot.key} is missing`);
77
77
  }
78
78
  }
79
- return satisfied(`required config materialized for ${step.moduleRef}`);
79
+ // A configure step is satisfied only when current persisted reality matches
80
+ // the immutable target carried by this plan. Required-key presence alone is
81
+ // insufficient: a failed/partial prior apply can leave every key present but
82
+ // with values that the module-owned materializer rejected.
83
+ const target = plan.moduleTargets.find((entry) => entry.moduleRef === step.moduleRef)?.config;
84
+ for (const [key, expected] of Object.entries(target ?? {})) {
85
+ const observed = reality.configValues[key];
86
+ if (observed !== expected) {
87
+ return notSatisfied(`config ${key} for ${step.moduleRef} is ${observed === undefined ? "not observed" : "not at the planned target"}`);
88
+ }
89
+ }
90
+ return satisfied(`planned config materialized for ${step.moduleRef}`);
80
91
  }
81
92
  function checkVerify(step, plan, reality) {
82
93
  const module = moduleOf(plan, step);
@@ -9,6 +9,7 @@ export interface PlanInput {
9
9
  currentDescriptors?: readonly ModuleDescriptor[];
10
10
  targetDescriptors?: readonly ModuleDescriptor[];
11
11
  facts?: readonly RepairFact[];
12
+ uninstallScope?: "module" | "platform-instance";
12
13
  now?: Date;
13
14
  }
14
15
  export declare function planDeployment(input: PlanInput): DeploymentPlan;
@@ -52,14 +52,14 @@ function planInstallOrConfigure(input) {
52
52
  const graph = buildDependencyGraph(modules);
53
53
  const steps = input.intent === "install"
54
54
  ? installSteps(modules, graph)
55
- : configureSteps(modules, graph);
55
+ : configureSteps(modules, graph, input.targets ?? [], input.config);
56
56
  return assemblePlan({
57
57
  intent: input.intent,
58
58
  modules,
59
59
  targets: input.targets ?? [],
60
60
  config: input.config,
61
61
  steps,
62
- humanActions: humanActionsFromModules(modules),
62
+ humanActions: input.intent === "install" ? humanActionsFromModules(modules) : [],
63
63
  now: input.now ?? new Date(),
64
64
  });
65
65
  }
@@ -90,35 +90,23 @@ function installSteps(modules, graph) {
90
90
  }
91
91
  return steps;
92
92
  }
93
- function configureSteps(modules, graph) {
93
+ function configureSteps(modules, graph, targets, config) {
94
94
  const seq = createSequencer();
95
95
  const byRef = new Map(modules.map((module) => [module.moduleRef, module]));
96
+ const targetByRef = new Map(targets.map((target) => [target.moduleRef, target]));
96
97
  const steps = [];
97
98
  for (const ref of graph.order) {
98
99
  const module = byRef.get(ref);
99
- if (module === undefined)
100
+ if (module === undefined || module.configSlots.length === 0)
100
101
  continue;
101
- if (module.configSlots.length > 0) {
102
- steps.push(configStep(seq, module));
103
- }
104
- if (module.kind === "external-resource") {
105
- const step = externalResourceStep(seq, module);
106
- if (step !== undefined)
107
- steps.push(step);
108
- }
109
- if (module.kind === "service") {
110
- if (module.lifecycle.includes("restart")) {
111
- steps.push(lifecycleStep(seq, module, "restart"));
112
- }
113
- else if (module.lifecycle.includes("start")) {
114
- steps.push(lifecycleStep(seq, module, "start"));
115
- }
116
- }
117
- for (const requirement of module.requirements) {
118
- if (requirement.kind === "human") {
119
- steps.push(humanStep(seq, module, requirement.action));
120
- }
121
- }
102
+ const explicitConfig = targetByRef.get(ref)?.config;
103
+ const providedConfig = config?.[ref];
104
+ const hasMaterializationTarget = (explicitConfig !== undefined &&
105
+ Object.keys(explicitConfig).length > 0) ||
106
+ (providedConfig !== undefined && Object.keys(providedConfig).length > 0);
107
+ if (!hasMaterializationTarget)
108
+ continue;
109
+ steps.push(configStep(seq, module));
122
110
  }
123
111
  return steps;
124
112
  }
@@ -128,7 +116,7 @@ function planUninstall(input) {
128
116
  throw new PlatformError("INVALID_REQUEST", "uninstall requires modules");
129
117
  }
130
118
  const core = modules.find((module) => module.installClass === "core");
131
- if (core !== undefined) {
119
+ if (core !== undefined && input.uninstallScope !== "platform-instance") {
132
120
  throw new PlatformError("CORE_PACKAGE_REQUIRED", `core module ${core.moduleRef} cannot be individually uninstalled`);
133
121
  }
134
122
  const graph = buildDependencyGraph(modules);
@@ -8,3 +8,4 @@ export interface ResolvedModuleConfig {
8
8
  }
9
9
  export declare function resolveModuleConfig(module: ResolvedModule, provided: Record<string, string> | undefined): ResolvedModuleConfig;
10
10
  export declare function checkConfigReadiness(modules: readonly ResolvedModule[], config: Record<string, Record<string, string>> | undefined): PreflightFinding[];
11
+ export declare function checkConfigReality(modules: readonly ResolvedModule[], config: Record<string, Record<string, string>> | undefined): Promise<PreflightFinding[]>;
@@ -1,3 +1,5 @@
1
+ import { constants } from "node:fs";
2
+ import { access, readFile, stat } from "node:fs/promises";
1
3
  import { PlatformError } from "../errors.js";
2
4
  import { isValidSecretRef } from "../security/redact.js";
3
5
  function compareRef(a, b) {
@@ -43,3 +45,44 @@ export function checkConfigReadiness(modules, config) {
43
45
  }
44
46
  return findings;
45
47
  }
48
+ function requiresExistingInputFile(key, sensitive) {
49
+ return (sensitive === true ||
50
+ /(?:ConfigPath|ProfilesFile|CredentialFile|TokenFile|tokenFile|credentialFile)$/.test(key));
51
+ }
52
+ function requiresJsonFile(key) {
53
+ return /(?:ConfigPath|ProfilesFile)$/.test(key);
54
+ }
55
+ export async function checkConfigReality(modules, config) {
56
+ const findings = [];
57
+ for (const module of [...modules].sort((a, b) => compareRef(a.moduleRef, b.moduleRef))) {
58
+ for (const slot of module.configSlots) {
59
+ if (slot.type !== "path")
60
+ continue;
61
+ const value = config?.[module.moduleRef]?.[slot.key] ??
62
+ (slot.default === undefined ? undefined : String(slot.default));
63
+ if (!value || !requiresExistingInputFile(slot.key, slot.sensitive))
64
+ continue;
65
+ try {
66
+ await access(value, constants.R_OK);
67
+ const info = await stat(value);
68
+ if (!info.isFile())
69
+ throw new Error("not a file");
70
+ const raw = await readFile(value, "utf8");
71
+ if (slot.sensitive === true && raw.trim().length < 32) {
72
+ throw new Error("credential/token file is shorter than 32 characters");
73
+ }
74
+ if (requiresJsonFile(slot.key))
75
+ JSON.parse(raw);
76
+ }
77
+ catch (error) {
78
+ findings.push({
79
+ code: "CONFIG_INVALID",
80
+ severity: "error",
81
+ moduleRef: module.moduleRef,
82
+ message: `config path "${slot.key}" for ${module.moduleRef} is not runtime-ready: ${error instanceof Error ? error.message : String(error)}`,
83
+ });
84
+ }
85
+ }
86
+ }
87
+ return findings;
88
+ }
@@ -1,8 +1,10 @@
1
1
  import type { ResolvedModule } from "../contracts.ts";
2
2
  import type { ModuleCatalog } from "../modules.ts";
3
+ import type { WorkspacePaths } from "../paths.ts";
3
4
  import type { PreflightResult } from "./findings.ts";
4
5
  export interface PreflightOptions {
5
6
  config?: Record<string, Record<string, string>>;
6
7
  catalog?: ModuleCatalog;
8
+ paths?: WorkspacePaths;
7
9
  }
8
10
  export declare function runPreflight(modules: readonly ResolvedModule[], options?: PreflightOptions): Promise<PreflightResult>;