@celilo/cli 0.3.30 → 0.4.0-alpha.1

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 (155) hide show
  1. package/drizzle/0005_module_operations.sql +12 -0
  2. package/drizzle/0006_base_module_aspects.sql +15 -0
  3. package/drizzle/0007_module_systems.sql +17 -0
  4. package/drizzle/meta/_journal.json +21 -0
  5. package/package.json +6 -5
  6. package/schemas/system_config.json +14 -28
  7. package/src/ansible/inventory.test.ts +46 -62
  8. package/src/ansible/inventory.ts +48 -25
  9. package/src/capabilities/registration.ts +25 -7
  10. package/src/capabilities/validation.test.ts +30 -0
  11. package/src/capabilities/validation.ts +8 -0
  12. package/src/cli/backup-rename.test.ts +95 -0
  13. package/src/cli/cli.test.ts +17 -23
  14. package/src/cli/command-registry.ts +199 -0
  15. package/src/cli/commands/backup-list.ts +1 -1
  16. package/src/cli/commands/events.ts +96 -0
  17. package/src/cli/commands/machine-add.ts +103 -59
  18. package/src/cli/commands/module-import.ts +153 -4
  19. package/src/cli/commands/module-remove.ts +86 -17
  20. package/src/cli/commands/module-status.ts +6 -2
  21. package/src/cli/commands/publish/alpha.test.ts +185 -0
  22. package/src/cli/commands/publish/alpha.ts +226 -0
  23. package/src/cli/commands/publish/changesets.test.ts +89 -0
  24. package/src/cli/commands/publish/changesets.ts +144 -0
  25. package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
  26. package/src/cli/commands/publish/consumer-pins.ts +149 -0
  27. package/src/cli/commands/publish/execute.ts +131 -0
  28. package/src/cli/commands/publish/global-install.test.ts +154 -0
  29. package/src/cli/commands/publish/global-install.ts +171 -0
  30. package/src/cli/commands/publish/helpers.ts +227 -0
  31. package/src/cli/commands/publish/index.ts +365 -0
  32. package/src/cli/commands/publish/module-registry.test.ts +40 -0
  33. package/src/cli/commands/publish/module-registry.ts +64 -0
  34. package/src/cli/commands/publish/plan.ts +107 -0
  35. package/src/cli/commands/publish/preflight.ts +238 -0
  36. package/src/cli/commands/publish/types.ts +264 -0
  37. package/src/cli/commands/publish/workspace.test.ts +323 -0
  38. package/src/cli/commands/publish/workspace.ts +596 -0
  39. package/src/cli/commands/restore.ts +126 -0
  40. package/src/cli/commands/storage-add-local.ts +1 -1
  41. package/src/cli/commands/storage-add-s3.ts +1 -1
  42. package/src/cli/commands/subscribers-add.ts +68 -0
  43. package/src/cli/commands/subscribers-list.ts +48 -0
  44. package/src/cli/commands/subscribers-remove.ts +38 -0
  45. package/src/cli/commands/subscribers-serve.ts +77 -0
  46. package/src/cli/commands/subscribers-status.ts +33 -0
  47. package/src/cli/commands/subscribers-test.ts +71 -0
  48. package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
  49. package/src/cli/commands/system-apply-config.test.ts +70 -0
  50. package/src/cli/commands/system-apply-config.ts +130 -0
  51. package/src/cli/commands/system-audit.ts +2 -1
  52. package/src/cli/commands/system-init-deprecation.test.ts +90 -0
  53. package/src/cli/commands/system-init.ts +36 -70
  54. package/src/cli/commands/system-update.ts +3 -2
  55. package/src/cli/completion.ts +22 -1
  56. package/src/cli/index.ts +214 -6
  57. package/src/cli/interactive-config.test.ts +19 -0
  58. package/src/cli/restore-command.test.ts +131 -0
  59. package/src/db/client.ts +42 -0
  60. package/src/db/schema.test.ts +13 -16
  61. package/src/db/schema.ts +161 -9
  62. package/src/hooks/capability-loader-firewall.test.ts +6 -15
  63. package/src/hooks/capability-loader.test.ts +2 -3
  64. package/src/hooks/capability-loader.ts +36 -2
  65. package/src/hooks/define-hook.test.ts +4 -0
  66. package/src/hooks/executor.test.ts +18 -0
  67. package/src/hooks/executor.ts +21 -2
  68. package/src/hooks/load-hook-config.test.ts +26 -24
  69. package/src/hooks/load-hook-config.ts +11 -2
  70. package/src/hooks/run-named-hook.ts +16 -0
  71. package/src/hooks/types.ts +9 -1
  72. package/src/manifest/contracts/v1.ts +70 -0
  73. package/src/manifest/schema.ts +262 -16
  74. package/src/manifest/validate-privileged.test.ts +84 -0
  75. package/src/manifest/validate.test.ts +156 -0
  76. package/src/manifest/validate.ts +69 -0
  77. package/src/module/import.ts +12 -0
  78. package/src/services/aspect-approvals.test.ts +231 -0
  79. package/src/services/aspect-approvals.ts +120 -0
  80. package/src/services/aspect-runner.test.ts +493 -0
  81. package/src/services/aspect-runner.ts +438 -0
  82. package/src/services/aspect-template-resolver.test.ts +101 -0
  83. package/src/services/aspect-template-resolver.ts +122 -0
  84. package/src/services/backup-create.ts +104 -25
  85. package/src/services/backup-envelope-roundtrip.test.ts +199 -0
  86. package/src/services/backup-in-flight-refusal.test.ts +163 -0
  87. package/src/services/backup-manifest.test.ts +115 -0
  88. package/src/services/backup-manifest.ts +163 -0
  89. package/src/services/backup-restore.ts +154 -19
  90. package/src/services/build-bus/delivery-events.ts +92 -0
  91. package/src/services/build-bus/event-factory.ts +54 -0
  92. package/src/services/build-bus/fan-out.test.ts +279 -0
  93. package/src/services/build-bus/fan-out.ts +161 -0
  94. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
  95. package/src/services/build-bus/hook-dispatch.test.ts +207 -0
  96. package/src/services/build-bus/hook-dispatch.ts +198 -0
  97. package/src/services/build-bus/hook-dispatcher.ts +115 -0
  98. package/src/services/build-bus/index.ts +41 -0
  99. package/src/services/build-bus/receiver-server.test.ts +179 -0
  100. package/src/services/build-bus/receiver-server.ts +159 -0
  101. package/src/services/build-bus/status.test.ts +212 -0
  102. package/src/services/build-bus/status.ts +213 -0
  103. package/src/services/build-bus/subscriber-store.ts +113 -0
  104. package/src/services/celilo-events.test.ts +70 -0
  105. package/src/services/celilo-events.ts +92 -0
  106. package/src/services/celilo-mgmt-hooks.test.ts +296 -0
  107. package/src/services/config-interview.ts +13 -95
  108. package/src/services/cross-module-data-manager.ts +2 -31
  109. package/src/services/cross-module-read.test.ts +250 -0
  110. package/src/services/cross-module-read.ts +232 -0
  111. package/src/services/deploy-validation.ts +7 -0
  112. package/src/services/deployed-systems.test.ts +235 -0
  113. package/src/services/deployed-systems.ts +308 -0
  114. package/src/services/dns-provider-backfill.ts +75 -0
  115. package/src/services/health-runner.ts +19 -3
  116. package/src/services/infrastructure-variable-resolver.test.ts +6 -32
  117. package/src/services/infrastructure-variable-resolver.ts +3 -13
  118. package/src/services/machine-detector.ts +104 -48
  119. package/src/services/machine-pool.ts +145 -2
  120. package/src/services/module-config.ts +78 -120
  121. package/src/services/module-deploy.ts +113 -40
  122. package/src/services/module-operations.test.ts +154 -0
  123. package/src/services/module-operations.ts +154 -0
  124. package/src/services/module-subscriptions.test.ts +58 -0
  125. package/src/services/module-subscriptions.ts +24 -1
  126. package/src/services/module-types-generator.test.ts +3 -3
  127. package/src/services/module-types-generator.ts +7 -2
  128. package/src/services/proxmox-reconcile.test.ts +333 -0
  129. package/src/services/proxmox-reconcile.ts +156 -0
  130. package/src/services/proxmox-state-recovery.ts +3 -24
  131. package/src/services/restore-from-file.test.ts +177 -0
  132. package/src/services/restore-from-file.ts +355 -0
  133. package/src/services/restore-preflight.test.ts +127 -0
  134. package/src/services/restore-preflight.ts +118 -0
  135. package/src/services/storage-providers/s3.ts +10 -2
  136. package/src/services/system-identity.ts +30 -0
  137. package/src/services/system-init.test.ts +64 -21
  138. package/src/services/system-init.ts +28 -26
  139. package/src/templates/generator.test.ts +7 -16
  140. package/src/templates/generator.ts +28 -115
  141. package/src/test-utils/integration.ts +5 -2
  142. package/src/types/infrastructure.ts +8 -0
  143. package/src/variables/computed/computed-integration.test.ts +191 -0
  144. package/src/variables/computed/computed.test.ts +177 -0
  145. package/src/variables/computed/evaluate.ts +271 -0
  146. package/src/variables/computed/marker.ts +53 -0
  147. package/src/variables/computed/parse.ts +262 -0
  148. package/src/variables/computed/provider-lookup.ts +130 -0
  149. package/src/variables/context.test.ts +89 -28
  150. package/src/variables/context.ts +196 -191
  151. package/src/variables/parser.ts +3 -3
  152. package/src/variables/resolver.test.ts +61 -0
  153. package/src/variables/resolver.ts +81 -0
  154. package/src/variables/types.ts +23 -1
  155. package/src/services/dns-auto-register.ts +0 -211
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Consumer-pins planner tests — covers the pure inner that decides
3
+ * what dep updates a single package.json would receive. The disk-
4
+ * walking wrapper (`planConsumerPins`) is a thin orchestrator on top
5
+ * of this; testing the pure layer exercises all the policy.
6
+ */
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+ import { bumpUpdatesFor } from './consumer-pins';
10
+ import type { PackageJson } from './types';
11
+
12
+ function latest(entries: Record<string, string>): Map<string, string> {
13
+ return new Map(Object.entries(entries));
14
+ }
15
+
16
+ describe('bumpUpdatesFor', () => {
17
+ test('empty package returns no updates', () => {
18
+ expect(bumpUpdatesFor({}, latest({ '@celilo/cli': '0.4.0' }))).toEqual([]);
19
+ });
20
+
21
+ test('skips deps not in the published map (only manages our own)', () => {
22
+ const pkg: PackageJson = {
23
+ dependencies: { lodash: '^4.17.21', '@celilo/cli': '^0.3.0' },
24
+ };
25
+ const updates = bumpUpdatesFor(pkg, latest({ '@celilo/cli': '0.4.0' }));
26
+ expect(updates).toHaveLength(1);
27
+ expect(updates[0].depName).toBe('@celilo/cli');
28
+ expect(updates[0].newSpec).toBe('^0.4.0');
29
+ });
30
+
31
+ test('preserves caret pin', () => {
32
+ const pkg: PackageJson = { dependencies: { '@celilo/cli': '^0.3.0' } };
33
+ expect(bumpUpdatesFor(pkg, latest({ '@celilo/cli': '0.4.0' }))).toEqual([
34
+ {
35
+ bucket: 'dependencies',
36
+ depName: '@celilo/cli',
37
+ oldSpec: '^0.3.0',
38
+ newSpec: '^0.4.0',
39
+ },
40
+ ]);
41
+ });
42
+
43
+ test('preserves tilde pin', () => {
44
+ const pkg: PackageJson = { dependencies: { '@celilo/cli': '~0.3.0' } };
45
+ expect(bumpUpdatesFor(pkg, latest({ '@celilo/cli': '0.4.0' }))).toEqual([
46
+ {
47
+ bucket: 'dependencies',
48
+ depName: '@celilo/cli',
49
+ oldSpec: '~0.3.0',
50
+ newSpec: '~0.4.0',
51
+ },
52
+ ]);
53
+ });
54
+
55
+ test('preserves exact pin (no operator widening)', () => {
56
+ const pkg: PackageJson = { dependencies: { '@celilo/cli': '0.3.0' } };
57
+ expect(bumpUpdatesFor(pkg, latest({ '@celilo/cli': '0.4.0' }))).toEqual([
58
+ {
59
+ bucket: 'dependencies',
60
+ depName: '@celilo/cli',
61
+ oldSpec: '0.3.0',
62
+ newSpec: '0.4.0',
63
+ },
64
+ ]);
65
+ });
66
+
67
+ test('skips workspace:* protocol specs', () => {
68
+ const pkg: PackageJson = {
69
+ dependencies: {
70
+ '@celilo/cli': 'workspace:^',
71
+ '@celilo/e2e': 'workspace:*',
72
+ },
73
+ };
74
+ expect(bumpUpdatesFor(pkg, latest({ '@celilo/cli': '0.4.0', '@celilo/e2e': '0.8.0' }))).toEqual(
75
+ [],
76
+ );
77
+ });
78
+
79
+ test('skips file: / git: protocol specs (deliberate local pins)', () => {
80
+ const pkg: PackageJson = {
81
+ dependencies: {
82
+ '@celilo/cli': 'file:../celilo',
83
+ '@celilo/e2e': 'git://github.com/celilo/e2e.git',
84
+ },
85
+ };
86
+ expect(bumpUpdatesFor(pkg, latest({ '@celilo/cli': '0.4.0', '@celilo/e2e': '0.8.0' }))).toEqual(
87
+ [],
88
+ );
89
+ });
90
+
91
+ test('bumps npm: protocol specs (those are real npm refs)', () => {
92
+ const pkg: PackageJson = {
93
+ dependencies: { '@celilo/cli': 'npm:@celilo/cli@^0.3.0' },
94
+ };
95
+ // Edge: the bareVersion strip won't recover "0.3.0" cleanly from
96
+ // the full npm: spec, so the equality check doesn't fire and we
97
+ // attempt a withOperator rewrite — but withOperator only strips
98
+ // operators it knows, leaving the npm:-prefixed spec different
99
+ // from newSpec, so an update IS emitted. This documents current
100
+ // behavior; bumping npm:-aliased deps isn't a tested use case.
101
+ const updates = bumpUpdatesFor(pkg, latest({ '@celilo/cli': '0.4.0' }));
102
+ expect(updates).toHaveLength(1);
103
+ });
104
+
105
+ test('skips deps already at the target bare version', () => {
106
+ const pkg: PackageJson = {
107
+ dependencies: {
108
+ a: '0.4.0',
109
+ b: '^0.4.0',
110
+ c: '~0.4.0',
111
+ },
112
+ };
113
+ expect(bumpUpdatesFor(pkg, latest({ a: '0.4.0', b: '0.4.0', c: '0.4.0' }))).toEqual([]);
114
+ });
115
+
116
+ test('walks every dependency bucket', () => {
117
+ const pkg: PackageJson = {
118
+ dependencies: { a: '^1.0.0' },
119
+ devDependencies: { b: '^1.0.0' },
120
+ peerDependencies: { c: '^1.0.0' },
121
+ optionalDependencies: { d: '^1.0.0' },
122
+ };
123
+ const updates = bumpUpdatesFor(pkg, latest({ a: '1.1.0', b: '1.1.0', c: '1.1.0', d: '1.1.0' }));
124
+ expect(updates.map((u) => u.bucket)).toEqual([
125
+ 'dependencies',
126
+ 'devDependencies',
127
+ 'peerDependencies',
128
+ 'optionalDependencies',
129
+ ]);
130
+ });
131
+
132
+ test('emits multiple updates for multiple changed deps', () => {
133
+ const pkg: PackageJson = {
134
+ dependencies: {
135
+ '@celilo/cli': '^0.3.0',
136
+ '@celilo/e2e': '^0.7.0',
137
+ '@celilo/event-bus': '^0.1.0',
138
+ },
139
+ };
140
+ const updates = bumpUpdatesFor(
141
+ pkg,
142
+ latest({
143
+ '@celilo/cli': '0.4.0',
144
+ '@celilo/e2e': '0.8.0',
145
+ '@celilo/event-bus': '0.1.5',
146
+ }),
147
+ );
148
+ expect(updates).toHaveLength(3);
149
+ expect(updates.map((u) => u.depName).sort()).toEqual([
150
+ '@celilo/cli',
151
+ '@celilo/e2e',
152
+ '@celilo/event-bus',
153
+ ]);
154
+ });
155
+ });
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Consumer pin bumps — Phase 2 of `celilo publish`.
3
+ *
4
+ * Walks the in-repo `modules/`, `apps/`, `packages/` trees and any
5
+ * external project paths from `.env`'s `EXTERNAL_PROJECT_PATHS`, and
6
+ * bumps every `@celilo/*` dependency pin to the current npm-latest
7
+ * version. Preserves the caret/tilde/exact operator each entry already
8
+ * uses. `workspace:*`/`workspace:^` references are left alone (those
9
+ * are rewritten at publish time, not consumer-install time).
10
+ *
11
+ * Skipped in --alpha mode (consumers opt into @alpha manually). Runs
12
+ * in --normal and --promote modes.
13
+ */
14
+
15
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import { join, relative } from 'node:path';
17
+ import {
18
+ REPO_ROOT,
19
+ bareVersion,
20
+ fetchLatestVersions,
21
+ findPackageJsons,
22
+ readExternalProjectPaths,
23
+ withOperator,
24
+ } from './helpers';
25
+ import type { ConsumerPinItem, PackageJson } from './types';
26
+
27
+ // ─── Planner ───────────────────────────────────────────────────────
28
+
29
+ /**
30
+ * Build the consumer-pin plan. For each package.json under managed
31
+ * roots, compute which `@celilo/*` deps would change if we bumped them
32
+ * to current npm-latest. Returns the file paths + per-bucket update
33
+ * lists; doesn't write anything.
34
+ */
35
+ export function planConsumerPins(): ConsumerPinItem[] {
36
+ const latestMap = fetchLatestVersions();
37
+ if (latestMap.size === 0) return [];
38
+
39
+ const externalPaths = readExternalProjectPaths();
40
+ const roots = [
41
+ join(REPO_ROOT, 'modules'),
42
+ join(REPO_ROOT, 'apps'),
43
+ join(REPO_ROOT, 'packages'),
44
+ ...externalPaths,
45
+ ].filter((p) => existsSync(p));
46
+
47
+ const items: ConsumerPinItem[] = [];
48
+ for (const root of roots) {
49
+ for (const pkgPath of findPackageJsons(root)) {
50
+ const updates = computeBumpUpdates(pkgPath, latestMap);
51
+ if (updates.length > 0) {
52
+ items.push({ filePath: pkgPath, updates });
53
+ }
54
+ }
55
+ }
56
+ return items;
57
+ }
58
+
59
+ /**
60
+ * Pure dep-bump computation. Given a parsed package.json and a map of
61
+ * `name → latest-version`, returns the list of pin changes that would
62
+ * be applied. No I/O. Skips:
63
+ * - deps not in the published map (we only manage our own).
64
+ * - `workspace:*` / `workspace:^` (those are rewritten at publish
65
+ * time, not at consumer-install time).
66
+ * - non-npm protocol specs (`file:`, `git:`, etc. — deliberate
67
+ * local pins).
68
+ * - deps already at the target bare version (operator-class match,
69
+ * `^1.0.0` and `1.0.0` both count as "at version 1.0.0").
70
+ */
71
+ export function bumpUpdatesFor(
72
+ pkg: PackageJson,
73
+ published: Map<string, string>,
74
+ ): ConsumerPinItem['updates'] {
75
+ const updates: ConsumerPinItem['updates'] = [];
76
+ const buckets: Array<ConsumerPinItem['updates'][number]['bucket']> = [
77
+ 'dependencies',
78
+ 'devDependencies',
79
+ 'peerDependencies',
80
+ 'optionalDependencies',
81
+ ];
82
+
83
+ for (const bucket of buckets) {
84
+ const deps = pkg[bucket] as Record<string, string> | undefined;
85
+ if (!deps) continue;
86
+ for (const [name, oldSpec] of Object.entries(deps)) {
87
+ const newVersion = published.get(name);
88
+ if (!newVersion) continue;
89
+ if (oldSpec.startsWith('workspace:')) continue;
90
+ if (/^[a-z]+:/.test(oldSpec) && !oldSpec.startsWith('npm:')) continue;
91
+ if (bareVersion(oldSpec) === newVersion) continue;
92
+ const newSpec = withOperator(oldSpec, newVersion);
93
+ if (newSpec === oldSpec) continue;
94
+ updates.push({ bucket, depName: name, oldSpec, newSpec });
95
+ }
96
+ }
97
+ return updates;
98
+ }
99
+
100
+ /**
101
+ * Disk wrapper around `bumpUpdatesFor`. Reads the file, delegates,
102
+ * returns. Exists so the planner doesn't have to do the read itself.
103
+ */
104
+ function computeBumpUpdates(
105
+ path: string,
106
+ published: Map<string, string>,
107
+ ): ConsumerPinItem['updates'] {
108
+ const pkg = JSON.parse(readFileSync(path, 'utf-8')) as PackageJson;
109
+ return bumpUpdatesFor(pkg, published);
110
+ }
111
+
112
+ // ─── Executor ──────────────────────────────────────────────────────
113
+
114
+ /**
115
+ * Apply a consumer-pin plan. For each item, read the file, splice in
116
+ * the updates, write back. Preserves 2-space indent + trailing newline.
117
+ */
118
+ export function executeConsumerPins(items: ConsumerPinItem[]): void {
119
+ if (items.length === 0) {
120
+ console.log('All consumer pins already at npm-latest. Nothing to do.');
121
+ return;
122
+ }
123
+
124
+ console.log('\n──────────────────────────────────────────────');
125
+ console.log(' Bumping consumer pins to current npm-latest');
126
+ console.log('──────────────────────────────────────────────');
127
+
128
+ for (const item of items) {
129
+ const original = readFileSync(item.filePath, 'utf-8');
130
+ const pkg = JSON.parse(original) as PackageJson;
131
+ for (const u of item.updates) {
132
+ const deps = pkg[u.bucket] as Record<string, string> | undefined;
133
+ if (deps) deps[u.depName] = u.newSpec;
134
+ }
135
+ const trailingNl = original.endsWith('\n') ? '\n' : '';
136
+ writeFileSync(item.filePath, JSON.stringify(pkg, null, 2) + trailingNl);
137
+
138
+ const display = relative(REPO_ROOT, item.filePath);
139
+ const shown = display.startsWith('..') ? item.filePath : display;
140
+ console.log(`✎ ${shown}`);
141
+ for (const u of item.updates) {
142
+ console.log(` ${u.bucket}.${u.depName}: ${u.oldSpec} → ${u.newSpec}`);
143
+ }
144
+ }
145
+
146
+ console.log(`\nUpdated ${items.length} package.json file(s).`);
147
+ console.log('Review with `git diff` (and in external project paths) before committing.');
148
+ console.log('A `bun install` may be needed in each touched project to refresh its lockfile.');
149
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Umbrella executor — runs a `PublishPlan` top to bottom.
3
+ *
4
+ * Mirrors the phase gating in plan.ts: if a per-phase items array is
5
+ * empty, the corresponding execute is skipped. Workspace runs first
6
+ * (its result feeds Phase 3's force-pin targets); the rest follow the
7
+ * spec's Phase 2 → 3 → 4 ordering. After the workspace phase succeeds,
8
+ * the build-bus fan-out emits webhooks to any registered subscribers
9
+ * — best-effort, doesn't gate phases 2/3/4.
10
+ */
11
+
12
+ import {
13
+ eventsForPublished,
14
+ fanOut,
15
+ formatDeliveryResult,
16
+ loadSubscribers,
17
+ recordDeliveryOutcome,
18
+ } from '../../../services/build-bus';
19
+ import { executeConsumerPins } from './consumer-pins';
20
+ import { executeGlobalUpdate } from './global-install';
21
+ import { currentGitHead } from './helpers';
22
+ import { executeModulePublish } from './module-registry';
23
+ import type { PublishPlan, PublishResult } from './types';
24
+ import { executeWorkspace } from './workspace';
25
+
26
+ export interface ExecutePlanInput {
27
+ plan: PublishPlan;
28
+ confirm: (question: string) => Promise<boolean>;
29
+ }
30
+
31
+ export async function executePlan(input: ExecutePlanInput): Promise<PublishResult> {
32
+ const { plan, confirm } = input;
33
+
34
+ // Build the workspace-versions map from the plan's items. The
35
+ // planner already tightened versions for alpha mode; we just need
36
+ // the lookup table for cross-package workspace:^ rewrites.
37
+ const workspaceVersions = new Map<string, string>();
38
+ for (const item of plan.workspace) {
39
+ workspaceVersions.set(item.name, item.versionToPublish);
40
+ }
41
+
42
+ const result = await executeWorkspace({
43
+ items: plan.workspace,
44
+ workspaceVersions,
45
+ mode: plan.mode,
46
+ confirm,
47
+ });
48
+
49
+ // Build-bus fan-out — fires AFTER workspace publish succeeds, BEFORE
50
+ // the optional follow-on phases. Best-effort: subscribers that fail
51
+ // delivery don't block phases 2/3/4 from running. Operator sees the
52
+ // outcome in the printed summary.
53
+ if (result.published.length > 0) {
54
+ await emitBuildBusEvents(result.published, plan);
55
+ }
56
+
57
+ if (plan.consumerPins.length > 0) {
58
+ executeConsumerPins(plan.consumerPins);
59
+ }
60
+
61
+ if (plan.globalUpdate.length > 0) {
62
+ executeGlobalUpdate(plan.globalUpdate);
63
+ }
64
+
65
+ if (plan.modulePublish.length > 0) {
66
+ executeModulePublish(plan.modulePublish, { allowStale: plan.options.allowStale });
67
+ }
68
+
69
+ return result;
70
+ }
71
+
72
+ /**
73
+ * Best-effort build-bus fan-out. Loads the subscriber list, builds a
74
+ * PublishEvent per just-published package, fires signed webhooks,
75
+ * prints a delivery summary. Any failure (config parse, fan-out
76
+ * error) is logged but doesn't crash the publish — subscribers are
77
+ * an opt-in cross-machine relay, not a publish prerequisite.
78
+ */
79
+ async function emitBuildBusEvents(
80
+ published: PublishResult['published'],
81
+ plan: PublishPlan,
82
+ ): Promise<void> {
83
+ let subscribers: ReturnType<typeof loadSubscribers>;
84
+ try {
85
+ subscribers = loadSubscribers();
86
+ } catch (err) {
87
+ console.warn(
88
+ `\n⚠ build-bus: could not load subscriber config — ${err instanceof Error ? err.message : String(err)}`,
89
+ );
90
+ console.warn(' (skipping fan-out; publish itself is unaffected)');
91
+ return;
92
+ }
93
+ if (subscribers.length === 0) return;
94
+
95
+ // Mode determines the dist-tag carried on the event. Alpha + promote
96
+ // both publish under specific tags (alpha → @alpha, promote → @latest
97
+ // as a real release). Normal mode is @latest.
98
+ const tag: 'latest' | 'alpha' = plan.mode.kind === 'alpha' ? 'alpha' : 'latest';
99
+ const gitHead = currentGitHead();
100
+
101
+ const events = eventsForPublished({
102
+ published,
103
+ tag,
104
+ gitHead,
105
+ registry: 'npm',
106
+ });
107
+
108
+ console.log('\n──────────────────────────────────────────────');
109
+ console.log(' Build-bus fan-out');
110
+ console.log('──────────────────────────────────────────────');
111
+
112
+ for (const event of events) {
113
+ const results = await fanOut(event, subscribers);
114
+ if (results.length === 0) {
115
+ console.log(
116
+ ` ${event.package.name}@${event.package.version}: no subscribers match (registry=${event.registry}, tag=${event.tag})`,
117
+ );
118
+ continue;
119
+ }
120
+ console.log(
121
+ ` ${event.package.name}@${event.package.version} → ${results.length} subscriber(s):`,
122
+ );
123
+ for (const r of results) {
124
+ console.log(` ${formatDeliveryResult(r)}`);
125
+ // Persist outcome to the local bus so `celilo subscribers
126
+ // status` can surface it later. Best-effort — never fails
127
+ // the publish flow.
128
+ recordDeliveryOutcome(event, r);
129
+ }
130
+ }
131
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Global-install planner tests — covers the pure decision function
3
+ * that turns "what's installed globally" + "what we're publishing" +
4
+ * --track-alpha into a list of GlobalUpdateItems for the executor.
5
+ *
6
+ * The disk-reading wrapper (`planGlobalUpdate`) is a thin shim on top
7
+ * of `decideGlobalUpdates`; testing the pure layer exercises all the
8
+ * policy.
9
+ */
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+ import { decideGlobalUpdates } from './global-install';
13
+
14
+ /** Helper for tests: pretend everything in the map is installed at the listed version. */
15
+ function installedVersionFrom(versions: Record<string, string>): (name: string) => string | null {
16
+ return (name) => versions[name] ?? null;
17
+ }
18
+
19
+ describe('decideGlobalUpdates', () => {
20
+ test('empty installed → empty plan', () => {
21
+ expect(
22
+ decideGlobalUpdates({
23
+ installed: [],
24
+ justPublished: [],
25
+ trackAlpha: false,
26
+ installedVersion: () => null,
27
+ }),
28
+ ).toEqual([]);
29
+ });
30
+
31
+ test('normal mode: force-pins just-published, update-pulls the rest', () => {
32
+ const result = decideGlobalUpdates({
33
+ installed: ['@celilo/cli', '@celilo/e2e', '@celilo/event-bus'],
34
+ justPublished: [
35
+ { name: '@celilo/cli', version: '0.4.0' },
36
+ { name: '@celilo/e2e', version: '0.8.0' },
37
+ ],
38
+ trackAlpha: false,
39
+ installedVersion: installedVersionFrom({
40
+ '@celilo/cli': '0.3.0',
41
+ '@celilo/e2e': '0.7.0',
42
+ '@celilo/event-bus': '0.1.0',
43
+ }),
44
+ });
45
+
46
+ expect(result).toEqual([
47
+ {
48
+ name: '@celilo/cli',
49
+ installed: '0.3.0',
50
+ target: '0.4.0',
51
+ forcePin: true,
52
+ },
53
+ {
54
+ name: '@celilo/e2e',
55
+ installed: '0.7.0',
56
+ target: '0.8.0',
57
+ forcePin: true,
58
+ },
59
+ {
60
+ name: '@celilo/event-bus',
61
+ installed: '0.1.0',
62
+ target: '0.1.0',
63
+ forcePin: false,
64
+ },
65
+ ]);
66
+ });
67
+
68
+ test('--track-alpha filters to just-published only', () => {
69
+ const result = decideGlobalUpdates({
70
+ installed: ['@celilo/cli', '@celilo/e2e', '@celilo/event-bus'],
71
+ justPublished: [{ name: '@celilo/e2e', version: '0.8.0-alpha.0' }],
72
+ trackAlpha: true,
73
+ installedVersion: installedVersionFrom({
74
+ '@celilo/cli': '0.3.0',
75
+ '@celilo/e2e': '0.7.0',
76
+ '@celilo/event-bus': '0.1.0',
77
+ }),
78
+ });
79
+
80
+ // Other globals (@celilo/cli, @celilo/event-bus) are left alone —
81
+ // they may already be on their own alpha streams.
82
+ expect(result).toHaveLength(1);
83
+ expect(result[0]).toEqual({
84
+ name: '@celilo/e2e',
85
+ installed: '0.7.0',
86
+ target: '0.8.0-alpha.0',
87
+ forcePin: true,
88
+ });
89
+ });
90
+
91
+ test('--track-alpha + nothing-just-published → empty plan', () => {
92
+ expect(
93
+ decideGlobalUpdates({
94
+ installed: ['@celilo/cli', '@celilo/e2e'],
95
+ justPublished: [],
96
+ trackAlpha: true,
97
+ installedVersion: installedVersionFrom({
98
+ '@celilo/cli': '0.3.0',
99
+ '@celilo/e2e': '0.7.0',
100
+ }),
101
+ }),
102
+ ).toEqual([]);
103
+ });
104
+
105
+ test('handles missing installed version gracefully (target falls back to "?")', () => {
106
+ const result = decideGlobalUpdates({
107
+ installed: ['@celilo/cli'],
108
+ justPublished: [],
109
+ trackAlpha: false,
110
+ installedVersion: () => null, // bun symlink missing, malformed pkg, etc.
111
+ });
112
+
113
+ expect(result).toEqual([
114
+ {
115
+ name: '@celilo/cli',
116
+ installed: null,
117
+ target: '?',
118
+ forcePin: false,
119
+ },
120
+ ]);
121
+ });
122
+
123
+ test('preserves installed order', () => {
124
+ const result = decideGlobalUpdates({
125
+ installed: ['@celilo/event-bus', '@celilo/cli', '@celilo/e2e'],
126
+ justPublished: [],
127
+ trackAlpha: false,
128
+ installedVersion: installedVersionFrom({
129
+ '@celilo/event-bus': '0.1.0',
130
+ '@celilo/cli': '0.3.0',
131
+ '@celilo/e2e': '0.7.0',
132
+ }),
133
+ });
134
+
135
+ expect(result.map((i) => i.name)).toEqual(['@celilo/event-bus', '@celilo/cli', '@celilo/e2e']);
136
+ });
137
+
138
+ test('just-published packages NOT in installed are not added (only refreshes existing globals)', () => {
139
+ // Operator hasn't installed @celilo/cli globally, but the publish
140
+ // run shipped it. We don't auto-install — we only refresh what's
141
+ // already there. So @celilo/cli should NOT appear in the plan.
142
+ const result = decideGlobalUpdates({
143
+ installed: ['@celilo/e2e'],
144
+ justPublished: [
145
+ { name: '@celilo/cli', version: '0.4.0' },
146
+ { name: '@celilo/e2e', version: '0.8.0' },
147
+ ],
148
+ trackAlpha: false,
149
+ installedVersion: installedVersionFrom({ '@celilo/e2e': '0.7.0' }),
150
+ });
151
+
152
+ expect(result.map((i) => i.name)).toEqual(['@celilo/e2e']);
153
+ });
154
+ });