@celilo/cli 0.3.30 → 0.4.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +5 -4
  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,365 @@
1
+ /**
2
+ * `celilo publish` entry point — thin orchestration over the
3
+ * planner/executor split (v2/PUBLILO_CLI.md Phase 2).
4
+ *
5
+ * parse args → preflight → build plan → display → confirm → execute
6
+ *
7
+ * Each per-phase planner and executor lives in its own module
8
+ * (workspace.ts, consumer-pins.ts, global-install.ts,
9
+ * module-registry.ts); this file just wires them together and owns the
10
+ * top-level flag parsing + dry-run / release-touch / promote special
11
+ * paths.
12
+ *
13
+ * Both `celilo publish ...` (via the CLI dispatcher) and
14
+ * `bun run publish ...` (via the legacy shim at scripts/publish.ts)
15
+ * call `main()` here with their argv slice.
16
+ *
17
+ * Usage:
18
+ * celilo publish # publish (preflight first)
19
+ * celilo publish --dry-run # preflight + plan, no changes
20
+ * celilo publish --release-touch # auto-fix module stale-drift
21
+ * celilo publish --allow-stale # skip stale-version safeguards
22
+ * celilo publish -y / --yes # auto-confirm prompts
23
+ * celilo publish --alpha # X.Y.Z-alpha.N to @alpha
24
+ * celilo publish --alpha --track-alpha # also force-pin alphas globally
25
+ * celilo publish --alpha --alpha-modules # also publish module +N
26
+ * celilo publish --promote <pkg>@<alpha-version> # graduate alpha to real
27
+ */
28
+
29
+ import { execSync } from 'node:child_process';
30
+ import { createInterface } from 'node:readline';
31
+ import {
32
+ alphaSkipDecision,
33
+ isAlphaVersion,
34
+ nextAlphaNumber,
35
+ parsePackageSpec,
36
+ stripAlphaSuffix,
37
+ } from './alpha';
38
+ import {
39
+ applyPendingChangesets,
40
+ listPendingChangesets,
41
+ printPendingChangesets,
42
+ } from './changesets';
43
+ import { executePlan } from './execute';
44
+ import { PACKAGES, REPO_ROOT, isPublished, readPkg } from './helpers';
45
+ import { displayPlan, planPublish } from './plan';
46
+ import {
47
+ applyReleaseTouch,
48
+ autoTouchAndCommit,
49
+ printPreflightReport,
50
+ runPreflight,
51
+ } from './preflight';
52
+ import type { PublishMode, PublishOptions } from './types';
53
+
54
+ // ─── Confirm helper ────────────────────────────────────────────────
55
+
56
+ // Set once at main() entry from the resolved argv. Module-scoped because
57
+ // confirm() is called from a dozen places and threading the flag through
58
+ // each path adds noise without lifting any decisions.
59
+ let autoYes = false;
60
+
61
+ function prompt(question: string): Promise<string> {
62
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
63
+ return new Promise((res) =>
64
+ rl.question(question, (a) => {
65
+ rl.close();
66
+ res(a);
67
+ }),
68
+ );
69
+ }
70
+
71
+ /**
72
+ * `-y` / `--yes` auto-confirms every prompt. Doesn't bypass the
73
+ * dirty-tree check or the stale-version safeguard — those are real
74
+ * gates, not just confirmations.
75
+ */
76
+ async function confirm(question: string): Promise<boolean> {
77
+ if (autoYes) {
78
+ process.stdout.write(`${question}y (auto-confirmed via -y)\n`);
79
+ return true;
80
+ }
81
+ const reply = await prompt(question);
82
+ return /^[Yy]$/.test(reply.trim());
83
+ }
84
+
85
+ // ─── Flag parsing ──────────────────────────────────────────────────
86
+
87
+ interface ParsedFlags {
88
+ options: PublishOptions;
89
+ dryRun: boolean;
90
+ releaseTouch: boolean;
91
+ /**
92
+ * Suppresses the pending-changesets prompt at the top of normal-mode
93
+ * publishes. Use when there are unapplied changesets but the
94
+ * operator wants to ship what's in source right now (or pre-applied
95
+ * the bumps via `bunx changeset version` themselves). The flag has
96
+ * no effect in alpha or promote mode — those skip changesets
97
+ * categorically.
98
+ */
99
+ skipChangesets: boolean;
100
+ }
101
+
102
+ export function parseOptions(argv: string[]): ParsedFlags {
103
+ function takeFlagValue(flag: string): string | null {
104
+ const i = argv.indexOf(flag);
105
+ if (i < 0) return null;
106
+ const value = argv[i + 1];
107
+ if (value === undefined || value.startsWith('-')) {
108
+ console.error(`✗ ${flag} requires a value (e.g. ${flag} @celilo/e2e@0.7.14-alpha.3)`);
109
+ process.exit(1);
110
+ }
111
+ return value;
112
+ }
113
+
114
+ const allowStale = argv.includes('--allow-stale');
115
+ const yes = argv.includes('-y') || argv.includes('--yes');
116
+ const dryRun = argv.includes('--dry-run') || argv.includes('-n');
117
+ const releaseTouch = argv.includes('--release-touch');
118
+ const alphaFlag = argv.includes('--alpha');
119
+ const trackAlphaFlag = argv.includes('--track-alpha');
120
+ const alphaModulesFlag = argv.includes('--alpha-modules');
121
+ const skipChangesets = argv.includes('--skip-changesets');
122
+ const promoteArg = takeFlagValue('--promote');
123
+
124
+ if (alphaFlag && promoteArg) {
125
+ console.error('✗ --alpha and --promote are mutually exclusive.');
126
+ process.exit(1);
127
+ }
128
+ if (trackAlphaFlag && !alphaFlag) {
129
+ console.error('✗ --track-alpha requires --alpha.');
130
+ process.exit(1);
131
+ }
132
+ if (alphaModulesFlag && !alphaFlag) {
133
+ console.error('✗ --alpha-modules requires --alpha.');
134
+ process.exit(1);
135
+ }
136
+ if (releaseTouch && (alphaFlag || promoteArg)) {
137
+ console.error('✗ --release-touch cannot be combined with --alpha or --promote.');
138
+ process.exit(1);
139
+ }
140
+
141
+ const mode: PublishMode = promoteArg
142
+ ? { kind: 'promote', target: parsePackageSpec(promoteArg) }
143
+ : alphaFlag
144
+ ? { kind: 'alpha', trackAlpha: trackAlphaFlag, alphaModules: alphaModulesFlag }
145
+ : { kind: 'normal' };
146
+
147
+ if (mode.kind === 'promote' && !isAlphaVersion(mode.target.version)) {
148
+ console.error(
149
+ `✗ --promote target "${mode.target.name}@${mode.target.version}" is not an alpha (no -alpha.N suffix).`,
150
+ );
151
+ process.exit(1);
152
+ }
153
+
154
+ return {
155
+ options: { allowStale, autoYes: yes, mode },
156
+ dryRun,
157
+ releaseTouch,
158
+ skipChangesets,
159
+ };
160
+ }
161
+
162
+ // ─── Special-case handlers ─────────────────────────────────────────
163
+
164
+ async function handleDryRun(opts: PublishOptions): Promise<never> {
165
+ const report = runPreflight();
166
+ // Match the mode-aware blocking logic the real publish uses.
167
+ const skipWorkspaceStale = opts.mode.kind !== 'normal';
168
+ const skipModuleStale = opts.mode.kind === 'alpha';
169
+ printPreflightReport(report);
170
+
171
+ if (opts.mode.kind === 'alpha') {
172
+ console.log('\nAlpha publish plan:');
173
+ for (const pkg of PACKAGES) {
174
+ const { name, version } = readPkg(pkg);
175
+ if (!name || !version) continue;
176
+ const n = nextAlphaNumber(name, version);
177
+ const decision = alphaSkipDecision(pkg, name, version, n);
178
+ const note = decision.skip ? ` [skip: ${decision.reason}]` : '';
179
+ console.log(` ${name.padEnd(32)} ${version} → ${version}-alpha.${n} (${pkg})${note}`);
180
+ }
181
+ } else if (opts.mode.kind === 'promote') {
182
+ const base = stripAlphaSuffix(opts.mode.target.version);
183
+ console.log(
184
+ `\nPromote plan:\n ${opts.mode.target.name.padEnd(32)} ${opts.mode.target.version} → ${base}`,
185
+ );
186
+ }
187
+
188
+ const anyIssue =
189
+ report.dirty ||
190
+ (!skipWorkspaceStale && report.workspaceStale.length > 0) ||
191
+ (!skipModuleStale && report.moduleStale.length > 0);
192
+ process.exit(anyIssue ? 1 : 0);
193
+ }
194
+
195
+ async function handleReleaseTouch(): Promise<never> {
196
+ const report = runPreflight();
197
+ if (report.dirty) {
198
+ console.error(
199
+ '✗ Working tree is dirty — refusing to touch manifests on top of uncommitted work.',
200
+ );
201
+ console.error(' Commit or stash first, then re-run --release-touch.\n');
202
+ console.error(execSync('git status --short', { encoding: 'utf-8', cwd: REPO_ROOT }));
203
+ process.exit(1);
204
+ }
205
+ if (report.workspaceStale.length > 0) {
206
+ console.error(
207
+ "✗ Workspace package(s) need real version bumps — these can't be release-touched:",
208
+ );
209
+ for (const i of report.workspaceStale) {
210
+ console.error(` ${i.name}@${i.version} (bump ${i.pkg}/package.json)`);
211
+ }
212
+ console.error(
213
+ '\n Bump those manually, commit, then re-run --release-touch (or just `bun run publish`).',
214
+ );
215
+ process.exit(1);
216
+ }
217
+ applyReleaseTouch(report.moduleStale);
218
+ process.exit(0);
219
+ }
220
+
221
+ // ─── Main ──────────────────────────────────────────────────────────
222
+
223
+ export async function main(argv: string[]): Promise<void> {
224
+ // process.chdir matches the prior script-scope behavior: many helpers
225
+ // run `git`, `bun`, `npm` as subprocesses without a cwd, relying on
226
+ // the publisher being at the monorepo root.
227
+ process.chdir(REPO_ROOT);
228
+
229
+ const { options, dryRun, releaseTouch, skipChangesets } = parseOptions(argv);
230
+ autoYes = options.autoYes;
231
+
232
+ if (dryRun) return handleDryRun(options);
233
+ if (releaseTouch) return handleReleaseTouch();
234
+
235
+ // Promote requires the alpha to actually exist on npm — otherwise
236
+ // there's nothing to graduate.
237
+ if (options.mode.kind === 'promote') {
238
+ if (!isPublished(options.mode.target.name, options.mode.target.version)) {
239
+ console.error(
240
+ `✗ Cannot promote: ${options.mode.target.name}@${options.mode.target.version} is not on npm.`,
241
+ );
242
+ process.exit(1);
243
+ }
244
+ }
245
+
246
+ // Preflight cascade: if only module manifests drifted (the common
247
+ // case), offer to auto-touch + commit + continue. For anything else,
248
+ // print and exit.
249
+ const preflight = runPreflight();
250
+ const skipWorkspaceStale = options.mode.kind !== 'normal';
251
+ const skipModuleStale = options.mode.kind === 'alpha';
252
+ const blocking =
253
+ preflight.dirty ||
254
+ (!skipWorkspaceStale && preflight.workspaceStale.length > 0) ||
255
+ (!skipModuleStale && preflight.moduleStale.length > 0);
256
+
257
+ if (blocking && !options.allowStale) {
258
+ const onlyModuleDrift =
259
+ !skipModuleStale &&
260
+ !preflight.dirty &&
261
+ preflight.workspaceStale.length === 0 &&
262
+ preflight.moduleStale.length > 0;
263
+
264
+ if (onlyModuleDrift) {
265
+ printPreflightReport(preflight);
266
+ const proceed = await confirm(
267
+ '\nAuto-touch the drifted manifests, commit, and continue with publish? [y/N] ',
268
+ );
269
+ if (!proceed) {
270
+ console.log(
271
+ '\nAborted. Run `bun run publish --release-touch` to touch without publishing.',
272
+ );
273
+ process.exit(1);
274
+ }
275
+ const { ok, recheck } = autoTouchAndCommit(preflight);
276
+ if (!ok) process.exit(1);
277
+ if (
278
+ recheck &&
279
+ (recheck.dirty || recheck.workspaceStale.length > 0 || recheck.moduleStale.length > 0)
280
+ ) {
281
+ console.error('\n✗ Preflight still has issues after auto-touch:');
282
+ printPreflightReport(recheck);
283
+ process.exit(1);
284
+ }
285
+ console.log(
286
+ `\n✓ Touched and committed ${preflight.moduleStale.length} manifest(s). Continuing with publish.\n`,
287
+ );
288
+ } else {
289
+ printPreflightReport(preflight);
290
+ process.exit(1);
291
+ }
292
+ } else if (blocking && options.allowStale) {
293
+ console.warn('\n⚠ Preflight issues detected; publishing anyway (--allow-stale):');
294
+ printPreflightReport(preflight);
295
+ }
296
+
297
+ // Changesets — Phase 4 of v2/PUBLILO_CLI.md. Only applies in normal
298
+ // mode: alpha publishes are throwaway prereleases (applying changesets
299
+ // would burn them on alpha publishes), and promote graduates an
300
+ // existing alpha rather than producing a new version. The
301
+ // --skip-changesets flag is the manual escape hatch.
302
+ if (options.mode.kind === 'normal' && !skipChangesets) {
303
+ const pending = listPendingChangesets();
304
+ if (pending.length > 0) {
305
+ printPendingChangesets(pending);
306
+ const apply = await confirm(
307
+ '\nApply changesets (bump versions, update changelogs, commit), then continue with publish? [Y/n] ',
308
+ );
309
+ if (!apply) {
310
+ console.log(
311
+ '\nAborted. Run with --skip-changesets to publish current package.json versions instead.',
312
+ );
313
+ process.exit(1);
314
+ }
315
+ try {
316
+ applyPendingChangesets(pending.length);
317
+ } catch (err) {
318
+ console.error(`\n✗ ${err instanceof Error ? err.message : String(err)}`);
319
+ process.exit(1);
320
+ }
321
+ // Re-run preflight on the post-bump tree so we don't proceed
322
+ // with an unexpected state (e.g. changeset version left
323
+ // something dirty that wasn't picked up by our git-add).
324
+ const recheck = runPreflight();
325
+ if (recheck.dirty) {
326
+ console.error('\n✗ Tree is dirty after applying changesets:');
327
+ printPreflightReport(recheck);
328
+ process.exit(1);
329
+ }
330
+ console.log(`\n✓ Applied ${pending.length} changeset(s). Continuing with publish.\n`);
331
+ }
332
+ }
333
+
334
+ const plan = planPublish(options);
335
+ displayPlan(plan);
336
+
337
+ // Alpha mode might have auto-skipped every package — bail before
338
+ // prompting for confirmation we don't need.
339
+ if (options.mode.kind === 'alpha' && plan.workspace.every((p) => p.skipReason !== undefined)) {
340
+ console.log('All packages skipped (no source changes since last alpha). Nothing to do.');
341
+ return;
342
+ }
343
+
344
+ if (!(await confirm('Proceed? [y/N] '))) {
345
+ console.log('Aborted.');
346
+ process.exit(0);
347
+ }
348
+
349
+ const result = await executePlan({ plan, confirm });
350
+
351
+ console.log('\n──────────────────────────────────────────────');
352
+ console.log(' Publish summary');
353
+ console.log('──────────────────────────────────────────────');
354
+ if (result.published.length) {
355
+ console.log('Published:');
356
+ for (const p of result.published) console.log(` ✓ ${p.name}@${p.version}`);
357
+ }
358
+ if (result.skipped.length) {
359
+ console.log('Skipped:');
360
+ for (const s of result.skipped) console.log(` - ${s}`);
361
+ }
362
+ if (!result.published.length && !result.skipped.length) {
363
+ console.log('Nothing to do.');
364
+ }
365
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Module-registry planner test — Phase 4 is the slimmest publish
3
+ * phase (revision selection lives inside `celilo module publish`),
4
+ * so the planner is a thin wrap around `listModuleDirs`. We test
5
+ * the pure inner `mapModuleDirsToItems` directly so the test surface
6
+ * doesn't need to mock `./helpers` — mocking that path leaks to
7
+ * unrelated tests via bun:test's process-global mock.module.
8
+ */
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+ import { mapModuleDirsToItems } from './module-registry';
12
+
13
+ describe('mapModuleDirsToItems', () => {
14
+ test('empty input → empty plan', () => {
15
+ expect(mapModuleDirsToItems([])).toEqual([]);
16
+ });
17
+
18
+ test('wraps each discovered module dir in a ModuleItem', () => {
19
+ expect(
20
+ mapModuleDirsToItems([
21
+ '/repo/modules/caddy',
22
+ '/repo/modules/homebridge',
23
+ '/repo/modules/dns-external',
24
+ ]),
25
+ ).toEqual([
26
+ { moduleDir: '/repo/modules/caddy' },
27
+ { moduleDir: '/repo/modules/homebridge' },
28
+ { moduleDir: '/repo/modules/dns-external' },
29
+ ]);
30
+ });
31
+
32
+ test('preserves input order', () => {
33
+ const result = mapModuleDirsToItems(['/repo/modules/z', '/repo/modules/a', '/repo/modules/m']);
34
+ expect(result.map((m) => m.moduleDir)).toEqual([
35
+ '/repo/modules/z',
36
+ '/repo/modules/a',
37
+ '/repo/modules/m',
38
+ ]);
39
+ });
40
+ });
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Module registry publish — Phase 4 of `celilo publish`.
3
+ *
4
+ * Phase 4 is the slimmest phase: revision selection and tarball
5
+ * construction live inside `celilo module publish` itself, so the plan
6
+ * only enumerates which module directories to ship. The executor
7
+ * shells out to a single `celilo module publish <dirs...>` invocation
8
+ * (using the local source, not the global install, so this script
9
+ * always runs the just-built CLI from this repo).
10
+ */
11
+
12
+ import { spawnSync } from 'node:child_process';
13
+ import { existsSync } from 'node:fs';
14
+ import { join, relative } from 'node:path';
15
+ import { REPO_ROOT, listModuleDirs } from './helpers';
16
+ import type { ModuleItem } from './types';
17
+
18
+ // ─── Planner ───────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * Pure wrapper: turn a list of module directories into the
22
+ * corresponding `ModuleItem[]`. Exists so the test surface can
23
+ * exercise the wrapping contract without mocking `listModuleDirs`
24
+ * (which lives in helpers.ts and is process-globally referenced by
25
+ * other modules — mocking it would leak to unrelated tests).
26
+ */
27
+ export function mapModuleDirsToItems(dirs: string[]): ModuleItem[] {
28
+ return dirs.map((moduleDir) => ({ moduleDir }));
29
+ }
30
+
31
+ export function planModulePublish(): ModuleItem[] {
32
+ return mapModuleDirsToItems(listModuleDirs());
33
+ }
34
+
35
+ // ─── Executor ──────────────────────────────────────────────────────
36
+
37
+ export function executeModulePublish(items: ModuleItem[], opts: { allowStale: boolean }): void {
38
+ if (items.length === 0) {
39
+ console.log('\nNo modules with manifest.yml found — skipping module-publish phase.');
40
+ return;
41
+ }
42
+
43
+ const modulesRoot = join(REPO_ROOT, 'modules');
44
+ if (!existsSync(modulesRoot)) {
45
+ console.log('\nNo modules/ directory at repo root — skipping module-publish phase.');
46
+ return;
47
+ }
48
+
49
+ console.log('\n──────────────────────────────────────────────');
50
+ console.log(' Publishing modules to celilo registry');
51
+ console.log('──────────────────────────────────────────────');
52
+ for (const m of items) console.log(` ${relative(REPO_ROOT, m.moduleDir)}`);
53
+ console.log();
54
+
55
+ const cliEntry = join(REPO_ROOT, 'apps', 'celilo', 'src', 'cli', 'index.ts');
56
+ const args = ['run', cliEntry, 'module', 'publish', ...items.map((m) => m.moduleDir)];
57
+ if (opts.allowStale) args.push('--allow-stale');
58
+
59
+ const r = spawnSync('bun', args, { cwd: REPO_ROOT, stdio: 'inherit' });
60
+ if (r.status !== 0) {
61
+ console.error(`\n✗ Module publish failed (exit ${r.status}). Aborting.`);
62
+ process.exit(r.status ?? 1);
63
+ }
64
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Umbrella planner — composes the per-phase planners into a single
3
+ * `PublishPlan`.
4
+ *
5
+ * The planner is the value-add of v2/PUBLILO_CLI.md Phase 2: it does
6
+ * all the world-reads (npm view, git log, fs walks) and produces a
7
+ * typed description of every side effect the executor will perform.
8
+ * Dry-run prints the plan and exits; a real publish confirms the plan
9
+ * and hands it to the executor.
10
+ *
11
+ * Per-phase planners are imported lazily where needed so unit tests
12
+ * can mock individual phases without dragging in the others' I/O.
13
+ */
14
+
15
+ import { planConsumerPins } from './consumer-pins';
16
+ import { planGlobalUpdate } from './global-install';
17
+ import { PACKAGES, buildWorkspaceVersionMap, currentGitHead } from './helpers';
18
+ import { planModulePublish } from './module-registry';
19
+ import { runPreflight } from './preflight';
20
+ import type { PublishOptions, PublishPlan } from './types';
21
+ import { planWorkspace } from './workspace';
22
+
23
+ /**
24
+ * Build the full publish plan. Reads from the world (preflight, npm,
25
+ * git, fs); doesn't mutate.
26
+ *
27
+ * Phase gating per the spec:
28
+ * - normal: all four phases planned.
29
+ * - alpha: workspace planned; consumer pins SKIPPED (consumers
30
+ * opt into @alpha manually); global update SKIPPED unless
31
+ * --track-alpha; module publish SKIPPED unless
32
+ * --alpha-modules.
33
+ * - promote: all four phases planned — this IS a real release.
34
+ *
35
+ * In alpha mode the consumer-pin / global-update / module-publish
36
+ * planners are simply not invoked, so their items stay empty in the
37
+ * returned plan. The executor reads the same flag combination to know
38
+ * which phases to actually run; the empty-list contract means "nothing
39
+ * to do here" either way.
40
+ */
41
+ export function planPublish(opts: PublishOptions): PublishPlan {
42
+ const preflight = runPreflight();
43
+ const baseWorkspaceVersions = buildWorkspaceVersionMap();
44
+ const gitHead = currentGitHead();
45
+
46
+ const workspaceResult = planWorkspace({
47
+ mode: opts.mode,
48
+ packages: PACKAGES,
49
+ baseWorkspaceVersions,
50
+ gitHead,
51
+ });
52
+
53
+ // PROJECTED publish list — what executeWorkspace would land on
54
+ // npm if no per-package skips happen at execution time. Used to
55
+ // seed the global-update plan with force-pin targets.
56
+ const projectedPublishes = workspaceResult.items
57
+ .filter((item) => !item.skipReason)
58
+ .map((item) => ({ name: item.name, version: item.versionToPublish }));
59
+
60
+ const runPhase2 = opts.mode.kind !== 'alpha';
61
+ const runPhase3 = opts.mode.kind !== 'alpha' || opts.mode.trackAlpha;
62
+ const runPhase4 =
63
+ opts.mode.kind === 'normal' ||
64
+ opts.mode.kind === 'promote' ||
65
+ (opts.mode.kind === 'alpha' && opts.mode.alphaModules);
66
+
67
+ return {
68
+ mode: opts.mode,
69
+ options: opts,
70
+ preflight,
71
+ workspace: workspaceResult.items,
72
+ consumerPins: runPhase2 ? planConsumerPins() : [],
73
+ globalUpdate: runPhase3
74
+ ? planGlobalUpdate({
75
+ justPublished: projectedPublishes,
76
+ trackAlpha: opts.mode.kind === 'alpha' && opts.mode.trackAlpha,
77
+ })
78
+ : [],
79
+ modulePublish: runPhase4 ? planModulePublish() : [],
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Print the plan for the operator. Called before any side effects (in
85
+ * both --dry-run and real publish), so confirmation is informed.
86
+ */
87
+ export function displayPlan(plan: PublishPlan): void {
88
+ const mode = plan.mode;
89
+ const planLabel =
90
+ mode.kind === 'alpha'
91
+ ? 'Alpha publish plan'
92
+ : mode.kind === 'promote'
93
+ ? 'Promote plan'
94
+ : 'Publish plan';
95
+ console.log(`${planLabel} (in this order):\n`);
96
+ for (const p of plan.workspace) {
97
+ const arrow =
98
+ mode.kind === 'promote'
99
+ ? `${mode.target.version} → ${p.versionToPublish}`
100
+ : p.versionToPublish === p.baseVersion
101
+ ? p.versionToPublish
102
+ : `${p.baseVersion} → ${p.versionToPublish}`;
103
+ const note = p.skipReason ? ` [skip: ${p.skipReason}]` : '';
104
+ console.log(` ${p.name.padEnd(32)} ${arrow} (${p.pkg})${note}`);
105
+ }
106
+ console.log();
107
+ }