@celilo/cli 2.2.1 → 3.0.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 (173) hide show
  1. package/CELILO_CORE_MODULES.md +2 -1
  2. package/CELILO_SUBSYSTEMS.md +1 -1
  3. package/README.md +1 -1
  4. package/drizzle/0032_module_jail_policies.sql +28 -0
  5. package/drizzle/0033_build_bus_hook_runs.sql +37 -0
  6. package/drizzle/meta/_journal.json +15 -1
  7. package/package.json +3 -3
  8. package/src/__integration__/container-services-cli.integration.test.ts +1 -1
  9. package/src/api/serve.ts +13 -1
  10. package/src/api/sessions.test.ts +3 -3
  11. package/src/api-clients/proxmox.ts +10 -7
  12. package/src/cli/backup-rename.test.ts +3 -3
  13. package/src/cli/cli.test.ts +1 -1
  14. package/src/cli/commands/events.test.ts +2 -2
  15. package/src/cli/commands/firewall-interface-list.test.ts +2 -2
  16. package/src/cli/commands/machine-list.test.ts +57 -0
  17. package/src/cli/commands/machine-list.ts +35 -4
  18. package/src/cli/commands/module-health.test.ts +35 -0
  19. package/src/cli/commands/module-health.ts +12 -3
  20. package/src/cli/commands/module-import-registry.test.ts +1 -1
  21. package/src/cli/commands/module-jail.test.ts +242 -0
  22. package/src/cli/commands/module-jail.ts +227 -0
  23. package/src/cli/commands/module-list-jail.test.ts +136 -0
  24. package/src/cli/commands/module-list.ts +30 -3
  25. package/src/cli/commands/module-publish.test.ts +28 -6
  26. package/src/cli/commands/module-publish.ts +14 -12
  27. package/src/cli/commands/module-update.test.ts +22 -7
  28. package/src/cli/commands/module-update.ts +73 -18
  29. package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
  30. package/src/cli/commands/module-upgrade.test.ts +115 -1
  31. package/src/cli/commands/module-upgrade.ts +66 -4
  32. package/src/cli/commands/module-verify.test.ts +1 -1
  33. package/src/cli/commands/publish/execute.ts +4 -1
  34. package/src/cli/commands/publish/helpers.ts +4 -3
  35. package/src/cli/commands/publish/index.ts +23 -1
  36. package/src/cli/commands/publish/module-registry.test.ts +24 -1
  37. package/src/cli/commands/publish/module-registry.ts +23 -4
  38. package/src/cli/commands/publish/plan.test.ts +64 -0
  39. package/src/cli/commands/publish/plan.ts +52 -19
  40. package/src/cli/commands/publish/types.ts +20 -0
  41. package/src/cli/commands/registry-owner.test.ts +1 -1
  42. package/src/cli/commands/registry-token.test.ts +1 -1
  43. package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
  44. package/src/cli/commands/subscribers-install-daemon.ts +107 -0
  45. package/src/cli/commands/subscribers-serve.test.ts +22 -0
  46. package/src/cli/commands/subscribers-serve.ts +22 -4
  47. package/src/cli/commands/system-audit.ts +34 -11
  48. package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
  49. package/src/cli/commands/system-doctor.test.ts +76 -10
  50. package/src/cli/commands/system-doctor.ts +34 -3
  51. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  52. package/src/cli/commands/system-update.ts +16 -6
  53. package/src/cli/completion.ts +18 -3
  54. package/src/cli/flag-surface-gate.test.ts +279 -0
  55. package/src/cli/fuel-gauge.ts +12 -4
  56. package/src/cli/index.ts +80 -2
  57. package/src/cli/json-output.test.ts +81 -0
  58. package/src/cli/parser.test.ts +37 -1
  59. package/src/cli/restore-command.test.ts +3 -3
  60. package/src/cli/restore-migration-failure.test.ts +2 -2
  61. package/src/cli/tui/audit-state.ts +2 -0
  62. package/src/cli/types.ts +9 -0
  63. package/src/config/paths.test.ts +19 -19
  64. package/src/db/client.test.ts +46 -1
  65. package/src/db/client.ts +26 -0
  66. package/src/db/schema.ts +63 -0
  67. package/src/hooks/broker.test.ts +9 -3
  68. package/src/hooks/capability-loader-firewall.test.ts +9 -1
  69. package/src/hooks/capability-loader.ts +8 -0
  70. package/src/hooks/executor.test.ts +91 -14
  71. package/src/hooks/executor.ts +31 -1
  72. package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
  73. package/src/hooks/hook-jail-unreachability.test.ts +27 -20
  74. package/src/hooks/hook-trespass.test.ts +22 -6
  75. package/src/hooks/jail.test.ts +105 -17
  76. package/src/hooks/jail.ts +47 -10
  77. package/src/hooks/run-named-hook.ts +19 -16
  78. package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
  79. package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
  80. package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
  81. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
  82. package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
  83. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
  84. package/src/hooks/test-fixtures/silent-hook.ts +0 -1
  85. package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
  86. package/src/hooks/test-fixtures/success-hook.ts +4 -4
  87. package/src/manifest/contracts/v1.ts +32 -14
  88. package/src/manifest/json-schema-roundtrip.test.ts +1 -1
  89. package/src/manifest/schema.ts +66 -16
  90. package/src/manifest/validate.test.ts +47 -0
  91. package/src/policy/capability-shape-baseline.ts +63 -21
  92. package/src/policy/capability-shape-drift.test.ts +53 -1
  93. package/src/policy/capability-shape.test.ts +105 -0
  94. package/src/policy/capability-shape.ts +283 -2
  95. package/src/policy/module-script-scan.ts +52 -68
  96. package/src/policy/no-hand-built-ssh.test.ts +5 -1
  97. package/src/policy/no-swallowed-refusal.test.ts +14 -14
  98. package/src/registry/client.test.ts +67 -2
  99. package/src/registry/client.ts +7 -7
  100. package/src/secrets/storage.test.ts +70 -4
  101. package/src/secrets/storage.ts +71 -1
  102. package/src/services/alerting/keys.test.ts +4 -0
  103. package/src/services/alerting/keys.ts +12 -2
  104. package/src/services/audit/health.test.ts +155 -2
  105. package/src/services/audit/health.ts +76 -2
  106. package/src/services/audit/index.test.ts +23 -1
  107. package/src/services/audit/index.ts +8 -2
  108. package/src/services/audit/interface-classification.test.ts +16 -5
  109. package/src/services/audit/interface-classification.ts +25 -2
  110. package/src/services/audit/jail-exemptions.test.ts +42 -0
  111. package/src/services/audit/jail-exemptions.ts +44 -0
  112. package/src/services/audit/module-integrity.test.ts +23 -1
  113. package/src/services/audit/module-integrity.ts +7 -2
  114. package/src/services/audit/module-versions.ts +5 -1
  115. package/src/services/audit/public-dns.test.ts +20 -0
  116. package/src/services/audit/public-dns.ts +7 -2
  117. package/src/services/audit/recurrence-gate.test.ts +225 -0
  118. package/src/services/audit/trusted-sources.test.ts +17 -0
  119. package/src/services/audit/trusted-sources.ts +21 -0
  120. package/src/services/audit/types.ts +2 -1
  121. package/src/services/backup-create.ts +9 -0
  122. package/src/services/backup-envelope-roundtrip.test.ts +1 -1
  123. package/src/services/backup-in-flight-refusal.test.ts +1 -1
  124. package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
  125. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +105 -105
  126. package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
  127. package/src/services/build-bus/hook-dispatch.test.ts +86 -116
  128. package/src/services/build-bus/hook-dispatch.ts +99 -121
  129. package/src/services/build-bus/hook-dispatcher.ts +143 -17
  130. package/src/services/build-bus/receiver-daemon.test.ts +189 -0
  131. package/src/services/build-bus/receiver-daemon.ts +355 -0
  132. package/src/services/build-bus/self-update.ts +156 -0
  133. package/src/services/bus-ensure-flow.test.ts +1 -1
  134. package/src/services/bus-interview-park.test.ts +2 -2
  135. package/src/services/bus-interview.test.ts +2 -2
  136. package/src/services/bus-secret-flow.test.ts +1 -1
  137. package/src/services/capability-compat.test.ts +90 -0
  138. package/src/services/capability-compat.ts +128 -0
  139. package/src/services/celilo-events.test.ts +1 -1
  140. package/src/services/celilo-mgmt-hooks.test.ts +23 -5
  141. package/src/services/container-service.test.ts +1 -1
  142. package/src/services/cross-module-read.test.ts +2 -2
  143. package/src/services/deploy-preflight.ts +7 -0
  144. package/src/services/deploy-terraform.ts +38 -1
  145. package/src/services/deployed-systems.ts +1 -1
  146. package/src/services/events-daemon.test.ts +57 -0
  147. package/src/services/events-daemon.ts +76 -0
  148. package/src/services/firewall-reach.ts +21 -8
  149. package/src/services/fleet-checks.test.ts +159 -4
  150. package/src/services/fleet-checks.ts +206 -3
  151. package/src/services/health-runner.test.ts +87 -2
  152. package/src/services/health-runner.ts +83 -16
  153. package/src/services/infrastructure-selector.test.ts +1 -1
  154. package/src/services/jail-exemptions.test.ts +125 -0
  155. package/src/services/jail-exemptions.ts +81 -0
  156. package/src/services/machine-pool.test.ts +1 -1
  157. package/src/services/module-deploy-prune.test.ts +89 -0
  158. package/src/services/module-deploy.ts +84 -129
  159. package/src/services/module-subscriptions.test.ts +2 -2
  160. package/src/services/module-types-drift.test.ts +1 -1
  161. package/src/services/module-validator/capability-versions.test.ts +13 -2
  162. package/src/services/network-discovery.test.ts +64 -1
  163. package/src/services/network-discovery.ts +30 -5
  164. package/src/services/responder-probe.test.ts +1 -1
  165. package/src/services/restore-from-file.test.ts +4 -4
  166. package/src/services/restore-preflight.test.ts +1 -1
  167. package/src/services/ssh-key-manager.test.ts +2 -2
  168. package/src/services/system-state-stage.test.ts +2 -2
  169. package/src/services/terraform-safety.test.ts +83 -0
  170. package/src/services/terraform-safety.ts +53 -0
  171. package/src/services/update/orchestrator.test.ts +3 -1
  172. package/src/test-utils/bus-responder.ts +1 -1
  173. package/tsconfig.json +2 -13
@@ -22,6 +22,7 @@ import type { ModuleManifest } from '../../manifest/schema';
22
22
  import { RegistryClient } from '../../registry/client';
23
23
  import { createModuleBackup, createSystemStateBackup } from '../../services/backup-create';
24
24
  import { getDefaultBackupStorage } from '../../services/backup-storage';
25
+ import { capabilityBlockersForManifest } from '../../services/capability-compat';
25
26
  import {
26
27
  type DeployPosture,
27
28
  type UpgradePolicy,
@@ -33,6 +34,7 @@ import { deployModule } from '../../services/module-deploy';
33
34
  import { getArg, getFlag } from '../parser';
34
35
  import { log } from '../prompts';
35
36
  import type { CommandResult } from '../types';
37
+ import { fetchTargetManifest } from './module-update';
36
38
  import { classifyVersionChange, fetchAndUpdate } from './module-update';
37
39
 
38
40
  const VALID_POLICIES: readonly UpgradePolicy[] = ['by-semver', 'always-safe', 'always-fast'];
@@ -127,6 +129,8 @@ export interface PollCandidate {
127
129
  /** Latest registry version, or null when the module isn't on the registry. */
128
130
  latest: string | null;
129
131
  autoUpgrade: boolean;
132
+ /** True when the module row carries state ERROR (a failed upgrade, celilo#1363). */
133
+ inError: boolean;
130
134
  }
131
135
 
132
136
  export interface PollTarget {
@@ -144,6 +148,10 @@ export function selectPollTargets(candidates: PollCandidate[]): PollTarget[] {
144
148
  const targets: PollTarget[] = [];
145
149
  for (const c of candidates) {
146
150
  if (!c.autoUpgrade || !c.latest) continue;
151
+ // A module in ERROR failed an upgrade already. Retrying it on every poll
152
+ // redeploys the same broken version indefinitely (celilo#1363), so skip it
153
+ // until an attended health check clears the state (healthy → VERIFIED).
154
+ if (c.inError) continue;
147
155
  const change = classifyVersionChange(c.installed, c.latest);
148
156
  if (change === 'up-to-date' || change === 'ahead') continue;
149
157
  targets.push({ moduleId: c.moduleId, from: c.installed, to: c.latest });
@@ -163,8 +171,10 @@ function resolveAutoUpgrade(moduleId: string): boolean {
163
171
  * Upgrade ONE module to a known target version: posture → update → backup →
164
172
  * deploy → verify. The shared core for both the single-module command and the
165
173
  * poll. `mod` is the current DB row; `targetVersion` is the registry latest.
174
+ * Exported for tests: the failure paths here are where the store diverges from
175
+ * reality (celilo#1363).
166
176
  */
167
- async function upgradeOneModule(
177
+ export async function upgradeOneModule(
168
178
  mod: typeof modules.$inferSelect,
169
179
  targetVersion: string,
170
180
  client: RegistryClient,
@@ -173,6 +183,28 @@ async function upgradeOneModule(
173
183
  ): Promise<CommandResult> {
174
184
  const moduleId = mod.id;
175
185
 
186
+ // GATE (celilo#1361): read the TARGET manifest BEFORE anything mutates, and
187
+ // refuse an upgrade whose capability requirements no deployed provider
188
+ // serves. Deploying first and failing in the module's publish hook is how
189
+ // celilo-website burned six release revisions (+1 through +6): each failure
190
+ // recorded the new version, re-triggered the poll, and changed nothing.
191
+ // A deferral changes no state and retries once the provider lands.
192
+ const targetManifestCheck = await fetchTargetManifest(client, moduleId, targetVersion);
193
+ if (targetManifestCheck.ok) {
194
+ const blockers = capabilityBlockersForManifest(db, targetManifestCheck.manifest);
195
+ if (blockers.length > 0) {
196
+ const why = blockers.map((b) => b.message).join('; ');
197
+ return {
198
+ success: false,
199
+ deferred: true,
200
+ error: `${moduleId} upgrade to ${targetVersion} deferred: the deployed providers cannot serve its capability requirements: ${why}. Upgrade the provider module first; the registry poll retries automatically.`,
201
+ details: blockers,
202
+ };
203
+ }
204
+ }
205
+ // A failed fetch here is NOT deferred — `fetchAndUpdate` below reports the
206
+ // same failure through its own channel with the real error text.
207
+
176
208
  // Update FIRST (refresh stored def). Every downstream decision — the
177
209
  // backup gate and the posture policy — must consult the TARGET version's
178
210
  // manifest, not the installed one. Gating on the pre-update manifest skipped
@@ -237,6 +269,19 @@ async function upgradeOneModule(
237
269
  // Deploy (idempotent).
238
270
  const deployed = await deployModule(moduleId, db, {});
239
271
  if (!deployed.success) {
272
+ // fetchAndUpdate already swapped the files and wrote the new version to
273
+ // the store, so the recorded version no longer matches anything verified.
274
+ // Record the failure in the state rather than leaving a stale VERIFIED
275
+ // beside the new version (celilo#1363, option A). An attended health check
276
+ // that passes moves ERROR → VERIFIED and clears the record.
277
+ db.update(modules)
278
+ .set({
279
+ state: 'ERROR',
280
+ errorMessage: `Upgrade to ${targetVersion} failed to deploy: ${deployed.error}`,
281
+ updatedAt: new Date(),
282
+ })
283
+ .where(eq(modules.id, moduleId))
284
+ .run();
240
285
  return {
241
286
  success: false,
242
287
  error: `Deploy failed for ${moduleId} (now at ${targetVersion}): ${deployed.error}`,
@@ -288,6 +333,7 @@ async function runRegistryPoll(
288
333
  installed: mod.version,
289
334
  latest: await latestRegistryVersion(client, mod.id),
290
335
  autoUpgrade: resolveAutoUpgrade(mod.id),
336
+ inError: mod.state === 'ERROR',
291
337
  });
292
338
  }
293
339
 
@@ -298,6 +344,7 @@ async function runRegistryPoll(
298
344
 
299
345
  log.info(`Registry poll: ${targets.length} module(s) to upgrade.`);
300
346
  const upgraded: string[] = [];
347
+ const deferred: string[] = [];
301
348
  const failed: string[] = [];
302
349
  // Serial, in installed order. (Strict provider-before-consumer ordering via
303
350
  // topologicalOrder is a refinement; the poll is idempotent + re-runs.)
@@ -305,14 +352,29 @@ async function runRegistryPoll(
305
352
  const mod = rowById.get(t.moduleId);
306
353
  if (!mod) continue;
307
354
  const result = await upgradeOneModule(mod, t.to, client, db, flags);
308
- if (result.success) upgraded.push(`${t.moduleId}→${t.to}`);
309
- else failed.push(`${t.moduleId}: ${result.error}`);
355
+ if (result.success) {
356
+ upgraded.push(`${t.moduleId}→${t.to}`);
357
+ } else if (result.deferred) {
358
+ // Expected steady state while a provider upgrade is still pending: the
359
+ // consumer retries next tick. Not a failure — celilo#1361.
360
+ deferred.push(`${t.moduleId}: ${result.error}`);
361
+ } else {
362
+ failed.push(`${t.moduleId}: ${result.error}`);
363
+ }
310
364
  }
311
365
 
312
366
  if (failed.length > 0) {
313
367
  return {
314
368
  success: false,
315
- error: `Registry poll: upgraded ${upgraded.length}, FAILED ${failed.length}:\n ${failed.join('\n ')}`,
369
+ error:
370
+ `Registry poll: upgraded ${upgraded.length}, deferred ${deferred.length}, FAILED ${failed.length}:\n` +
371
+ ` ${[...failed, ...(deferred.length > 0 ? [`(deferred) ${deferred.join('\n (deferred) ')}`] : [])].join('\n ')}`,
372
+ };
373
+ }
374
+ if (deferred.length > 0) {
375
+ return {
376
+ success: true,
377
+ message: `Registry poll: upgraded ${upgraded.length}, deferred ${deferred.length} (capability requirements not yet served — retries next poll):\n ${deferred.join('\n ')}`,
316
378
  };
317
379
  }
318
380
  return {
@@ -30,7 +30,7 @@ describe('moduleAudit (deprecation alias)', () => {
30
30
  afterEach(() => {
31
31
  rmSync(dataDir, { recursive: true, force: true });
32
32
  resetTestDbPath();
33
- if (originalDataDir === undefined) process.env.CELILO_DATA_DIR = undefined;
33
+ if (originalDataDir === undefined) delete process.env.CELILO_DATA_DIR;
34
34
  else process.env.CELILO_DATA_DIR = originalDataDir;
35
35
  });
36
36
 
@@ -63,7 +63,10 @@ export async function executePlan(input: ExecutePlanInput): Promise<PublishResul
63
63
  }
64
64
 
65
65
  if (plan.modulePublish.length > 0) {
66
- executeModulePublish(plan.modulePublish, { allowStale: plan.options.allowStale });
66
+ executeModulePublish(plan.modulePublish, {
67
+ allowStale: plan.options.allowStale,
68
+ skippedModules: plan.options.skippedModules,
69
+ });
67
70
  }
68
71
 
69
72
  return result;
@@ -91,8 +91,9 @@ export function buildWorkspaceVersionMap(): WorkspaceVersionMap {
91
91
  }
92
92
 
93
93
  /**
94
- * List every publishable module under modules/. Excludes `archive/`
95
- * (operator-confirmed: not publishable) and any dir lacking a
94
+ * List every publishable module under modules/. Excludes `__archive__/`
95
+ * (operator-confirmed: not publishable, and renamed with the dunder prefix so
96
+ * nothing mistakes it for a module) and any dir lacking a
96
97
  * manifest.yml.
97
98
  */
98
99
  export function listModuleDirs(): string[] {
@@ -100,7 +101,7 @@ export function listModuleDirs(): string[] {
100
101
  if (!existsSync(modulesRoot)) return [];
101
102
  const out: string[] = [];
102
103
  for (const name of readdirSync(modulesRoot)) {
103
- if (name === 'archive') continue;
104
+ if (name === '__archive__') continue;
104
105
  const dir = join(modulesRoot, name);
105
106
  let st: ReturnType<typeof statSync>;
106
107
  try {
@@ -20,6 +20,7 @@
20
20
  * celilo publish --release-touch # auto-fix module stale-drift
21
21
  * celilo publish --allow-stale # skip stale-version safeguards
22
22
  * celilo publish -y / --yes # auto-confirm prompts
23
+ * celilo publish --skip-module <id> # leave a module out of the registry sweep
23
24
  * celilo publish --alpha # X.Y.Z-alpha.N to @alpha
24
25
  * celilo publish --alpha --track-alpha # also force-pin alphas globally
25
26
  * celilo publish --alpha --alpha-modules # also publish module +N
@@ -119,7 +120,22 @@ export function parseOptions(argv: string[]): ParsedFlags {
119
120
  const trackAlphaFlag = argv.includes('--track-alpha');
120
121
  const alphaModulesFlag = argv.includes('--alpha-modules');
121
122
  const skipChangesets = argv.includes('--skip-changesets');
123
+ const skipModulesFlag = argv.includes('--skip-modules');
124
+ const modulesOnlyFlag = argv.includes('--modules-only');
125
+ if (skipModulesFlag && modulesOnlyFlag) {
126
+ console.error('✗ --skip-modules and --modules-only are mutually exclusive.');
127
+ process.exit(1);
128
+ }
122
129
  const promoteArg = takeFlagValue('--promote');
130
+ const skippedModules: string[] = [];
131
+ for (let i = argv.indexOf('--skip-module'); i >= 0; i = argv.indexOf('--skip-module', i + 1)) {
132
+ const id = argv[i + 1];
133
+ if (id === undefined || id.startsWith('-')) {
134
+ console.error('✗ --skip-module requires a module id (e.g. --skip-module homebridge)');
135
+ process.exit(1);
136
+ }
137
+ skippedModules.push(id);
138
+ }
123
139
 
124
140
  if (alphaFlag && promoteArg) {
125
141
  console.error('✗ --alpha and --promote are mutually exclusive.');
@@ -152,7 +168,13 @@ export function parseOptions(argv: string[]): ParsedFlags {
152
168
  }
153
169
 
154
170
  return {
155
- options: { allowStale, autoYes: yes, mode },
171
+ options: {
172
+ allowStale,
173
+ autoYes: yes,
174
+ mode,
175
+ skippedModules,
176
+ modulePhase: modulesOnlyFlag ? 'only' : skipModulesFlag ? 'skip' : 'run',
177
+ },
156
178
  dryRun,
157
179
  releaseTouch,
158
180
  skipChangesets,
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { describe, expect, test } from 'bun:test';
11
- import { mapModuleDirsToItems } from './module-registry';
11
+ import { applyModuleSkips, mapModuleDirsToItems } from './module-registry';
12
12
 
13
13
  describe('mapModuleDirsToItems', () => {
14
14
  test('empty input → empty plan', () => {
@@ -38,3 +38,26 @@ describe('mapModuleDirsToItems', () => {
38
38
  ]);
39
39
  });
40
40
  });
41
+
42
+ describe('applyModuleSkips', () => {
43
+ const items = mapModuleDirsToItems([
44
+ '/repo/modules/caddy',
45
+ '/repo/modules/burner',
46
+ '/repo/modules/homebridge',
47
+ ]);
48
+
49
+ test('no skips leaves the plan unchanged', () => {
50
+ expect(applyModuleSkips(items, [])).toEqual(items);
51
+ });
52
+
53
+ test('drops each named module and keeps the order of the rest', () => {
54
+ expect(applyModuleSkips(items, ['burner']).map((m) => m.moduleDir)).toEqual([
55
+ '/repo/modules/caddy',
56
+ '/repo/modules/homebridge',
57
+ ]);
58
+ });
59
+
60
+ test('a name that matches no module directory throws and names it', () => {
61
+ expect(() => applyModuleSkips(items, ['burnr'])).toThrow('burnr');
62
+ });
63
+ });
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { spawnSync } from 'node:child_process';
13
13
  import { existsSync } from 'node:fs';
14
- import { join, relative } from 'node:path';
14
+ import { basename, join, relative } from 'node:path';
15
15
  import { REPO_ROOT, listModuleDirs } from './helpers';
16
16
  import type { ModuleItem } from './types';
17
17
 
@@ -28,13 +28,31 @@ export function mapModuleDirsToItems(dirs: string[]): ModuleItem[] {
28
28
  return dirs.map((moduleDir) => ({ moduleDir }));
29
29
  }
30
30
 
31
- export function planModulePublish(): ModuleItem[] {
32
- return mapModuleDirsToItems(listModuleDirs());
31
+ /**
32
+ * Leave the modules named by `--skip-module` out of the sweep. A name
33
+ * that matches no module directory throws, so a typo cannot skip
34
+ * nothing and publish the module it meant to hold back.
35
+ */
36
+ export function applyModuleSkips(items: ModuleItem[], skippedModules: string[]): ModuleItem[] {
37
+ const known = new Set(items.map((m) => basename(m.moduleDir)));
38
+ const unknown = skippedModules.filter((id) => !known.has(id));
39
+ if (unknown.length > 0) {
40
+ throw new Error(`--skip-module names no module directory: ${unknown.join(', ')}`);
41
+ }
42
+ const skipped = new Set(skippedModules);
43
+ return items.filter((m) => !skipped.has(basename(m.moduleDir)));
44
+ }
45
+
46
+ export function planModulePublish(skippedModules: string[]): ModuleItem[] {
47
+ return applyModuleSkips(mapModuleDirsToItems(listModuleDirs()), skippedModules);
33
48
  }
34
49
 
35
50
  // ─── Executor ──────────────────────────────────────────────────────
36
51
 
37
- export function executeModulePublish(items: ModuleItem[], opts: { allowStale: boolean }): void {
52
+ export function executeModulePublish(
53
+ items: ModuleItem[],
54
+ opts: { allowStale: boolean; skippedModules: string[] },
55
+ ): void {
38
56
  if (items.length === 0) {
39
57
  console.log('\nNo modules with manifest.yml found — skipping module-publish phase.');
40
58
  return;
@@ -49,6 +67,7 @@ export function executeModulePublish(items: ModuleItem[], opts: { allowStale: bo
49
67
  console.log('\n──────────────────────────────────────────────');
50
68
  console.log(' Publishing modules to celilo registry');
51
69
  console.log('──────────────────────────────────────────────');
70
+ for (const id of opts.skippedModules) console.log(` modules/${id} (skipped: --skip-module)`);
52
71
  for (const m of items) console.log(` ${relative(REPO_ROOT, m.moduleDir)}`);
53
72
  console.log();
54
73
 
@@ -0,0 +1,64 @@
1
+ /**
2
+ * decidePhases — the pure phase-gating decision behind `celilo publish`.
3
+ *
4
+ * The module-phase flags (`--skip-modules` / `--modules-only`) exist so
5
+ * the release pipeline can deliver the CLI independently of the module
6
+ * sweep (celilo#1369). Their gating lives in one pure function so the
7
+ * combinations are testable without npm, git or the registry.
8
+ */
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+ import { decidePhases } from './plan';
12
+
13
+ describe('decidePhases', () => {
14
+ test('normal mode, default phase: everything runs', () => {
15
+ expect(decidePhases({ kind: 'normal' }, 'run')).toEqual({
16
+ workspace: true,
17
+ consumerPins: true,
18
+ globalUpdate: true,
19
+ modulePublish: true,
20
+ });
21
+ });
22
+
23
+ test('--skip-modules plans no module sweep, keeps the npm phases', () => {
24
+ expect(decidePhases({ kind: 'normal' }, 'skip')).toEqual({
25
+ workspace: true,
26
+ consumerPins: true,
27
+ globalUpdate: true,
28
+ modulePublish: false,
29
+ });
30
+ });
31
+
32
+ test('--modules-only plans the module sweep and nothing else', () => {
33
+ expect(decidePhases({ kind: 'normal' }, 'only')).toEqual({
34
+ workspace: false,
35
+ consumerPins: false,
36
+ globalUpdate: false,
37
+ modulePublish: true,
38
+ });
39
+ });
40
+
41
+ test('promote is a real release — same shape as normal', () => {
42
+ expect(
43
+ decidePhases({ kind: 'promote', target: { name: '@celilo/cli', version: '1.0.0' } }, 'run'),
44
+ ).toEqual(decidePhases({ kind: 'normal' }, 'run'));
45
+ });
46
+
47
+ test('alpha still suppresses pins and the sweep without --alpha-modules', () => {
48
+ expect(decidePhases({ kind: 'alpha', trackAlpha: false, alphaModules: false }, 'run')).toEqual({
49
+ workspace: true,
50
+ consumerPins: false,
51
+ globalUpdate: false,
52
+ modulePublish: false,
53
+ });
54
+ });
55
+
56
+ test('alpha with --track-alpha and --alpha-modules runs its phases', () => {
57
+ expect(decidePhases({ kind: 'alpha', trackAlpha: true, alphaModules: true }, 'run')).toEqual({
58
+ workspace: true,
59
+ consumerPins: false,
60
+ globalUpdate: true,
61
+ modulePublish: true,
62
+ });
63
+ });
64
+ });
@@ -17,9 +17,48 @@ import { planGlobalUpdate } from './global-install';
17
17
  import { buildWorkspaceVersionMap, currentGitHead, getPublishPackages } from './helpers';
18
18
  import { planModulePublish } from './module-registry';
19
19
  import { runPreflight } from './preflight';
20
- import type { PublishOptions, PublishPlan } from './types';
20
+ import type { PublishMode, PublishOptions, PublishPlan } from './types';
21
21
  import { planWorkspace } from './workspace';
22
22
 
23
+ /**
24
+ * Which publish phases run, from the mode and the module-phase flag.
25
+ * Pure so the flag combinations stay testable without npm, git or the
26
+ * registry.
27
+ *
28
+ * `modulePhase` only moves the module sweep: `skip` plans no sweep (the
29
+ * npm phases run alone), `only` plans the sweep alone (celilo#1369's
30
+ * release-pipeline split — CLI delivery must not depend on the sweep,
31
+ * so the sweep is run as its own invocation after delivery).
32
+ */
33
+ export interface PlannedPhases {
34
+ workspace: boolean;
35
+ consumerPins: boolean;
36
+ globalUpdate: boolean;
37
+ modulePublish: boolean;
38
+ }
39
+
40
+ export function decidePhases(
41
+ mode: PublishMode,
42
+ modulePhase: PublishOptions['modulePhase'],
43
+ ): PlannedPhases {
44
+ if (modulePhase === 'only') {
45
+ return { workspace: false, consumerPins: false, globalUpdate: false, modulePublish: true };
46
+ }
47
+ const runPhase2 = mode.kind !== 'alpha';
48
+ const runPhase3 = mode.kind !== 'alpha' || mode.trackAlpha;
49
+ const runModulePublish =
50
+ (mode.kind === 'normal' ||
51
+ mode.kind === 'promote' ||
52
+ (mode.kind === 'alpha' && mode.alphaModules)) &&
53
+ modulePhase !== 'skip';
54
+ return {
55
+ workspace: true,
56
+ consumerPins: runPhase2,
57
+ globalUpdate: runPhase3,
58
+ modulePublish: runModulePublish,
59
+ };
60
+ }
61
+
23
62
  /**
24
63
  * Build the full publish plan. Reads from the world (preflight, npm,
25
64
  * git, fs); doesn't mutate.
@@ -40,15 +79,16 @@ import { planWorkspace } from './workspace';
40
79
  */
41
80
  export function planPublish(opts: PublishOptions): PublishPlan {
42
81
  const preflight = runPreflight();
43
- const baseWorkspaceVersions = buildWorkspaceVersionMap();
44
- const gitHead = currentGitHead();
82
+ const phases = decidePhases(opts.mode, opts.modulePhase);
45
83
 
46
- const workspaceResult = planWorkspace({
47
- mode: opts.mode,
48
- packages: getPublishPackages(),
49
- baseWorkspaceVersions,
50
- gitHead,
51
- });
84
+ const workspaceResult = phases.workspace
85
+ ? planWorkspace({
86
+ mode: opts.mode,
87
+ packages: getPublishPackages(),
88
+ baseWorkspaceVersions: buildWorkspaceVersionMap(),
89
+ gitHead: currentGitHead(),
90
+ })
91
+ : { items: [] };
52
92
 
53
93
  // PROJECTED publish list — what executeWorkspace would land on
54
94
  // npm if no per-package skips happen at execution time. Used to
@@ -57,26 +97,19 @@ export function planPublish(opts: PublishOptions): PublishPlan {
57
97
  .filter((item) => !item.skipReason)
58
98
  .map((item) => ({ name: item.name, version: item.versionToPublish }));
59
99
 
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
100
  return {
68
101
  mode: opts.mode,
69
102
  options: opts,
70
103
  preflight,
71
104
  workspace: workspaceResult.items,
72
- consumerPins: runPhase2 ? planConsumerPins() : [],
73
- globalUpdate: runPhase3
105
+ consumerPins: phases.consumerPins ? planConsumerPins() : [],
106
+ globalUpdate: phases.globalUpdate
74
107
  ? planGlobalUpdate({
75
108
  justPublished: projectedPublishes,
76
109
  trackAlpha: opts.mode.kind === 'alpha' && opts.mode.trackAlpha,
77
110
  })
78
111
  : [],
79
- modulePublish: runPhase4 ? planModulePublish() : [],
112
+ modulePublish: phases.modulePublish ? planModulePublish(opts.skippedModules) : [],
80
113
  };
81
114
  }
82
115
 
@@ -61,6 +61,26 @@ export interface PublishOptions {
61
61
  allowStale: boolean;
62
62
  autoYes: boolean;
63
63
  mode: PublishMode;
64
+ /**
65
+ * Module ids to leave out of the module-registry sweep (`--skip-module
66
+ * <id>`, repeatable). For a module that cannot publish yet. A failed
67
+ * module no longer stops the sweep (celilo#1369), but it still fails
68
+ * the run at the end, so a module that cannot publish yet is held
69
+ * back with this instead.
70
+ */
71
+ skippedModules: string[];
72
+ /**
73
+ * Which part of the publish touches the module registry (`--modules-only`
74
+ * / `--skip-modules`). `run` is the default whole publish. `skip` runs
75
+ * the npm phases only; `only` runs the module sweep only.
76
+ *
77
+ * The release pipeline uses the split so CLI delivery (the .deb, the
78
+ * apt upload, the Forgejo release) does not depend on the module sweep
79
+ * succeeding: npm publishes first, the fleet delivery happens, and the
80
+ * module sweep runs last, where its failure reds the run without
81
+ * withholding the CLI (celilo#1369).
82
+ */
83
+ modulePhase: 'run' | 'skip' | 'only';
64
84
  }
65
85
 
66
86
  /**
@@ -45,7 +45,7 @@ describe('registry owner commands', () => {
45
45
  afterEach(async () => {
46
46
  fetchSpy.mockRestore();
47
47
  closeDb();
48
- process.env.CELILO_DATA_DIR = undefined;
48
+ delete process.env.CELILO_DATA_DIR;
49
49
  resetTestDbPath();
50
50
  if (existsSync(dataDir)) await rm(dataDir, { recursive: true, force: true });
51
51
  });
@@ -50,7 +50,7 @@ describe('registry token add/rm (F2 clobber fix)', () => {
50
50
 
51
51
  afterEach(async () => {
52
52
  closeDb();
53
- process.env.CELILO_DATA_DIR = undefined;
53
+ delete process.env.CELILO_DATA_DIR;
54
54
  resetTestDbPath();
55
55
  if (existsSync(dataDir)) {
56
56
  await rm(dataDir, { recursive: true, force: true });
@@ -0,0 +1,44 @@
1
+ import { afterAll, describe, expect, it } from 'bun:test';
2
+ import { mkdtempSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { handleSubscribersInstallDaemon } from './subscribers-install-daemon';
6
+
7
+ // resolveCeliloPath existence-checks explicit overrides, so the render
8
+ // needs a celilo path that exists — a fake binary in a temp dir.
9
+ const FAKE_CELOLILO = join(mkdtempSync(join(tmpdir(), 'celilo-fake-bin-')), 'celilo');
10
+ writeFileSync(FAKE_CELOLILO, '#!/bin/sh\n', { mode: 0o755 });
11
+
12
+ // --print renders without writing, so these tests never touch unit paths.
13
+ const PRINT_FLAGS = { print: true, 'celilo-path': FAKE_CELOLILO };
14
+
15
+ const SAVED_ENV = process.env.CELILO_BUS_SECRET;
16
+ afterAll(() => {
17
+ if (SAVED_ENV === undefined) delete process.env.CELILO_BUS_SECRET;
18
+ else process.env.CELILO_BUS_SECRET = SAVED_ENV;
19
+ });
20
+
21
+ describe('handleSubscribersInstallDaemon secret resolution', () => {
22
+ it('falls back to CELILO_BUS_SECRET when --secret is absent — the doctor remediation and the rendered unit both lean on that env var', async () => {
23
+ process.env.CELILO_BUS_SECRET = 'env-secret';
24
+ const result = await handleSubscribersInstallDaemon([], { ...PRINT_FLAGS });
25
+ if (!result.success) throw new Error(`expected success: ${result.error}`);
26
+ });
27
+
28
+ it('prefers the --secret flag over the environment', async () => {
29
+ process.env.CELILO_BUS_SECRET = 'env-secret';
30
+ const result = await handleSubscribersInstallDaemon([], {
31
+ ...PRINT_FLAGS,
32
+ secret: 'flag-secret',
33
+ });
34
+ if (!result.success) throw new Error(`expected success: ${result.error}`);
35
+ expect((result.data as { unitContent: string }).unitContent).toContain('flag-secret');
36
+ });
37
+
38
+ it('refuses to render when neither source has a secret', async () => {
39
+ delete process.env.CELILO_BUS_SECRET;
40
+ const result = await handleSubscribersInstallDaemon([], { ...PRINT_FLAGS });
41
+ if (result.success) throw new Error('expected failure');
42
+ expect(result.error).toContain('secret is required');
43
+ });
44
+ });