@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
package/dist/src/cli.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { readFile } from "node:fs/promises";
2
+ import { readFile, rm } from "node:fs/promises";
3
3
  import { parseModuleDescriptor, } from "@tomflow/proflow-module-contract";
4
4
  import { descriptor as platformCliDescriptor } from "../deployment/descriptor.js";
5
5
  import { applyPlan } from "./apply/apply.js";
6
6
  import { rebuildCurrentAssumptions } from "./apply/current.js";
7
7
  import { createWorkspacePackageManagerDriver } from "./apply/driver.js";
8
+ import { acquireGlobalOperationLock, canonicalizeWorkspace, claimWorkspaceBinding, clearGlobalBinding, forgetMissingWorkspaceBinding, loadGlobalBinding, observeBoundWorkspace, requireBoundWorkspace, updateGlobalBindingState, } from "./binding/global-binding.js";
8
9
  import { buildProductionBindings, importRawAdapter, } from "./binding/production-bindings.js";
9
10
  import { AutoModuleCatalog, discoverModules } from "./discovery/discover.js";
10
11
  import { describeModule, readModuleDocument } from "./docs/docs.js";
@@ -52,6 +53,7 @@ function parseArgs(argv) {
52
53
  workspace: undefined,
53
54
  intent: undefined,
54
55
  configFile: undefined,
56
+ forget: false,
55
57
  positional,
56
58
  };
57
59
  for (let index = 1; index < argv.length; index += 1) {
@@ -59,18 +61,22 @@ function parseArgs(argv) {
59
61
  if (token === undefined)
60
62
  continue;
61
63
  if (token === "--workspace") {
62
- parsed.workspace = argv[index + 1];
64
+ parsed.workspace = requiredOptionValue(argv, index, token);
63
65
  index += 1;
64
66
  }
65
67
  else if (token === "--intent") {
66
- parsed.intent = argv[index + 1];
68
+ parsed.intent = requiredOptionValue(argv, index, token);
67
69
  index += 1;
68
70
  }
69
71
  else if (token === "--config") {
70
- parsed.configFile = argv[index + 1];
72
+ parsed.configFile = requiredOptionValue(argv, index, token);
71
73
  index += 1;
72
74
  }
75
+ else if (token === "--forget") {
76
+ parsed.forget = true;
77
+ }
73
78
  else if (token.startsWith("-")) {
79
+ throw new PlatformError("INVALID_REQUEST", `unknown option: ${token}`);
74
80
  }
75
81
  else {
76
82
  positional.push(token);
@@ -78,12 +84,29 @@ function parseArgs(argv) {
78
84
  }
79
85
  return parsed;
80
86
  }
87
+ function requiredOptionValue(argv, index, option) {
88
+ const value = argv[index + 1];
89
+ if (value === undefined || value === "" || value.startsWith("-")) {
90
+ throw new PlatformError("INVALID_REQUEST", `${option} requires a value`);
91
+ }
92
+ return value;
93
+ }
81
94
  function outcome(command, status, data) {
82
95
  const result = { command, status };
83
96
  if (data !== undefined)
84
97
  result.data = data;
85
98
  return result;
86
99
  }
100
+ function workspaceSummary(binding) {
101
+ return {
102
+ boundWorkspace: binding.workspaceRealPath,
103
+ workspaceInstanceId: binding.workspaceInstanceId,
104
+ bindingState: binding.state,
105
+ };
106
+ }
107
+ function withWorkspace(result, binding) {
108
+ return { ...result, workspace: workspaceSummary(binding) };
109
+ }
87
110
  function aggregateStatus(statuses) {
88
111
  if (statuses.includes("FAILED"))
89
112
  return "FAILED";
@@ -166,8 +189,7 @@ async function loadConfigFile(path) {
166
189
  }
167
190
  return result;
168
191
  }
169
- async function buildContext(workspace) {
170
- const root = workspace ?? process.cwd();
192
+ async function buildContext(root) {
171
193
  const paths = workspacePaths(root);
172
194
  const catalog = await buildCatalogWithProductionBindings(root, paths);
173
195
  return { catalog, paths };
@@ -196,7 +218,7 @@ async function buildCatalogWithProductionBindings(root, paths) {
196
218
  workspaceRoot: root,
197
219
  modules,
198
220
  configByModuleRef,
199
- importAdapter: (packageName, source) => importRawAdapter(packageName, source),
221
+ importAdapter: (packageName, source) => importRawAdapter(packageName, source, root),
200
222
  });
201
223
  return new AutoModuleCatalog(root, bindings);
202
224
  }
@@ -287,14 +309,41 @@ async function handleInstallerPreflight(root) {
287
309
  : "BLOCKED";
288
310
  return outcome("preflight", status, result);
289
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
+ }
290
332
  async function handlePreflight(ctx, args) {
291
333
  const modules = await discoverModules({ catalog: ctx.catalog });
292
334
  const selected = selectModules(modules, args.positional[0]);
293
- const config = await loadConfigFile(args.configFile);
294
- const result = await runPreflight(selected, { config, catalog: ctx.catalog });
295
- return result.ok
296
- ? outcome("preflight", "SUCCEEDED", result)
297
- : 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);
298
347
  }
299
348
  async function handlePlan(ctx, args) {
300
349
  const intent = args.intent;
@@ -401,6 +450,7 @@ async function handlePlan(ctx, args) {
401
450
  currentDescriptors,
402
451
  targetDescriptors,
403
452
  targets,
453
+ config,
404
454
  });
405
455
  await savePlan(ctx.paths, plan);
406
456
  return outcome("plan", "SUCCEEDED", {
@@ -409,9 +459,15 @@ async function handlePlan(ctx, args) {
409
459
  plan,
410
460
  });
411
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
+ }
412
467
  const preflight = await runPreflight(selected, {
413
468
  config,
414
469
  catalog: ctx.catalog,
470
+ paths: ctx.paths,
415
471
  });
416
472
  if (preflight.status === "NOT_READY" ||
417
473
  preflight.status === "ACTION_REQUIRED") {
@@ -450,6 +506,11 @@ async function handleApply(ctx, args) {
450
506
  driver: createWorkspacePackageManagerDriver({
451
507
  workspaceRoot: ctx.paths.root,
452
508
  }),
509
+ ...(plan.intent === "upgrade"
510
+ ? {
511
+ refreshCatalog: async () => (await buildContext(ctx.paths.root)).catalog,
512
+ }
513
+ : {}),
453
514
  });
454
515
  const status = result.outcome === "COMPLETE"
455
516
  ? "SUCCEEDED"
@@ -477,6 +538,23 @@ async function handleManagedMutation(ctx, args, intent) {
477
538
  if (planned.status !== "SUCCEEDED") {
478
539
  return { ...planned, command: intent };
479
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
+ }
480
558
  const planRef = planRefFromOutcome(planned);
481
559
  const applied = await handleApply(ctx, {
482
560
  ...args,
@@ -493,7 +571,7 @@ async function handleManagedMutation(ctx, args, intent) {
493
571
  // Package mutations change Workspace package reality. Rebuild the catalog
494
572
  // before reporting the managed set so the result can never be sourced from
495
573
  // the pre-mutation catalog.
496
- const refreshed = await buildContext(args.workspace);
574
+ const refreshed = await buildContext(ctx.paths.root);
497
575
  const managed = await handleModules(refreshed, {
498
576
  ...args,
499
577
  command: "modules",
@@ -506,6 +584,315 @@ async function handleManagedMutation(ctx, args, intent) {
506
584
  managedModules: managed.data,
507
585
  });
508
586
  }
587
+ async function resolveRequestedInstallWorkspace(args, runtime) {
588
+ const requested = args.workspace ?? runtime.cwd ?? process.cwd();
589
+ return (await canonicalizeWorkspace(requested)).workspaceRealPath;
590
+ }
591
+ async function handleInstallPlanBeforeBinding(args, runtime) {
592
+ const requestedWorkspace = await resolveRequestedInstallWorkspace(args, runtime);
593
+ const current = await loadGlobalBinding(runtime.globalRoot);
594
+ if (current !== undefined &&
595
+ current.workspaceRealPath !== requestedWorkspace) {
596
+ throw new PlatformError("WORKSPACE_ALREADY_BOUND", `ProFlow is already bound to ${current.workspaceRealPath}; uninstall it before planning an install for ${requestedWorkspace}`);
597
+ }
598
+ const ctx = await buildContext(current?.workspaceRealPath ?? requestedWorkspace);
599
+ const result = await handlePlan(ctx, args);
600
+ return current === undefined ? result : withWorkspace(result, current);
601
+ }
602
+ async function handleApplyWithGlobalBinding(args, runtime) {
603
+ const planRef = args.positional[0];
604
+ if (planRef === undefined) {
605
+ throw new PlatformError("INVALID_REQUEST", "apply requires <planRef>");
606
+ }
607
+ const operationLock = await acquireGlobalOperationLock(runtime.globalRoot);
608
+ let binding = await loadGlobalBinding(runtime.globalRoot);
609
+ try {
610
+ let ctx;
611
+ if (binding === undefined) {
612
+ const requestedWorkspace = await resolveRequestedInstallWorkspace(args, runtime);
613
+ ctx = await buildContext(requestedWorkspace);
614
+ const plan = await loadPlan(ctx.paths, planRef);
615
+ if (plan === undefined) {
616
+ throw new PlatformError("PLAN_NOT_FOUND", `plan ${planRef} not found`);
617
+ }
618
+ if (plan.intent !== "install") {
619
+ throw new PlatformError("WORKSPACE_NOT_BOUND", "only an install plan may establish the first global Workspace binding");
620
+ }
621
+ binding = (await claimWorkspaceBinding({
622
+ workspace: requestedWorkspace,
623
+ globalRoot: runtime.globalRoot,
624
+ })).binding;
625
+ }
626
+ else {
627
+ if (args.workspace !== undefined) {
628
+ const requested = await canonicalizeWorkspace(args.workspace);
629
+ if (requested.workspaceRealPath !== binding.workspaceRealPath) {
630
+ throw new PlatformError("WORKSPACE_ALREADY_BOUND", `command targets ${requested.workspaceRealPath}, but the global Platform Instance is bound to ${binding.workspaceRealPath}`);
631
+ }
632
+ }
633
+ ctx = await buildContext(binding.workspaceRealPath);
634
+ }
635
+ const plan = await loadPlan(ctx.paths, planRef);
636
+ if (plan === undefined) {
637
+ throw new PlatformError("PLAN_NOT_FOUND", `plan ${planRef} not found`);
638
+ }
639
+ const controlsPlatformState = plan.intent === "install" && binding.state !== "INSTALLED";
640
+ if (binding.state === "UNINSTALLING") {
641
+ throw new PlatformError("GLOBAL_OPERATION_LOCKED", "the bound Platform Instance is marked UNINSTALLING; finish recovery before apply");
642
+ }
643
+ if (controlsPlatformState && binding.state !== "INSTALLING") {
644
+ binding = await updateGlobalBindingState({
645
+ workspaceInstanceId: binding.workspaceInstanceId,
646
+ state: "INSTALLING",
647
+ globalRoot: runtime.globalRoot,
648
+ });
649
+ }
650
+ try {
651
+ const result = await handleApply(ctx, args);
652
+ if (controlsPlatformState) {
653
+ if (result.status === "SUCCEEDED") {
654
+ binding = await updateGlobalBindingState({
655
+ workspaceInstanceId: binding.workspaceInstanceId,
656
+ state: "INSTALLED",
657
+ globalRoot: runtime.globalRoot,
658
+ });
659
+ }
660
+ else if (result.status === "FAILED" || result.status === "BLOCKED") {
661
+ binding = await updateGlobalBindingState({
662
+ workspaceInstanceId: binding.workspaceInstanceId,
663
+ state: "BROKEN",
664
+ globalRoot: runtime.globalRoot,
665
+ failure: {
666
+ code: result.error?.code ?? result.status,
667
+ message: result.error?.message ??
668
+ "install apply did not reach a successful postcondition",
669
+ },
670
+ });
671
+ }
672
+ }
673
+ return withWorkspace(result, binding);
674
+ }
675
+ catch (error) {
676
+ if (controlsPlatformState) {
677
+ binding = await updateGlobalBindingState({
678
+ workspaceInstanceId: binding.workspaceInstanceId,
679
+ state: "BROKEN",
680
+ globalRoot: runtime.globalRoot,
681
+ failure: {
682
+ code: error instanceof PlatformError ? error.code : "COMMAND_FAILED",
683
+ message: error instanceof Error ? error.message : String(error),
684
+ },
685
+ });
686
+ }
687
+ throw error;
688
+ }
689
+ }
690
+ finally {
691
+ await operationLock.release();
692
+ }
693
+ }
694
+ async function handleInstallWithGlobalBinding(args, runtime) {
695
+ const operationLock = await acquireGlobalOperationLock(runtime.globalRoot);
696
+ try {
697
+ const requestedWorkspace = await resolveRequestedInstallWorkspace(args, runtime);
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) {
704
+ return withWorkspace(outcome("install", "SUCCEEDED", {
705
+ alreadyInstalled: true,
706
+ changed: false,
707
+ boundWorkspace: existing.workspaceRealPath,
708
+ }), existing);
709
+ }
710
+ if (existing?.state === "UNINSTALLING") {
711
+ throw new PlatformError("GLOBAL_OPERATION_LOCKED", "the bound Platform Instance is marked UNINSTALLING; finish recovery before installing");
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;
729
+ let activeBinding = initialBinding;
730
+ const controlsPlatformState = !claimed.alreadyBound || initialBinding.state !== "INSTALLED";
731
+ if (controlsPlatformState && initialBinding.state !== "INSTALLING") {
732
+ activeBinding = await updateGlobalBindingState({
733
+ workspaceInstanceId: initialBinding.workspaceInstanceId,
734
+ state: "INSTALLING",
735
+ globalRoot: runtime.globalRoot,
736
+ });
737
+ }
738
+ try {
739
+ const ctx = await buildContext(activeBinding.workspaceRealPath);
740
+ const result = await handleManagedMutation(ctx, args, "install");
741
+ if (controlsPlatformState) {
742
+ if (result.status === "SUCCEEDED") {
743
+ activeBinding = await updateGlobalBindingState({
744
+ workspaceInstanceId: activeBinding.workspaceInstanceId,
745
+ state: "INSTALLED",
746
+ globalRoot: runtime.globalRoot,
747
+ });
748
+ }
749
+ else if (result.status === "FAILED" || result.status === "BLOCKED") {
750
+ activeBinding = await updateGlobalBindingState({
751
+ workspaceInstanceId: activeBinding.workspaceInstanceId,
752
+ state: "BROKEN",
753
+ globalRoot: runtime.globalRoot,
754
+ failure: {
755
+ code: result.error?.code ?? result.status,
756
+ message: result.error?.message ??
757
+ "Platform install did not reach a successful postcondition",
758
+ },
759
+ });
760
+ }
761
+ }
762
+ return withWorkspace(result, activeBinding);
763
+ }
764
+ catch (error) {
765
+ if (controlsPlatformState) {
766
+ activeBinding = await updateGlobalBindingState({
767
+ workspaceInstanceId: activeBinding.workspaceInstanceId,
768
+ state: "BROKEN",
769
+ globalRoot: runtime.globalRoot,
770
+ failure: {
771
+ code: error instanceof PlatformError ? error.code : "COMMAND_FAILED",
772
+ message: error instanceof Error ? error.message : String(error),
773
+ },
774
+ });
775
+ }
776
+ throw error;
777
+ }
778
+ }
779
+ finally {
780
+ await operationLock.release();
781
+ }
782
+ }
783
+ async function resolveBoundContext(runtime, requestedWorkspace) {
784
+ const binding = await requireBoundWorkspace(runtime.globalRoot);
785
+ if (requestedWorkspace !== undefined) {
786
+ const requested = await canonicalizeWorkspace(requestedWorkspace);
787
+ if (requested.workspaceRealPath !== binding.workspaceRealPath) {
788
+ throw new PlatformError("WORKSPACE_ALREADY_BOUND", `command targets ${requested.workspaceRealPath}, but the global Platform Instance is bound to ${binding.workspaceRealPath}`);
789
+ }
790
+ }
791
+ return {
792
+ ctx: await buildContext(binding.workspaceRealPath),
793
+ binding,
794
+ };
795
+ }
796
+ async function handleBoundMutatingCommand(args, runtime) {
797
+ const operationLock = await acquireGlobalOperationLock(runtime.globalRoot);
798
+ try {
799
+ const { ctx, binding } = await resolveBoundContext(runtime, args.workspace);
800
+ if (binding.state === "UNINSTALLING") {
801
+ throw new PlatformError("GLOBAL_OPERATION_LOCKED", "the bound Platform Instance is marked UNINSTALLING; finish uninstall recovery before another mutation");
802
+ }
803
+ let result;
804
+ if (args.command === "upgrade" || args.command === "uninstall") {
805
+ result = await handleManagedMutation(ctx, args, args.command);
806
+ }
807
+ else {
808
+ result = await handleLifecycle(ctx, args, args.command);
809
+ }
810
+ return withWorkspace(result, binding);
811
+ }
812
+ finally {
813
+ await operationLock.release();
814
+ }
815
+ }
816
+ async function handlePlatformInstanceUninstall(args, runtime) {
817
+ const operationLock = await acquireGlobalOperationLock(runtime.globalRoot);
818
+ let binding;
819
+ try {
820
+ binding = await requireBoundWorkspace(runtime.globalRoot);
821
+ if (args.workspace !== undefined) {
822
+ const requested = await canonicalizeWorkspace(args.workspace);
823
+ if (requested.workspaceRealPath !== binding.workspaceRealPath) {
824
+ throw new PlatformError("WORKSPACE_ALREADY_BOUND", `uninstall targets ${requested.workspaceRealPath}, but the global Platform Instance is bound to ${binding.workspaceRealPath}`);
825
+ }
826
+ }
827
+ binding = await updateGlobalBindingState({
828
+ workspaceInstanceId: binding.workspaceInstanceId,
829
+ state: "UNINSTALLING",
830
+ globalRoot: runtime.globalRoot,
831
+ });
832
+ const ctx = await buildContext(binding.workspaceRealPath);
833
+ const modules = await discoverModules({ catalog: ctx.catalog });
834
+ let removedModules = [];
835
+ if (modules.length > 0) {
836
+ const plan = planDeployment({
837
+ intent: "uninstall",
838
+ modules,
839
+ uninstallScope: "platform-instance",
840
+ });
841
+ await savePlan(ctx.paths, plan);
842
+ const current = await rebuildCurrentAssumptions(ctx.catalog, plan);
843
+ const applied = await applyPlan({
844
+ paths: ctx.paths,
845
+ planRef: plan.planRef,
846
+ catalog: ctx.catalog,
847
+ current,
848
+ driver: createWorkspacePackageManagerDriver({
849
+ workspaceRoot: ctx.paths.root,
850
+ }),
851
+ });
852
+ if (applied.outcome !== "COMPLETE") {
853
+ throw new PlatformError("UNINSTALL_FAILED", `Platform Instance uninstall stopped with ${applied.outcome}`);
854
+ }
855
+ removedModules = modules.map((module) => module.moduleRef);
856
+ }
857
+ const refreshed = await buildContext(binding.workspaceRealPath);
858
+ const remaining = await discoverModules({ catalog: refreshed.catalog });
859
+ if (remaining.length > 0) {
860
+ throw new PlatformError("UNINSTALL_FAILED", `Platform Instance uninstall left managed modules: ${remaining.map((module) => module.moduleRef).join(", ")}`);
861
+ }
862
+ // Deployment-owned plans/state/verification/instance identity must not leak
863
+ // into a future install of the same directory. Business/domain data outside
864
+ // `.proflow/deployment` is intentionally preserved.
865
+ await rm(ctx.paths.deployment, { recursive: true, force: true });
866
+ const completed = binding;
867
+ await clearGlobalBinding({
868
+ workspaceInstanceId: binding.workspaceInstanceId,
869
+ globalRoot: runtime.globalRoot,
870
+ });
871
+ binding = undefined;
872
+ return outcome("uninstall", "SUCCEEDED", {
873
+ uninstalledWorkspace: completed.workspaceRealPath,
874
+ removedModules,
875
+ bindingCleared: true,
876
+ });
877
+ }
878
+ catch (error) {
879
+ if (binding !== undefined) {
880
+ await updateGlobalBindingState({
881
+ workspaceInstanceId: binding.workspaceInstanceId,
882
+ state: "BROKEN",
883
+ globalRoot: runtime.globalRoot,
884
+ failure: {
885
+ code: error instanceof PlatformError ? error.code : "UNINSTALL_FAILED",
886
+ message: error instanceof Error ? error.message : String(error),
887
+ },
888
+ });
889
+ }
890
+ throw error;
891
+ }
892
+ finally {
893
+ await operationLock.release();
894
+ }
895
+ }
509
896
  async function handleLifecycle(ctx, args, primitive) {
510
897
  const modules = await discoverModules({ catalog: ctx.catalog });
511
898
  const selected = selectModules(modules, args.positional[0]);
@@ -572,7 +959,7 @@ async function handleDoctor(ctx, args) {
572
959
  async function handleManifest(ctx, args) {
573
960
  const modules = await discoverModules({ catalog: ctx.catalog });
574
961
  const selected = selectModules(modules, args.positional[0]);
575
- const config = await loadConfigFile(args.configFile);
962
+ const config = await loadEffectiveConfig(ctx, selected, args.configFile);
576
963
  const manifest = await buildManifest({
577
964
  catalog: ctx.catalog,
578
965
  modules: selected,
@@ -591,8 +978,14 @@ async function handleManifest(ctx, args) {
591
978
  return outcome("manifest", "FAILED", manifest);
592
979
  }
593
980
  }
594
- async function dispatchCommand(argv) {
595
- const args = parseArgs(argv);
981
+ async function dispatchCommand(argv, runtime = {}) {
982
+ let args;
983
+ try {
984
+ args = parseArgs(argv);
985
+ }
986
+ catch (error) {
987
+ return failure(argv[0] ?? "", error);
988
+ }
596
989
  if (!COMMANDS.includes(args.command)) {
597
990
  return {
598
991
  command: args.command,
@@ -604,47 +997,143 @@ async function dispatchCommand(argv) {
604
997
  };
605
998
  }
606
999
  try {
607
- const root = args.workspace ?? process.cwd();
608
- if (args.command === "search")
1000
+ const cwd = runtime.cwd ?? process.cwd();
1001
+ if (args.command === "search") {
1002
+ const current = await loadGlobalBinding(runtime.globalRoot);
1003
+ const root = args.workspace ?? current?.workspaceRealPath ?? cwd;
609
1004
  return await handleSearch(root, args);
1005
+ }
610
1006
  if (args.command === "preflight" && args.intent === "install") {
1007
+ const root = await resolveRequestedInstallWorkspace(args, runtime);
1008
+ const current = await loadGlobalBinding(runtime.globalRoot);
1009
+ if (current !== undefined && current.workspaceRealPath !== root) {
1010
+ throw new PlatformError("WORKSPACE_ALREADY_BOUND", `ProFlow is already bound to ${current.workspaceRealPath}; uninstall it before installing ${root}`);
1011
+ }
611
1012
  return await handleInstallerPreflight(root);
612
1013
  }
613
- const ctx = await buildContext(args.workspace);
1014
+ if (args.command === "plan") {
1015
+ if (args.intent === "install") {
1016
+ return await handleInstallPlanBeforeBinding(args, runtime);
1017
+ }
1018
+ if (args.intent === undefined ||
1019
+ !["configure", "upgrade", "uninstall", "repair"].includes(args.intent)) {
1020
+ const current = await loadGlobalBinding(runtime.globalRoot);
1021
+ const root = args.workspace ??
1022
+ current?.workspaceRealPath ??
1023
+ runtime.cwd ??
1024
+ process.cwd();
1025
+ return await handlePlan(await buildContext(root), args);
1026
+ }
1027
+ }
1028
+ if (args.command === "apply") {
1029
+ return await handleApplyWithGlobalBinding(args, runtime);
1030
+ }
1031
+ if (args.command === "install") {
1032
+ return await handleInstallWithGlobalBinding(args, runtime);
1033
+ }
1034
+ if (args.command === "status") {
1035
+ const observation = await observeBoundWorkspace(runtime.globalRoot);
1036
+ if (observation === undefined) {
1037
+ return outcome("status", "SUCCEEDED", {
1038
+ installed: false,
1039
+ bindingState: "UNBOUND",
1040
+ boundWorkspace: null,
1041
+ nextAction: "Run platform install [--workspace <path>]",
1042
+ });
1043
+ }
1044
+ if (!observation.workspaceExists) {
1045
+ return withWorkspace(outcome("status", "BLOCKED", {
1046
+ installed: false,
1047
+ code: "BOUND_WORKSPACE_MISSING",
1048
+ boundWorkspace: observation.binding.workspaceRealPath,
1049
+ nextAction: "Restore the Workspace or run platform uninstall --forget to clear only the stale binding",
1050
+ }), observation.binding);
1051
+ }
1052
+ }
1053
+ if (args.command === "uninstall" && args.forget) {
1054
+ if (args.positional.length > 0 || args.workspace !== undefined) {
1055
+ throw new PlatformError("INVALID_REQUEST", "platform uninstall --forget only clears the current stale global binding; do not combine it with a module/package or --workspace");
1056
+ }
1057
+ const operationLock = await acquireGlobalOperationLock(runtime.globalRoot);
1058
+ try {
1059
+ const forgotten = await forgetMissingWorkspaceBinding({
1060
+ globalRoot: runtime.globalRoot,
1061
+ });
1062
+ return outcome("uninstall", "SUCCEEDED", {
1063
+ forgottenWorkspace: forgotten.workspaceRealPath,
1064
+ bindingCleared: true,
1065
+ resourcesCleaned: false,
1066
+ });
1067
+ }
1068
+ finally {
1069
+ await operationLock.release();
1070
+ }
1071
+ }
1072
+ if (args.command === "uninstall" && args.positional.length === 0) {
1073
+ const current = await loadGlobalBinding(runtime.globalRoot);
1074
+ if (current === undefined) {
1075
+ return outcome("uninstall", "SUCCEEDED", {
1076
+ alreadyUninstalled: true,
1077
+ bindingCleared: true,
1078
+ });
1079
+ }
1080
+ return await handlePlatformInstanceUninstall(args, runtime);
1081
+ }
1082
+ if (args.command === "upgrade" ||
1083
+ (args.command === "uninstall" && args.positional.length > 0) ||
1084
+ args.command === "start" ||
1085
+ args.command === "stop" ||
1086
+ args.command === "restart") {
1087
+ return await handleBoundMutatingCommand(args, runtime);
1088
+ }
1089
+ const { ctx, binding } = await resolveBoundContext(runtime, args.workspace);
1090
+ let result;
614
1091
  switch (args.command) {
615
1092
  case "search":
616
- return await handleSearch(root, args);
1093
+ case "install":
1094
+ throw new PlatformError("COMMAND_FAILED", "unreachable command routing");
617
1095
  case "modules":
618
- return await handleModules(ctx, args);
1096
+ result = await handleModules(ctx, args);
1097
+ break;
619
1098
  case "docs":
620
- return await handleDocs(ctx, args);
621
- case "install":
1099
+ result = await handleDocs(ctx, args);
1100
+ break;
622
1101
  case "uninstall":
623
1102
  case "upgrade":
624
- return await handleManagedMutation(ctx, args, args.command);
625
- case "preflight":
626
- return await handlePreflight(ctx, args);
627
- case "plan":
628
- return await handlePlan(ctx, args);
629
- case "apply":
630
- return await handleApply(ctx, args);
631
1103
  case "start":
632
1104
  case "stop":
633
1105
  case "restart":
1106
+ throw new PlatformError("COMMAND_FAILED", "unreachable mutating command routing");
1107
+ case "preflight":
1108
+ result = await handlePreflight(ctx, args);
1109
+ break;
1110
+ case "plan":
1111
+ result = await handlePlan(ctx, args);
1112
+ break;
1113
+ case "apply":
1114
+ result = await handleApply(ctx, args);
1115
+ break;
634
1116
  case "status":
635
- return await handleLifecycle(ctx, args, args.command);
1117
+ result = await handleLifecycle(ctx, args, "status");
1118
+ break;
636
1119
  case "verify":
637
- return await handleVerify(ctx, args);
1120
+ result = await handleVerify(ctx, args);
1121
+ break;
638
1122
  case "doctor":
639
- return await handleDoctor(ctx, args);
1123
+ result = await handleDoctor(ctx, args);
1124
+ break;
640
1125
  case "manifest":
641
- return await handleManifest(ctx, args);
1126
+ result = await handleManifest(ctx, args);
1127
+ break;
1128
+ }
1129
+ if (result === undefined) {
1130
+ throw new PlatformError("COMMAND_FAILED", "unreachable command routing");
642
1131
  }
1132
+ return withWorkspace(result, binding);
643
1133
  }
644
1134
  catch (error) {
645
1135
  return failure(args.command, error);
646
1136
  }
647
- return failure(args.command, new PlatformError("COMMAND_FAILED", "unreachable"));
648
1137
  }
649
1138
  function toMachineResult(outcomeResult) {
650
1139
  const ok = outcomeResult.status === "SUCCEEDED";
@@ -654,6 +1143,9 @@ function toMachineResult(outcomeResult) {
654
1143
  status: outcomeResult.status,
655
1144
  moduleRef: MODULE_REF,
656
1145
  moduleVersion: MODULE_VERSION,
1146
+ ...(outcomeResult.workspace !== undefined
1147
+ ? { workspace: outcomeResult.workspace }
1148
+ : {}),
657
1149
  ...(outcomeResult.data !== undefined ? { data: outcomeResult.data } : {}),
658
1150
  ...(outcomeResult.status === "ACTION_REQUIRED"
659
1151
  ? {
@@ -674,7 +1166,125 @@ function toMachineResult(outcomeResult) {
674
1166
  : {}),
675
1167
  });
676
1168
  }
677
- export async function runCli(argv) {
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
+ }
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
+ }
678
1288
  if (argv.includes("--help") || argv.includes("-h")) {
679
1289
  return JSON.stringify({
680
1290
  contract: "deployment.result.v1",
@@ -685,10 +1295,19 @@ export async function runCli(argv) {
685
1295
  data: {
686
1296
  usage: "platform <command> [module|package] [--workspace <path>] [--json]",
687
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
+ ],
688
1308
  },
689
1309
  });
690
1310
  }
691
- const filtered = argv.filter((argument) => argument !== "--json");
692
1311
  if (filtered.length === 0) {
693
1312
  return JSON.stringify({
694
1313
  contract: "deployment.result.v1",
@@ -698,11 +1317,13 @@ export async function runCli(argv) {
698
1317
  moduleVersion: MODULE_VERSION,
699
1318
  });
700
1319
  }
701
- return toMachineResult(await dispatchCommand(filtered));
1320
+ return toMachineResult(await dispatchCommand(filtered, runtime));
702
1321
  }
703
1322
  if (import.meta.main) {
704
- const output = await runCli(process.argv.slice(2));
705
- 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`);
706
1327
  const parsed = JSON.parse(output);
707
1328
  if (typeof parsed === "object" &&
708
1329
  parsed !== null &&