@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.
- package/CELILO_CORE_MODULES.md +2 -1
- package/CELILO_SUBSYSTEMS.md +1 -1
- package/README.md +1 -1
- package/drizzle/0032_module_jail_policies.sql +28 -0
- package/drizzle/0033_build_bus_hook_runs.sql +37 -0
- package/drizzle/meta/_journal.json +15 -1
- package/package.json +3 -3
- package/src/__integration__/container-services-cli.integration.test.ts +1 -1
- package/src/api/serve.ts +13 -1
- package/src/api/sessions.test.ts +3 -3
- package/src/api-clients/proxmox.ts +10 -7
- package/src/cli/backup-rename.test.ts +3 -3
- package/src/cli/cli.test.ts +1 -1
- package/src/cli/commands/events.test.ts +2 -2
- package/src/cli/commands/firewall-interface-list.test.ts +2 -2
- package/src/cli/commands/machine-list.test.ts +57 -0
- package/src/cli/commands/machine-list.ts +35 -4
- package/src/cli/commands/module-health.test.ts +35 -0
- package/src/cli/commands/module-health.ts +12 -3
- package/src/cli/commands/module-import-registry.test.ts +1 -1
- package/src/cli/commands/module-jail.test.ts +242 -0
- package/src/cli/commands/module-jail.ts +227 -0
- package/src/cli/commands/module-list-jail.test.ts +136 -0
- package/src/cli/commands/module-list.ts +30 -3
- package/src/cli/commands/module-publish.test.ts +28 -6
- package/src/cli/commands/module-publish.ts +14 -12
- package/src/cli/commands/module-update.test.ts +22 -7
- package/src/cli/commands/module-update.ts +73 -18
- package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
- package/src/cli/commands/module-upgrade.test.ts +115 -1
- package/src/cli/commands/module-upgrade.ts +66 -4
- package/src/cli/commands/module-verify.test.ts +1 -1
- package/src/cli/commands/publish/execute.ts +4 -1
- package/src/cli/commands/publish/helpers.ts +4 -3
- package/src/cli/commands/publish/index.ts +23 -1
- package/src/cli/commands/publish/module-registry.test.ts +24 -1
- package/src/cli/commands/publish/module-registry.ts +23 -4
- package/src/cli/commands/publish/plan.test.ts +64 -0
- package/src/cli/commands/publish/plan.ts +52 -19
- package/src/cli/commands/publish/types.ts +20 -0
- package/src/cli/commands/registry-owner.test.ts +1 -1
- package/src/cli/commands/registry-token.test.ts +1 -1
- package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
- package/src/cli/commands/subscribers-install-daemon.ts +107 -0
- package/src/cli/commands/subscribers-serve.test.ts +22 -0
- package/src/cli/commands/subscribers-serve.ts +22 -4
- package/src/cli/commands/system-audit.ts +34 -11
- package/src/cli/commands/system-doctor-remediation-gate.test.ts +295 -0
- package/src/cli/commands/system-doctor.test.ts +76 -10
- package/src/cli/commands/system-doctor.ts +34 -3
- package/src/cli/commands/system-init-deprecation.test.ts +1 -1
- package/src/cli/commands/system-update.ts +16 -6
- package/src/cli/completion.ts +18 -3
- package/src/cli/flag-surface-gate.test.ts +279 -0
- package/src/cli/fuel-gauge.ts +12 -4
- package/src/cli/index.ts +80 -2
- package/src/cli/json-output.test.ts +81 -0
- package/src/cli/parser.test.ts +37 -1
- package/src/cli/restore-command.test.ts +3 -3
- package/src/cli/restore-migration-failure.test.ts +2 -2
- package/src/cli/tui/audit-state.ts +2 -0
- package/src/cli/types.ts +9 -0
- package/src/config/paths.test.ts +19 -19
- package/src/db/client.test.ts +46 -1
- package/src/db/client.ts +26 -0
- package/src/db/schema.ts +63 -0
- package/src/hooks/broker.test.ts +9 -3
- package/src/hooks/capability-loader-firewall.test.ts +9 -1
- package/src/hooks/capability-loader.ts +8 -0
- package/src/hooks/executor.test.ts +91 -14
- package/src/hooks/executor.ts +31 -1
- package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
- package/src/hooks/hook-jail-unreachability.test.ts +27 -20
- package/src/hooks/hook-trespass.test.ts +22 -6
- package/src/hooks/jail.test.ts +105 -17
- package/src/hooks/jail.ts +47 -10
- package/src/hooks/run-named-hook.ts +19 -16
- package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
- package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
- package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
- package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
- package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
- package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
- package/src/hooks/test-fixtures/silent-hook.ts +0 -1
- package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
- package/src/hooks/test-fixtures/success-hook.ts +4 -4
- package/src/manifest/contracts/v1.ts +32 -14
- package/src/manifest/json-schema-roundtrip.test.ts +1 -1
- package/src/manifest/schema.ts +66 -16
- package/src/manifest/validate.test.ts +47 -0
- package/src/policy/capability-shape-baseline.ts +63 -21
- package/src/policy/capability-shape-drift.test.ts +53 -1
- package/src/policy/capability-shape.test.ts +105 -0
- package/src/policy/capability-shape.ts +283 -2
- package/src/policy/module-script-scan.ts +52 -68
- package/src/policy/no-hand-built-ssh.test.ts +5 -1
- package/src/policy/no-swallowed-refusal.test.ts +14 -14
- package/src/registry/client.test.ts +67 -2
- package/src/registry/client.ts +7 -7
- package/src/secrets/storage.test.ts +70 -4
- package/src/secrets/storage.ts +71 -1
- package/src/services/alerting/keys.test.ts +4 -0
- package/src/services/alerting/keys.ts +12 -2
- package/src/services/audit/health.test.ts +155 -2
- package/src/services/audit/health.ts +76 -2
- package/src/services/audit/index.test.ts +23 -1
- package/src/services/audit/index.ts +8 -2
- package/src/services/audit/interface-classification.test.ts +16 -5
- package/src/services/audit/interface-classification.ts +25 -2
- package/src/services/audit/jail-exemptions.test.ts +42 -0
- package/src/services/audit/jail-exemptions.ts +44 -0
- package/src/services/audit/module-integrity.test.ts +23 -1
- package/src/services/audit/module-integrity.ts +7 -2
- package/src/services/audit/module-versions.ts +5 -1
- package/src/services/audit/public-dns.test.ts +20 -0
- package/src/services/audit/public-dns.ts +7 -2
- package/src/services/audit/recurrence-gate.test.ts +225 -0
- package/src/services/audit/trusted-sources.test.ts +17 -0
- package/src/services/audit/trusted-sources.ts +21 -0
- package/src/services/audit/types.ts +2 -1
- package/src/services/backup-create.ts +9 -0
- package/src/services/backup-envelope-roundtrip.test.ts +1 -1
- package/src/services/backup-in-flight-refusal.test.ts +1 -1
- package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +105 -105
- package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
- package/src/services/build-bus/hook-dispatch.test.ts +86 -116
- package/src/services/build-bus/hook-dispatch.ts +99 -121
- package/src/services/build-bus/hook-dispatcher.ts +143 -17
- package/src/services/build-bus/receiver-daemon.test.ts +189 -0
- package/src/services/build-bus/receiver-daemon.ts +355 -0
- package/src/services/build-bus/self-update.ts +156 -0
- package/src/services/bus-ensure-flow.test.ts +1 -1
- package/src/services/bus-interview-park.test.ts +2 -2
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/capability-compat.test.ts +90 -0
- package/src/services/capability-compat.ts +128 -0
- package/src/services/celilo-events.test.ts +1 -1
- package/src/services/celilo-mgmt-hooks.test.ts +23 -5
- package/src/services/container-service.test.ts +1 -1
- package/src/services/cross-module-read.test.ts +2 -2
- package/src/services/deploy-preflight.ts +7 -0
- package/src/services/deploy-terraform.ts +38 -1
- package/src/services/deployed-systems.ts +1 -1
- package/src/services/events-daemon.test.ts +57 -0
- package/src/services/events-daemon.ts +76 -0
- package/src/services/firewall-reach.ts +21 -8
- package/src/services/fleet-checks.test.ts +159 -4
- package/src/services/fleet-checks.ts +206 -3
- package/src/services/health-runner.test.ts +87 -2
- package/src/services/health-runner.ts +83 -16
- package/src/services/infrastructure-selector.test.ts +1 -1
- package/src/services/jail-exemptions.test.ts +125 -0
- package/src/services/jail-exemptions.ts +81 -0
- package/src/services/machine-pool.test.ts +1 -1
- package/src/services/module-deploy-prune.test.ts +89 -0
- package/src/services/module-deploy.ts +84 -129
- package/src/services/module-subscriptions.test.ts +2 -2
- package/src/services/module-types-drift.test.ts +1 -1
- package/src/services/module-validator/capability-versions.test.ts +13 -2
- package/src/services/network-discovery.test.ts +64 -1
- package/src/services/network-discovery.ts +30 -5
- package/src/services/responder-probe.test.ts +1 -1
- package/src/services/restore-from-file.test.ts +4 -4
- package/src/services/restore-preflight.test.ts +1 -1
- package/src/services/ssh-key-manager.test.ts +2 -2
- package/src/services/system-state-stage.test.ts +2 -2
- package/src/services/terraform-safety.test.ts +83 -0
- package/src/services/terraform-safety.ts +53 -0
- package/src/services/update/orchestrator.test.ts +3 -1
- package/src/test-utils/bus-responder.ts +1 -1
- 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)
|
|
309
|
-
|
|
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:
|
|
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
|
|
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, {
|
|
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 `
|
|
95
|
-
* (operator-confirmed: not publishable
|
|
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 === '
|
|
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: {
|
|
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
|
-
|
|
32
|
-
|
|
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(
|
|
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
|
|
44
|
-
const gitHead = currentGitHead();
|
|
82
|
+
const phases = decidePhases(opts.mode, opts.modulePhase);
|
|
45
83
|
|
|
46
|
-
const workspaceResult =
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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:
|
|
73
|
-
globalUpdate:
|
|
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:
|
|
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
|
|
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
|
|
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
|
+
});
|