@tomflow/proflow-platform-cli 0.1.2 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @tomflow/proflow-platform-cli
2
2
 
3
+ ## 0.1.3
4
+
5
+ ### Patch Changes
6
+
7
+ - Real-1 final remediation and black-box release closure
8
+
3
9
  ## 0.1.2
4
10
 
5
11
  ### Patch Changes
@@ -5,7 +5,7 @@ export declare const behaviorAdapter: {
5
5
  ok: boolean;
6
6
  status: "SUCCEEDED";
7
7
  moduleRef: "platform-cli";
8
- moduleVersion: "0.1.2";
8
+ moduleVersion: "0.1.3";
9
9
  data?: {} | null;
10
10
  };
11
11
  observedEffects: never[];
@@ -16,7 +16,7 @@ export declare const behaviorAdapter: {
16
16
  ok: boolean;
17
17
  status: "SUCCEEDED";
18
18
  moduleRef: "platform-cli";
19
- moduleVersion: "0.1.2";
19
+ moduleVersion: "0.1.3";
20
20
  data?: {} | null;
21
21
  };
22
22
  observedEffects: never[];
@@ -27,7 +27,7 @@ export declare const behaviorAdapter: {
27
27
  ok: boolean;
28
28
  status: "SUCCEEDED";
29
29
  moduleRef: "platform-cli";
30
- moduleVersion: "0.1.2";
30
+ moduleVersion: "0.1.3";
31
31
  data?: {} | null;
32
32
  };
33
33
  observedEffects: never[];
@@ -38,7 +38,7 @@ export declare const behaviorAdapter: {
38
38
  ok: boolean;
39
39
  status: "SUCCEEDED";
40
40
  moduleRef: "platform-cli";
41
- moduleVersion: "0.1.2";
41
+ moduleVersion: "0.1.3";
42
42
  data?: {} | null;
43
43
  };
44
44
  observedEffects: never[];
@@ -49,7 +49,7 @@ export declare const behaviorAdapter: {
49
49
  ok: boolean;
50
50
  status: "SUCCEEDED";
51
51
  moduleRef: "platform-cli";
52
- moduleVersion: "0.1.2";
52
+ moduleVersion: "0.1.3";
53
53
  data?: {} | null;
54
54
  };
55
55
  observedEffects: never[];
@@ -3,7 +3,7 @@ export declare const descriptor: {
3
3
  readonly contractVersion: "1.0.0";
4
4
  readonly moduleRef: "platform-cli";
5
5
  readonly packageName: "@tomflow/proflow-platform-cli";
6
- readonly moduleVersion: "0.1.2";
6
+ readonly moduleVersion: "0.1.3";
7
7
  readonly kind: "cli";
8
8
  readonly templateVersion: "1.0.0";
9
9
  readonly platformCompatibility: ">=1.0.0 <2.0.0";
@@ -3,7 +3,7 @@ export const descriptor = {
3
3
  contractVersion: "1.0.0",
4
4
  moduleRef: "platform-cli",
5
5
  packageName: "@tomflow/proflow-platform-cli",
6
- moduleVersion: "0.1.2",
6
+ moduleVersion: "0.1.3",
7
7
  kind: "cli",
8
8
  templateVersion: "1.0.0",
9
9
  platformCompatibility: ">=1.0.0 <2.0.0",
@@ -12,6 +12,8 @@ export interface ApplyContext {
12
12
  current: PlanInput;
13
13
  driver: PackageManagerDriver;
14
14
  observer?: RealityObserver;
15
+ /** Rebuilds installed descriptors/bindings after a package mutation. */
16
+ refreshCatalog?: () => Promise<ModuleCatalog>;
15
17
  }
16
18
  /**
17
19
  * Applies a frozen deployment plan deterministically. Every step is re-checked
@@ -1,7 +1,7 @@
1
1
  import { PlatformError } from "../errors.js";
2
2
  import { ensureLayout } from "../paths.js";
3
3
  import { emptyDeploymentState, loadDeploymentState, loadPlan, saveDeploymentState, } from "../persistence/index.js";
4
- import { checkPlanStale, evaluateStepCheck, } from "../planner/index.js";
4
+ import { checkPlanStale, ExecuteStrategy, evaluateStepCheck, } from "../planner/index.js";
5
5
  import { acquireWorkspaceLock } from "../security/index.js";
6
6
  import { executeStep } from "./execute.js";
7
7
  import { createRealityObserver } from "./reality.js";
@@ -21,10 +21,11 @@ function failureMessage(error) {
21
21
  */
22
22
  export async function applyPlan(context) {
23
23
  const driver = context.driver;
24
- const observer = context.observer ??
24
+ let catalog = context.catalog;
25
+ let observer = context.observer ??
25
26
  createRealityObserver({
26
27
  paths: context.paths,
27
- catalog: context.catalog,
28
+ catalog,
28
29
  driver,
29
30
  });
30
31
  await ensureLayout(context.paths);
@@ -105,7 +106,7 @@ export async function applyPlan(context) {
105
106
  }
106
107
  let outcome;
107
108
  try {
108
- outcome = await executeStep({ paths: context.paths, catalog: context.catalog, driver }, step, plan);
109
+ outcome = await executeStep({ paths: context.paths, catalog, driver }, step, plan);
109
110
  }
110
111
  catch (error) {
111
112
  stepResults.push({
@@ -125,6 +126,20 @@ export async function applyPlan(context) {
125
126
  }
126
127
  switch (outcome.kind) {
127
128
  case "SUCCEEDED": {
129
+ if (step.executeStrategy === ExecuteStrategy.lifecycleUninstall) {
130
+ // A lifecycle uninstall is owned by the module adapter. Its typed
131
+ // SUCCEEDED result is the authoritative teardown confirmation. An
132
+ // idempotent uninstall can legitimately leave status UNBOUND/UNKNOWN,
133
+ // so a second status probe must not reinterpret that successful
134
+ // teardown as a failed lifecycle:stopped postcondition.
135
+ stepResults.push({
136
+ stepRef: step.stepRef,
137
+ moduleRef: step.moduleRef,
138
+ status: "EXECUTED",
139
+ message: `${step.moduleRef} owner confirmed lifecycle uninstall`,
140
+ });
141
+ break;
142
+ }
128
143
  // Postcondition re-check: only a genuinely successful mutation is
129
144
  // confirmed; an effect that cannot be confirmed stops the apply
130
145
  // rather than blindly repeating it.
@@ -168,6 +183,34 @@ export async function applyPlan(context) {
168
183
  status: "EXECUTED",
169
184
  message: post.reason,
170
185
  });
186
+ if (step.kind === "package" && context.refreshCatalog !== undefined) {
187
+ try {
188
+ catalog = await context.refreshCatalog();
189
+ if (context.observer === undefined) {
190
+ observer = createRealityObserver({
191
+ paths: context.paths,
192
+ catalog,
193
+ driver,
194
+ });
195
+ }
196
+ }
197
+ catch (error) {
198
+ stepResults.push({
199
+ stepRef: step.stepRef,
200
+ moduleRef: step.moduleRef,
201
+ status: "FAILED",
202
+ message: `post-package catalog refresh failed: ${failureMessage(error)}`,
203
+ });
204
+ state.updatedAt = nowIso();
205
+ await saveDeploymentState(context.paths, state);
206
+ return {
207
+ planRef: plan.planRef,
208
+ outcome: "FAILED",
209
+ stepResults,
210
+ completedAt: nowIso(),
211
+ };
212
+ }
213
+ }
171
214
  break;
172
215
  }
173
216
  case "ACTION_REQUIRED": {
@@ -65,6 +65,20 @@ export async function rebuildCurrentAssumptions(catalog, plan) {
65
65
  facts: diagnosis.facts,
66
66
  };
67
67
  }
68
+ if (plan.intent === "uninstall") {
69
+ // A persisted uninstall plan does not store uninstallScope. Recover the only
70
+ // legal scope from its module set for the staleness re-plan: any core module
71
+ // can only have entered the plan through whole-instance uninstall.
72
+ const uninstallScope = plan.resolvedModules.some((module) => module.installClass === "core")
73
+ ? "platform-instance"
74
+ : "module";
75
+ return {
76
+ intent: plan.intent,
77
+ modules,
78
+ targets: plan.moduleTargets,
79
+ uninstallScope,
80
+ };
81
+ }
68
82
  return {
69
83
  intent: plan.intent,
70
84
  modules,
@@ -6,6 +6,29 @@ import { cleanupRemovableFilesystemEffects } from "./cleanup.js";
6
6
  function moduleOf(plan, step) {
7
7
  return plan.resolvedModules.find((module) => module.moduleRef === step.moduleRef);
8
8
  }
9
+ function moduleSourceForMaterialization(module) {
10
+ if (module.source.type === "registry") {
11
+ throw new PlatformError("APPLY_FAILED", `registry bootstrap target ${module.packageName} has no local config materializer`);
12
+ }
13
+ return {
14
+ type: module.source.type,
15
+ packageName: module.packageName,
16
+ ...(module.source.path === undefined ? {} : { path: module.source.path }),
17
+ };
18
+ }
19
+ async function materializeModuleOwnedConfig(deps, module, config) {
20
+ const namespace = await deps.catalog.loadAdapter(moduleSourceForMaterialization(module));
21
+ if (typeof namespace !== "object" || namespace === null)
22
+ return;
23
+ const materializer = Reflect.get(namespace, "materializeProductionConfig");
24
+ if (typeof materializer !== "function")
25
+ return;
26
+ await materializer({
27
+ moduleRef: module.moduleRef,
28
+ config: config.values,
29
+ workspaceRoot: deps.paths.root,
30
+ });
31
+ }
9
32
  function configForStep(plan, step, module) {
10
33
  const target = plan.moduleTargets.find((entry) => entry.moduleRef === step.moduleRef);
11
34
  if (target?.config === undefined)
@@ -98,6 +121,11 @@ export async function executeStep(deps, step, plan) {
98
121
  if (config === undefined) {
99
122
  throw new PlatformError("APPLY_FAILED", `module ${step.moduleRef} has no config target to materialize`);
100
123
  }
124
+ // The Platform-owned public config is authoritative apply reality. Do not
125
+ // commit it until the module-owned materializer has accepted the target;
126
+ // otherwise a failed materializer can leave a false-satisfied config step
127
+ // that a same-plan resume would incorrectly SKIP.
128
+ await materializeModuleOwnedConfig(deps, module, config);
101
129
  await materializeConfig(deps.paths, config);
102
130
  return { kind: "SUCCEEDED" };
103
131
  }
@@ -2,6 +2,7 @@ import type { ResolvedModule } from "../contracts.ts";
2
2
  type ResolvedSource = ResolvedModule["source"];
3
3
  export interface DeploymentAdapterBinding {
4
4
  behaviorAdapter: Record<string, unknown>;
5
+ materializeProductionConfig?: (...args: unknown[]) => unknown;
5
6
  }
6
7
  export type ProductionBindingFactory = (input: {
7
8
  moduleRef: string;
@@ -30,16 +31,5 @@ export interface ProductionBindingOptions {
30
31
  importAdapter: (packageName: string, source: ResolvedSource) => Promise<Record<string, unknown>>;
31
32
  }
32
33
  export declare function importRawAdapter(packageName: string, source: ResolvedSource, workspaceRoot: string): Promise<Record<string, unknown>>;
33
- /**
34
- * The shipped Platform CLI production binding factory. For every discovered
35
- * module it imports the module's own `deployment/adapter.ts` and, when that
36
- * adapter exposes a `createProductionBinding` factory, invokes it with the
37
- * module's materialized config to obtain a real bound adapter. A module without
38
- * a production factory — or whose import/factory fails, or which has no
39
- * materialized config the adapter accepts — is left out of the map, so the
40
- * catalog falls back to the module's unbound default, which must fail-closed
41
- * (ACTION_REQUIRED / NOT_READY). Platform CLI never invents a service or
42
- * resource reality: it only relays the adapter's own current reality.
43
- */
44
34
  export declare function buildProductionBindings(options: ProductionBindingOptions): Promise<ReadonlyMap<string, DeploymentAdapterBinding>>;
45
35
  export {};
@@ -45,6 +45,14 @@ export async function importRawAdapter(packageName, source, workspaceRoot) {
45
45
  * (ACTION_REQUIRED / NOT_READY). Platform CLI never invents a service or
46
46
  * resource reality: it only relays the adapter's own current reality.
47
47
  */
48
+ function materializerBinding(namespace) {
49
+ const materializer = namespace.materializeProductionConfig;
50
+ return typeof materializer === "function"
51
+ ? {
52
+ materializeProductionConfig: materializer,
53
+ }
54
+ : {};
55
+ }
48
56
  export async function buildProductionBindings(options) {
49
57
  const bindings = new Map();
50
58
  for (const module of options.modules) {
@@ -69,6 +77,7 @@ export async function buildProductionBindings(options) {
69
77
  : {});
70
78
  bindings.set(module.packageName, {
71
79
  behaviorAdapter: managedServiceAdapter(options.workspaceRoot, module, processBinding.serviceProcess, probeAdapter),
80
+ ...materializerBinding(namespace),
72
81
  });
73
82
  }
74
83
  // A formal service package that exposes the process seam must never fall
@@ -92,7 +101,10 @@ export async function buildProductionBindings(options) {
92
101
  binding !== null &&
93
102
  typeof binding.behaviorAdapter === "object" &&
94
103
  binding.behaviorAdapter !== null) {
95
- bindings.set(module.packageName, binding);
104
+ bindings.set(module.packageName, {
105
+ ...binding,
106
+ ...materializerBinding(namespace),
107
+ });
96
108
  }
97
109
  }
98
110
  catch {
package/dist/src/cli.d.ts CHANGED
@@ -20,4 +20,5 @@ export interface CliRuntimeOptions {
20
20
  cwd?: string;
21
21
  globalRoot?: string;
22
22
  }
23
+ export declare function renderHumanResult(machineOutput: string): string;
23
24
  export declare function runCli(argv: readonly string[], runtime?: CliRuntimeOptions): Promise<string>;
package/dist/src/cli.js CHANGED
@@ -86,7 +86,7 @@ function parseArgs(argv) {
86
86
  }
87
87
  function requiredOptionValue(argv, index, option) {
88
88
  const value = argv[index + 1];
89
- if (value === undefined || value.startsWith("-")) {
89
+ if (value === undefined || value === "" || value.startsWith("-")) {
90
90
  throw new PlatformError("INVALID_REQUEST", `${option} requires a value`);
91
91
  }
92
92
  return value;
@@ -309,14 +309,41 @@ async function handleInstallerPreflight(root) {
309
309
  : "BLOCKED";
310
310
  return outcome("preflight", status, result);
311
311
  }
312
+ async function loadEffectiveConfig(ctx, modules, configFile) {
313
+ const effective = {};
314
+ for (const module of modules) {
315
+ const stored = await loadConfig(ctx.paths, module.moduleRef);
316
+ if (stored === undefined)
317
+ continue;
318
+ effective[module.moduleRef] = {
319
+ ...stored.publicValues,
320
+ ...stored.secretValues,
321
+ };
322
+ }
323
+ const provided = await loadConfigFile(configFile);
324
+ for (const [moduleRef, values] of Object.entries(provided)) {
325
+ effective[moduleRef] = {
326
+ ...(effective[moduleRef] ?? {}),
327
+ ...values,
328
+ };
329
+ }
330
+ return effective;
331
+ }
312
332
  async function handlePreflight(ctx, args) {
313
333
  const modules = await discoverModules({ catalog: ctx.catalog });
314
334
  const selected = selectModules(modules, args.positional[0]);
315
- const config = await loadConfigFile(args.configFile);
316
- const result = await runPreflight(selected, { config, catalog: ctx.catalog });
317
- return result.ok
318
- ? outcome("preflight", "SUCCEEDED", result)
319
- : outcome("preflight", "BLOCKED", result);
335
+ const config = await loadEffectiveConfig(ctx, selected, args.configFile);
336
+ const result = await runPreflight(selected, {
337
+ config,
338
+ catalog: ctx.catalog,
339
+ paths: ctx.paths,
340
+ });
341
+ const status = result.status === "READY"
342
+ ? "SUCCEEDED"
343
+ : result.status === "ACTION_REQUIRED"
344
+ ? "ACTION_REQUIRED"
345
+ : "BLOCKED";
346
+ return outcome("preflight", status, result);
320
347
  }
321
348
  async function handlePlan(ctx, args) {
322
349
  const intent = args.intent;
@@ -423,6 +450,7 @@ async function handlePlan(ctx, args) {
423
450
  currentDescriptors,
424
451
  targetDescriptors,
425
452
  targets,
453
+ config,
426
454
  });
427
455
  await savePlan(ctx.paths, plan);
428
456
  return outcome("plan", "SUCCEEDED", {
@@ -431,9 +459,15 @@ async function handlePlan(ctx, args) {
431
459
  plan,
432
460
  });
433
461
  }
462
+ if (intent === "configure") {
463
+ const plan = planDeployment({ intent, modules: selected, config });
464
+ await savePlan(ctx.paths, plan);
465
+ return outcome("plan", "SUCCEEDED", { planRef: plan.planRef, plan });
466
+ }
434
467
  const preflight = await runPreflight(selected, {
435
468
  config,
436
469
  catalog: ctx.catalog,
470
+ paths: ctx.paths,
437
471
  });
438
472
  if (preflight.status === "NOT_READY" ||
439
473
  preflight.status === "ACTION_REQUIRED") {
@@ -472,6 +506,11 @@ async function handleApply(ctx, args) {
472
506
  driver: createWorkspacePackageManagerDriver({
473
507
  workspaceRoot: ctx.paths.root,
474
508
  }),
509
+ ...(plan.intent === "upgrade"
510
+ ? {
511
+ refreshCatalog: async () => (await buildContext(ctx.paths.root)).catalog,
512
+ }
513
+ : {}),
475
514
  });
476
515
  const status = result.outcome === "COMPLETE"
477
516
  ? "SUCCEEDED"
@@ -499,6 +538,23 @@ async function handleManagedMutation(ctx, args, intent) {
499
538
  if (planned.status !== "SUCCEEDED") {
500
539
  return { ...planned, command: intent };
501
540
  }
541
+ if (intent === "upgrade") {
542
+ const data = planned.data;
543
+ if (typeof data === "object" && data !== null && !Array.isArray(data)) {
544
+ const plan = Reflect.get(data, "plan");
545
+ if (typeof plan === "object" &&
546
+ plan !== null &&
547
+ !Array.isArray(plan) &&
548
+ Array.isArray(Reflect.get(plan, "steps")) &&
549
+ Reflect.get(plan, "steps").length === 0) {
550
+ return outcome("upgrade", "SUCCEEDED", {
551
+ ...data,
552
+ alreadyLatest: true,
553
+ changed: false,
554
+ });
555
+ }
556
+ }
557
+ }
502
558
  const planRef = planRefFromOutcome(planned);
503
559
  const applied = await handleApply(ctx, {
504
560
  ...args,
@@ -639,23 +695,37 @@ async function handleInstallWithGlobalBinding(args, runtime) {
639
695
  const operationLock = await acquireGlobalOperationLock(runtime.globalRoot);
640
696
  try {
641
697
  const requestedWorkspace = await resolveRequestedInstallWorkspace(args, runtime);
642
- const claimed = await claimWorkspaceBinding({
643
- workspace: requestedWorkspace,
644
- globalRoot: runtime.globalRoot,
645
- });
646
- const initialBinding = claimed.binding;
647
- if (claimed.alreadyBound &&
648
- initialBinding.state === "INSTALLED" &&
649
- args.positional[0] === undefined) {
698
+ const existing = await loadGlobalBinding(runtime.globalRoot);
699
+ if (existing !== undefined &&
700
+ existing.workspaceRealPath !== requestedWorkspace) {
701
+ throw new PlatformError("WORKSPACE_ALREADY_BOUND", `ProFlow is already bound to ${existing.workspaceRealPath}; uninstall it before installing ${requestedWorkspace}`);
702
+ }
703
+ if (existing?.state === "INSTALLED" && args.positional[0] === undefined) {
650
704
  return withWorkspace(outcome("install", "SUCCEEDED", {
651
705
  alreadyInstalled: true,
652
706
  changed: false,
653
- boundWorkspace: initialBinding.workspaceRealPath,
654
- }), initialBinding);
707
+ boundWorkspace: existing.workspaceRealPath,
708
+ }), existing);
655
709
  }
656
- if (initialBinding.state === "UNINSTALLING") {
710
+ if (existing?.state === "UNINSTALLING") {
657
711
  throw new PlatformError("GLOBAL_OPERATION_LOCKED", "the bound Platform Instance is marked UNINSTALLING; finish recovery before installing");
658
712
  }
713
+ // Gate A runs before claiming a fresh global binding. Deterministic local
714
+ // failures (package-manager conflict, missing executable, registry down,
715
+ // unwritable Workspace) therefore do not leave a fake BROKEN installation.
716
+ // The global operation lock keeps this preflight + claim sequence atomic
717
+ // against other Platform mutations.
718
+ const installer = await preflightInstallerEnvironment({
719
+ workspaceRoot: requestedWorkspace,
720
+ });
721
+ if (installer.status !== "READY") {
722
+ return outcome("install", installer.status === "ACTION_REQUIRED" ? "ACTION_REQUIRED" : "BLOCKED", { requestedWorkspace, preflight: installer });
723
+ }
724
+ const claimed = await claimWorkspaceBinding({
725
+ workspace: requestedWorkspace,
726
+ globalRoot: runtime.globalRoot,
727
+ });
728
+ const initialBinding = claimed.binding;
659
729
  let activeBinding = initialBinding;
660
730
  const controlsPlatformState = !claimed.alreadyBound || initialBinding.state !== "INSTALLED";
661
731
  if (controlsPlatformState && initialBinding.state !== "INSTALLING") {
@@ -889,7 +959,7 @@ async function handleDoctor(ctx, args) {
889
959
  async function handleManifest(ctx, args) {
890
960
  const modules = await discoverModules({ catalog: ctx.catalog });
891
961
  const selected = selectModules(modules, args.positional[0]);
892
- const config = await loadConfigFile(args.configFile);
962
+ const config = await loadEffectiveConfig(ctx, selected, args.configFile);
893
963
  const manifest = await buildManifest({
894
964
  catalog: ctx.catalog,
895
965
  modules: selected,
@@ -1096,7 +1166,125 @@ function toMachineResult(outcomeResult) {
1096
1166
  : {}),
1097
1167
  });
1098
1168
  }
1169
+ function isRecord(value) {
1170
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1171
+ }
1172
+ function humanDetailLines(data) {
1173
+ if (!isRecord(data)) {
1174
+ return data === undefined ? [] : [String(data)];
1175
+ }
1176
+ const lines = [];
1177
+ for (const key of [
1178
+ "installed",
1179
+ "bindingState",
1180
+ "boundWorkspace",
1181
+ "requestedWorkspace",
1182
+ "alreadyInstalled",
1183
+ "alreadyLatest",
1184
+ "changed",
1185
+ "bindingCleared",
1186
+ "resourcesCleaned",
1187
+ "planRef",
1188
+ "outcome",
1189
+ ]) {
1190
+ const value = data[key];
1191
+ if (typeof value === "string" ||
1192
+ typeof value === "number" ||
1193
+ typeof value === "boolean") {
1194
+ lines.push(`${key}: ${String(value)}`);
1195
+ }
1196
+ }
1197
+ if (typeof data.nextAction === "string" && data.nextAction !== "") {
1198
+ lines.push(`Next: ${data.nextAction}`);
1199
+ }
1200
+ const findings = data.findings;
1201
+ let hasMissingConfig = false;
1202
+ if (Array.isArray(findings) && findings.length > 0) {
1203
+ lines.push("Findings:");
1204
+ for (const finding of findings) {
1205
+ if (!isRecord(finding))
1206
+ continue;
1207
+ const severity = typeof finding.severity === "string"
1208
+ ? finding.severity.toUpperCase()
1209
+ : "INFO";
1210
+ const code = typeof finding.code === "string" ? finding.code : "CHECK";
1211
+ if (code === "CONFIG_MISSING")
1212
+ hasMissingConfig = true;
1213
+ const moduleRef = typeof finding.moduleRef === "string" ? ` ${finding.moduleRef}` : "";
1214
+ const message = typeof finding.message === "string" ? finding.message : "";
1215
+ lines.push(`- [${severity}] ${code}${moduleRef}${message === "" ? "" : `: ${message}`}`);
1216
+ }
1217
+ }
1218
+ if (hasMissingConfig) {
1219
+ lines.push("Next: review .proflow/deployment/generated/INSTALL.md, create the required config file, then run platform plan --intent configure --config <file> and platform apply <planRef>.");
1220
+ }
1221
+ const preflight = data.preflight;
1222
+ if (isRecord(preflight)) {
1223
+ const nested = humanDetailLines(preflight);
1224
+ if (nested.length > 0) {
1225
+ lines.push("Preflight:", ...nested.map((line) => ` ${line}`));
1226
+ }
1227
+ }
1228
+ return lines;
1229
+ }
1230
+ export function renderHumanResult(machineOutput) {
1231
+ const parsed = JSON.parse(machineOutput);
1232
+ if (!isRecord(parsed))
1233
+ return machineOutput;
1234
+ const data = parsed.data;
1235
+ if (isRecord(data) && typeof data.version === "string") {
1236
+ return `ProFlow Platform CLI ${data.version}`;
1237
+ }
1238
+ if (isRecord(data) && typeof data.usage === "string") {
1239
+ const commands = Array.isArray(data.commands)
1240
+ ? data.commands.filter((item) => typeof item === "string")
1241
+ : [];
1242
+ const examples = Array.isArray(data.examples)
1243
+ ? data.examples.filter((item) => typeof item === "string")
1244
+ : [];
1245
+ return [
1246
+ "ProFlow Platform CLI",
1247
+ "",
1248
+ `Usage: ${data.usage}`,
1249
+ ...(commands.length === 0
1250
+ ? []
1251
+ : ["", `Commands: ${commands.join(", ")}`]),
1252
+ ...(examples.length === 0
1253
+ ? []
1254
+ : ["", "Common flows:", ...examples.map((item) => ` ${item}`)]),
1255
+ ].join("\n");
1256
+ }
1257
+ const status = typeof parsed.status === "string" ? parsed.status : "UNKNOWN";
1258
+ const lines = [`ProFlow: ${status}`];
1259
+ const workspace = parsed.workspace;
1260
+ if (isRecord(workspace) && typeof workspace.boundWorkspace === "string") {
1261
+ lines.push(`Workspace: ${workspace.boundWorkspace}`);
1262
+ }
1263
+ const error = parsed.error;
1264
+ if (isRecord(error)) {
1265
+ const code = typeof error.code === "string" ? error.code : "COMMAND_FAILED";
1266
+ const message = typeof error.message === "string" ? error.message : "Unknown error";
1267
+ lines.push(`Error: ${code}: ${message}`);
1268
+ }
1269
+ lines.push(...humanDetailLines(data));
1270
+ if (lines.length === 1 && status === "SUCCEEDED") {
1271
+ lines.push(`Version: ${MODULE_VERSION}`, "Next: run platform --help");
1272
+ }
1273
+ return lines.join("\n");
1274
+ }
1099
1275
  export async function runCli(argv, runtime = {}) {
1276
+ const filtered = argv.filter((argument) => argument !== "--json");
1277
+ if (filtered.length === 1 &&
1278
+ (filtered[0] === "--version" || filtered[0] === "-v")) {
1279
+ return JSON.stringify({
1280
+ contract: "deployment.result.v1",
1281
+ ok: true,
1282
+ status: "SUCCEEDED",
1283
+ moduleRef: MODULE_REF,
1284
+ moduleVersion: MODULE_VERSION,
1285
+ data: { version: MODULE_VERSION },
1286
+ });
1287
+ }
1100
1288
  if (argv.includes("--help") || argv.includes("-h")) {
1101
1289
  return JSON.stringify({
1102
1290
  contract: "deployment.result.v1",
@@ -1107,10 +1295,19 @@ export async function runCli(argv, runtime = {}) {
1107
1295
  data: {
1108
1296
  usage: "platform <command> [module|package] [--workspace <path>] [--json]",
1109
1297
  commands: [...COMMANDS],
1298
+ examples: [
1299
+ "platform preflight --intent install --workspace <path>",
1300
+ "platform install [module|package] [--workspace <path>]",
1301
+ "platform preflight # startup/runtime readiness for the bound Workspace",
1302
+ "platform plan --intent configure --config <file>",
1303
+ "platform apply <planRef>",
1304
+ "platform start | status | verify | doctor | stop | restart",
1305
+ "platform uninstall # uninstall the bound Platform Instance, not the global CLI",
1306
+ "append --json for the stable machine-readable contract",
1307
+ ],
1110
1308
  },
1111
1309
  });
1112
1310
  }
1113
- const filtered = argv.filter((argument) => argument !== "--json");
1114
1311
  if (filtered.length === 0) {
1115
1312
  return JSON.stringify({
1116
1313
  contract: "deployment.result.v1",
@@ -1123,8 +1320,10 @@ export async function runCli(argv, runtime = {}) {
1123
1320
  return toMachineResult(await dispatchCommand(filtered, runtime));
1124
1321
  }
1125
1322
  if (import.meta.main) {
1126
- const output = await runCli(process.argv.slice(2));
1127
- process.stdout.write(`${output}\n`);
1323
+ const argv = process.argv.slice(2);
1324
+ const output = await runCli(argv);
1325
+ const rendered = argv.includes("--json") ? output : renderHumanResult(output);
1326
+ process.stdout.write(`${rendered}\n`);
1128
1327
  const parsed = JSON.parse(output);
1129
1328
  if (typeof parsed === "object" &&
1130
1329
  parsed !== null &&
@@ -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", "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"];
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,6 +6,7 @@ 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",
@@ -102,7 +102,7 @@ export async function preflightInstallerEnvironment(options) {
102
102
  if (npmVersion !== undefined) {
103
103
  try {
104
104
  registry = await resolveScopeRegistry(options.workspaceRoot, runner);
105
- 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);
106
106
  findings.push({
107
107
  code: "REGISTRY_READY",
108
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) {
@@ -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);
@@ -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
  }
@@ -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>;
@@ -1,11 +1,25 @@
1
1
  import { PlatformError } from "../errors.js";
2
2
  import { buildDependencyGraph, ModuleRefUnresolvedError, } from "../graph/graph.js";
3
3
  import { dispatchLifecycle } from "../lifecycle/index.js";
4
- import { checkConfigReadiness } from "./config.js";
4
+ import { loadLatestVerification } from "../persistence/index.js";
5
+ import { checkConfigReadiness, checkConfigReality } from "./config.js";
5
6
  import { probeAllRequirements } from "./requirements.js";
6
7
  function compareRef(a, b) {
7
8
  return a < b ? -1 : a > b ? 1 : 0;
8
9
  }
10
+ async function humanVerifiedModules(modules, paths) {
11
+ const verified = new Set();
12
+ if (paths === undefined)
13
+ return verified;
14
+ for (const module of modules) {
15
+ const latest = await loadLatestVerification(paths, module.moduleRef);
16
+ if (latest?.result === "PASS" &&
17
+ latest.moduleVersion === module.moduleVersion) {
18
+ verified.add(module.moduleRef);
19
+ }
20
+ }
21
+ return verified;
22
+ }
9
23
  export async function runPreflight(modules, options = {}) {
10
24
  const findings = [];
11
25
  let dependency;
@@ -33,7 +47,9 @@ export async function runPreflight(modules, options = {}) {
33
47
  }
34
48
  }
35
49
  findings.push(...checkConfigReadiness(modules, options.config));
36
- const requirementProbes = await probeAllRequirements(modules);
50
+ findings.push(...(await checkConfigReality(modules, options.config)));
51
+ const verifiedHumanModules = await humanVerifiedModules(modules, options.paths);
52
+ const requirementProbes = await probeAllRequirements(modules, verifiedHumanModules);
37
53
  for (const probe of requirementProbes) {
38
54
  if (probe.status === "ACTION_REQUIRED") {
39
55
  findings.push({
@@ -7,5 +7,5 @@ export interface RequirementProbe {
7
7
  status: ProbeStatus;
8
8
  message: string;
9
9
  }
10
- export declare function probeAllRequirements(modules: readonly ResolvedModule[]): Promise<RequirementProbe[]>;
11
- export declare function probeRequirement(moduleRef: string, requirement: ModuleRequirement, modules: readonly ResolvedModule[]): Promise<RequirementProbe>;
10
+ export declare function probeAllRequirements(modules: readonly ResolvedModule[], humanVerifiedModuleRefs?: ReadonlySet<string>): Promise<RequirementProbe[]>;
11
+ export declare function probeRequirement(moduleRef: string, requirement: ModuleRequirement, modules: readonly ResolvedModule[], humanVerifiedModuleRefs?: ReadonlySet<string>): Promise<RequirementProbe>;
@@ -5,17 +5,17 @@ import { versionSatisfies } from "../modules.js";
5
5
  function compareRef(a, b) {
6
6
  return a < b ? -1 : a > b ? 1 : 0;
7
7
  }
8
- export async function probeAllRequirements(modules) {
8
+ export async function probeAllRequirements(modules, humanVerifiedModuleRefs = new Set()) {
9
9
  const sorted = [...modules].sort((a, b) => compareRef(a.moduleRef, b.moduleRef));
10
10
  const results = [];
11
11
  for (const module of sorted) {
12
12
  for (const requirement of module.requirements) {
13
- results.push(await probeRequirement(module.moduleRef, requirement, modules));
13
+ results.push(await probeRequirement(module.moduleRef, requirement, modules, humanVerifiedModuleRefs));
14
14
  }
15
15
  }
16
16
  return results;
17
17
  }
18
- export async function probeRequirement(moduleRef, requirement, modules) {
18
+ export async function probeRequirement(moduleRef, requirement, modules, humanVerifiedModuleRefs = new Set()) {
19
19
  switch (requirement.kind) {
20
20
  case "runtime":
21
21
  return probeRuntime(moduleRef, requirement);
@@ -30,12 +30,19 @@ export async function probeRequirement(moduleRef, requirement, modules) {
30
30
  case "module-contract":
31
31
  return probeModuleContract(moduleRef, requirement, modules);
32
32
  case "human":
33
- return {
34
- moduleRef,
35
- requirement,
36
- status: "ACTION_REQUIRED",
37
- message: requirement.action,
38
- };
33
+ return humanVerifiedModuleRefs.has(moduleRef)
34
+ ? {
35
+ moduleRef,
36
+ requirement,
37
+ status: "PASS",
38
+ message: `human prerequisite verified for ${moduleRef}`,
39
+ }
40
+ : {
41
+ moduleRef,
42
+ requirement,
43
+ status: "ACTION_REQUIRED",
44
+ message: requirement.action,
45
+ };
39
46
  }
40
47
  }
41
48
  function probeRuntime(moduleRef, requirement) {
@@ -6,7 +6,7 @@ export interface NpmCommandResult {
6
6
  stderr: string;
7
7
  }
8
8
  export interface NpmCommandRunner {
9
- run(args: readonly string[], cwd: string): Promise<NpmCommandResult>;
9
+ run(args: readonly string[], cwd: string, timeoutMs?: number): Promise<NpmCommandResult>;
10
10
  }
11
11
  export interface RegistryModuleCandidate {
12
12
  packageName: string;
@@ -12,12 +12,13 @@ export const PRO_FLOW_SCOPE = "@tomflow";
12
12
  export const PRO_FLOW_PACKAGE_PREFIX = "@tomflow/proflow-";
13
13
  export function systemNpmRunner() {
14
14
  return {
15
- async run(args, cwd) {
15
+ async run(args, cwd, timeoutMs) {
16
16
  try {
17
17
  const result = await execFileAsync("npm", [...args], {
18
18
  cwd,
19
19
  encoding: "utf8",
20
20
  maxBuffer: 4 * 1024 * 1024,
21
+ ...(timeoutMs === undefined ? {} : { timeout: timeoutMs }),
21
22
  });
22
23
  return { stdout: result.stdout, stderr: result.stderr };
23
24
  }
@@ -45,6 +45,32 @@ function lockedError(existing) {
45
45
  : `pid=${existing.pid} planRef=${existing.planRef} since=${existing.createdAt} fingerprint=${existing.workspaceFingerprint}`;
46
46
  return new PlatformError("WORKSPACE_LOCKED", `workspace apply already in progress (${details})`);
47
47
  }
48
+ function isProvablyDeadPid(pid) {
49
+ try {
50
+ process.kill(pid, 0);
51
+ return false;
52
+ }
53
+ catch (error) {
54
+ return errorHasCode(error, "ESRCH");
55
+ }
56
+ }
57
+ async function reclaimProvablyStaleLock(paths, expectedFingerprint) {
58
+ const existing = await readWorkspaceLock(paths);
59
+ if (existing === undefined ||
60
+ existing.workspaceFingerprint !== expectedFingerprint ||
61
+ !isProvablyDeadPid(existing.pid)) {
62
+ return false;
63
+ }
64
+ try {
65
+ await unlink(workspaceLockPath(paths));
66
+ return true;
67
+ }
68
+ catch (error) {
69
+ if (errorHasCode(error, "ENOENT"))
70
+ return true;
71
+ throw error;
72
+ }
73
+ }
48
74
  // v1 single-process exclusive lock: O_EXCL create, no distributed coordination,
49
75
  // and no reclaim of a lock that cannot be proven stale.
50
76
  export async function acquireWorkspaceLock(paths, planRef) {
@@ -61,10 +87,22 @@ export async function acquireWorkspaceLock(paths, planRef) {
61
87
  handle = await open(file, "wx", 0o644);
62
88
  }
63
89
  catch (error) {
64
- if (errorHasCode(error, "EEXIST")) {
90
+ if (!errorHasCode(error, "EEXIST"))
91
+ throw error;
92
+ if (await reclaimProvablyStaleLock(paths, record.workspaceFingerprint)) {
93
+ try {
94
+ handle = await open(file, "wx", 0o644);
95
+ }
96
+ catch (retryError) {
97
+ if (errorHasCode(retryError, "EEXIST")) {
98
+ throw lockedError(await readWorkspaceLock(paths));
99
+ }
100
+ throw retryError;
101
+ }
102
+ }
103
+ else {
65
104
  throw lockedError(await readWorkspaceLock(paths));
66
105
  }
67
- throw error;
68
106
  }
69
107
  try {
70
108
  await handle.writeFile(`${JSON.stringify(record, null, 2)}\n`, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tomflow/proflow-platform-cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -24,8 +24,8 @@
24
24
  "@tomflow/proflow-module-contract": "^0.1.1"
25
25
  },
26
26
  "devDependencies": {
27
- "@tomflow/proflow-module-template": "^0.1.2",
28
- "@tomflow/proflow-deployment-conformance": "^0.1.2"
27
+ "@tomflow/proflow-deployment-conformance": "^0.1.2",
28
+ "@tomflow/proflow-module-template": "^0.1.2"
29
29
  },
30
30
  "description": "Deterministic platform-level deployment discovery, planning, lifecycle and verification CLI.",
31
31
  "keywords": [
@@ -3,7 +3,7 @@
3
3
  "contractVersion": "1.0.0",
4
4
  "moduleRef": "platform-cli",
5
5
  "packageName": "@tomflow/proflow-platform-cli",
6
- "moduleVersion": "0.1.2",
6
+ "moduleVersion": "0.1.3",
7
7
  "kind": "cli",
8
8
  "templateVersion": "1.0.0",
9
9
  "platformCompatibility": ">=1.0.0 <2.0.0",