@celilo/cli 2.3.0 → 3.1.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 +1 -1
- package/package.json +3 -3
- package/src/api/serve.ts +13 -1
- package/src/cli/commands/module-health.test.ts +35 -0
- package/src/cli/commands/module-health.ts +11 -3
- package/src/cli/commands/module-publish.test.ts +22 -0
- package/src/cli/commands/module-publish.ts +14 -12
- package/src/cli/commands/module-update.ts +69 -17
- package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
- package/src/cli/commands/module-upgrade.ts +43 -3
- package/src/cli/commands/publish/helpers.ts +4 -3
- package/src/cli/commands/publish/index.ts +13 -1
- 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 +16 -3
- 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 +14 -5
- package/src/cli/commands/system-update.ts +11 -6
- package/src/cli/completion.ts +10 -1
- package/src/cli/fuel-gauge.ts +12 -4
- package/src/cli/index.ts +12 -0
- package/src/cli/json-output.test.ts +81 -0
- package/src/cli/types.ts +9 -0
- package/src/hooks/broker.test.ts +9 -3
- package/src/hooks/capability-loader.ts +49 -1
- package/src/hooks/executor.test.ts +5 -2
- package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
- package/src/hooks/hook-jail-unreachability.test.ts +10 -2
- package/src/hooks/hook-trespass.test.ts +13 -4
- 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 +19 -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 +69 -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-business-baseline.ts +17 -0
- package/src/registry/client.test.ts +65 -0
- package/src/registry/client.ts +7 -7
- package/src/secrets/storage.test.ts +69 -3
- package/src/secrets/storage.ts +71 -1
- package/src/services/audit/health.test.ts +58 -0
- package/src/services/audit/health.ts +15 -3
- package/src/services/audit/index.test.ts +1 -1
- package/src/services/audit/interface-classification.test.ts +16 -5
- package/src/services/audit/interface-classification.ts +25 -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/build-bus/hook-dispatch-executor.test.ts +269 -0
- package/src/services/build-bus/hook-dispatch-mgmt.test.ts +102 -150
- 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 +106 -28
- 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/capability-compat.test.ts +90 -0
- package/src/services/capability-compat.ts +128 -0
- package/src/services/deploy-terraform.ts +38 -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 +37 -1
- package/src/services/fleet-checks.ts +31 -1
- package/src/services/health-runner.ts +43 -5
- package/src/services/module-deploy-prune.test.ts +89 -0
- package/src/services/module-deploy.ts +72 -125
- package/src/services/module-types-drift.test.ts +1 -1
- package/src/services/module-validator/capability-versions.test.ts +13 -2
- package/src/services/provider-converge.test.ts +209 -0
- package/src/services/provider-converge.ts +241 -0
- package/src/services/static-content-converge.test.ts +39 -0
- package/src/services/static-content-converge.ts +26 -5
- package/src/services/terraform-safety.test.ts +83 -0
- package/src/services/terraform-safety.ts +53 -0
- package/src/services/update/orchestrator.test.ts +1 -1
- package/tsconfig.json +2 -13
|
@@ -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
|
|
|
@@ -63,11 +63,24 @@ export interface PublishOptions {
|
|
|
63
63
|
mode: PublishMode;
|
|
64
64
|
/**
|
|
65
65
|
* Module ids to leave out of the module-registry sweep (`--skip-module
|
|
66
|
-
* <id>`, repeatable). For a module that cannot publish yet.
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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.
|
|
69
70
|
*/
|
|
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';
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
/**
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo subscribers install-daemon` — write a systemd unit (Linux) or
|
|
3
|
+
* launchd plist (macOS) that runs the build-bus webhook receiver
|
|
4
|
+
* (`celilo subscribers serve`) under supervision. Mirrors
|
|
5
|
+
* `celilo events install-daemon` for the dispatcher: default is a
|
|
6
|
+
* per-user unit; `--system` writes the system-scope unit that survives
|
|
7
|
+
* logout/reboot — the management-plane shape. Writes the file but
|
|
8
|
+
* doesn't touch supervisor state — the operator (or the celilo-mgmt
|
|
9
|
+
* Ansible role) runs the enable/bootstrap steps themselves.
|
|
10
|
+
*
|
|
11
|
+
* The rendered unit runs `subscribers serve --no-dispatch` by default:
|
|
12
|
+
* the standing dispatcher (celilo-events.service) owns dispatch on a
|
|
13
|
+
* management box, and a second dispatcher polling the same bus is the
|
|
14
|
+
* celilo#580/#610 failure. Pass --dispatch for a combined unit on a box
|
|
15
|
+
* that has no events daemon.
|
|
16
|
+
*
|
|
17
|
+
* celilo#1304: nothing installed the receiver anywhere, so celilo-mgr's
|
|
18
|
+
* on_upstream_publish self-update could never fire. This command is the
|
|
19
|
+
* missing installer.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
installReceiverDaemon,
|
|
24
|
+
planReceiverInstall,
|
|
25
|
+
uninstallReceiverDaemon,
|
|
26
|
+
} from '../../services/build-bus/receiver-daemon';
|
|
27
|
+
import type { CommandResult } from '../types';
|
|
28
|
+
import { resolveServeSecret } from './subscribers-serve';
|
|
29
|
+
|
|
30
|
+
function shortenPath(p: string): string {
|
|
31
|
+
return p.startsWith(process.env.HOME ?? '\0') ? p.replace(process.env.HOME ?? '', '~') : p;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function daemonScopeFrom(flags: Record<string, string | boolean>): 'user' | 'system' {
|
|
35
|
+
return flags.system === true ? 'system' : 'user';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function handleSubscribersInstallDaemon(
|
|
39
|
+
_args: string[],
|
|
40
|
+
flags: Record<string, string | boolean>,
|
|
41
|
+
env: Record<string, string | undefined> = process.env,
|
|
42
|
+
): Promise<CommandResult> {
|
|
43
|
+
try {
|
|
44
|
+
const options = {
|
|
45
|
+
celiloPath: typeof flags['celilo-path'] === 'string' ? flags['celilo-path'] : undefined,
|
|
46
|
+
port: flags.port ? Number(flags.port) : undefined,
|
|
47
|
+
// Same resolution as `serve`: the flag wins, CELILO_BUS_SECRET is
|
|
48
|
+
// the documented fallback (the doctor remediation names it), and
|
|
49
|
+
// null means the renderer refuses rather than invent a secret.
|
|
50
|
+
secret: resolveServeSecret(flags, env) ?? undefined,
|
|
51
|
+
scope: daemonScopeFrom(flags),
|
|
52
|
+
dispatch: flags.dispatch === true,
|
|
53
|
+
};
|
|
54
|
+
// --print: render-only, raw unit content on stdout. The celilo-mgmt
|
|
55
|
+
// Ansible role captures this and does the root-owned write itself
|
|
56
|
+
// (the deb wrapper runs celilo as the unprivileged celilo user, so
|
|
57
|
+
// a direct --system write would EACCES on /etc/systemd/system).
|
|
58
|
+
if (flags.print) {
|
|
59
|
+
const plan = planReceiverInstall(options);
|
|
60
|
+
return { success: true, message: plan.unitContent, rawOutput: true, data: plan };
|
|
61
|
+
}
|
|
62
|
+
const result = installReceiverDaemon(options);
|
|
63
|
+
const lines = [
|
|
64
|
+
`Wrote ${result.platform} ${result.scope} unit: ${shortenPath(result.unitPath)}`,
|
|
65
|
+
` celilo: ${shortenPath(result.celiloPath)}`,
|
|
66
|
+
` port: ${result.port}`,
|
|
67
|
+
` dispatch: ${result.dispatch ? 'combined (receiver + hooks)' : 'receiver-only (--no-dispatch)'}`,
|
|
68
|
+
...(result.runAsUser ? [` runs as: ${result.runAsUser}`] : []),
|
|
69
|
+
'',
|
|
70
|
+
'The unit embeds the shared secret the publisher must sign webhooks with —',
|
|
71
|
+
"register this host in the publisher's build-bus-subscribers.json with the",
|
|
72
|
+
'same value (`celilo subscribers add <this-host-url> --secret <same secret>`).',
|
|
73
|
+
'',
|
|
74
|
+
'Next steps (run these yourself — install does not touch supervisor state):',
|
|
75
|
+
...result.nextSteps.map((s) => ` ${s}`),
|
|
76
|
+
];
|
|
77
|
+
return { success: true, message: lines.join('\n'), data: result };
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return {
|
|
80
|
+
success: false,
|
|
81
|
+
error: err instanceof Error ? err.message : String(err),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function handleSubscribersUninstallDaemon(
|
|
87
|
+
_args: string[],
|
|
88
|
+
flags: Record<string, string | boolean>,
|
|
89
|
+
): Promise<CommandResult> {
|
|
90
|
+
try {
|
|
91
|
+
const result = uninstallReceiverDaemon({ scope: daemonScopeFrom(flags) });
|
|
92
|
+
const lines = [
|
|
93
|
+
result.removed
|
|
94
|
+
? `Removed unit: ${shortenPath(result.unitPath)}`
|
|
95
|
+
: `No unit file at ${shortenPath(result.unitPath)} — nothing to remove.`,
|
|
96
|
+
'',
|
|
97
|
+
'Next steps:',
|
|
98
|
+
...result.nextSteps.map((s) => ` ${s}`),
|
|
99
|
+
];
|
|
100
|
+
return { success: true, message: lines.join('\n'), data: result };
|
|
101
|
+
} catch (err) {
|
|
102
|
+
return {
|
|
103
|
+
success: false,
|
|
104
|
+
error: err instanceof Error ? err.message : String(err),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { resolveServeSecret } from './subscribers-serve';
|
|
3
|
+
|
|
4
|
+
describe('resolveServeSecret', () => {
|
|
5
|
+
it('prefers the --secret flag over the environment', () => {
|
|
6
|
+
expect(resolveServeSecret({ secret: 'flag-secret' }, { CELILO_BUS_SECRET: 'env-secret' })).toBe(
|
|
7
|
+
'flag-secret',
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('falls back to CELILO_BUS_SECRET when the flag is absent', () => {
|
|
12
|
+
expect(resolveServeSecret({}, { CELILO_BUS_SECRET: 'env-secret' })).toBe('env-secret');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('ignores an empty env value', () => {
|
|
16
|
+
expect(resolveServeSecret({}, { CELILO_BUS_SECRET: '' })).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('returns null when neither source has a secret', () => {
|
|
20
|
+
expect(resolveServeSecret({}, {})).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -16,6 +16,24 @@ import { startHookDispatcher } from '../../services/build-bus/hook-dispatcher';
|
|
|
16
16
|
import { startReceiverServer } from '../../services/build-bus/receiver-server';
|
|
17
17
|
import type { CommandResult } from '../types';
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the receiver's shared HMAC secret. The `--secret` flag wins;
|
|
21
|
+
* otherwise `CELILO_BUS_SECRET` from the environment — which is what
|
|
22
|
+
* the supervisor unit (celilo-build-bus-receiver.service / the launchd
|
|
23
|
+
* plist) sets, and what the doctor's build-bus-publishing remediation
|
|
24
|
+
* has always told operators to export. Returns null when neither has a
|
|
25
|
+
* usable value; the caller turns that into the flag-required error.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveServeSecret(
|
|
28
|
+
flags: Record<string, string | boolean>,
|
|
29
|
+
env: Record<string, string | undefined> = process.env,
|
|
30
|
+
): string | null {
|
|
31
|
+
if (typeof flags.secret === 'string' && flags.secret) return flags.secret;
|
|
32
|
+
const fromEnv = env.CELILO_BUS_SECRET;
|
|
33
|
+
if (typeof fromEnv === 'string' && fromEnv) return fromEnv;
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
19
37
|
export async function handleSubscribersServe(
|
|
20
38
|
_args: string[],
|
|
21
39
|
flags: Record<string, string | boolean>,
|
|
@@ -27,11 +45,11 @@ export async function handleSubscribersServe(
|
|
|
27
45
|
error: `Invalid --port value: ${flags.port}. Must be 1–65535.`,
|
|
28
46
|
};
|
|
29
47
|
}
|
|
30
|
-
const secret = flags
|
|
31
|
-
if (
|
|
48
|
+
const secret = resolveServeSecret(flags);
|
|
49
|
+
if (!secret) {
|
|
32
50
|
return {
|
|
33
51
|
success: false,
|
|
34
|
-
error: '--secret <shared-hmac-secret> is required.',
|
|
52
|
+
error: '--secret <shared-hmac-secret> is required (or export CELILO_BUS_SECRET).',
|
|
35
53
|
};
|
|
36
54
|
}
|
|
37
55
|
|
|
@@ -47,7 +65,7 @@ export async function handleSubscribersServe(
|
|
|
47
65
|
secret,
|
|
48
66
|
onEvent: dispatcher
|
|
49
67
|
? async (envelope) => {
|
|
50
|
-
// handleEvent returns
|
|
68
|
+
// handleEvent returns UpstreamHookResult[]; the receiver's
|
|
51
69
|
// onEvent contract is void | Promise<void>. Discard the
|
|
52
70
|
// array — the dispatcher logs its own outcomes.
|
|
53
71
|
await dispatcher.handleEvent(envelope.event);
|
|
@@ -154,8 +154,14 @@ function findMigrationsFolderSafe(): string | null {
|
|
|
154
154
|
* indicators (FuelGauge) inside the audit subroutines and routes
|
|
155
155
|
* progress messages to the caller instead — used by the TUI so
|
|
156
156
|
* audit progress appears inside the alt-screen render.
|
|
157
|
+
*
|
|
158
|
+
* `quiet` (when true) silences them outright: the caller is going to
|
|
159
|
+
* emit a JSON document on stdout, and a gauge's frames are not JSON
|
|
160
|
+
* (celilo#1362).
|
|
157
161
|
*/
|
|
158
|
-
async function buildAuditDeps(
|
|
162
|
+
async function buildAuditDeps(
|
|
163
|
+
options: { onProgress?: (msg: string) => void; quiet?: boolean } = {},
|
|
164
|
+
) {
|
|
159
165
|
const db = getDb();
|
|
160
166
|
|
|
161
167
|
const installed = db.select().from(modules).all();
|
|
@@ -210,7 +216,10 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
|
|
|
210
216
|
envVars: terraformEnvByModule.get(m.id) ?? {},
|
|
211
217
|
}));
|
|
212
218
|
|
|
213
|
-
const healthResults = await runAllHealthChecks(db, {
|
|
219
|
+
const healthResults = await runAllHealthChecks(db, {
|
|
220
|
+
onProgress: options.onProgress,
|
|
221
|
+
quiet: options.quiet,
|
|
222
|
+
});
|
|
214
223
|
|
|
215
224
|
// Module integrity. Shallow by default: the installed tree against its
|
|
216
225
|
// baseline, and the generated project against the installed tree. Both are
|
|
@@ -442,7 +451,7 @@ async function buildAuditDeps(onProgress?: (msg: string) => void) {
|
|
|
442
451
|
// still catching a transport that has genuinely stopped being readable.
|
|
443
452
|
staleAfterMs: 30 * 60_000,
|
|
444
453
|
},
|
|
445
|
-
trustedSources:
|
|
454
|
+
trustedSources: collectFirewallReach(db),
|
|
446
455
|
};
|
|
447
456
|
}
|
|
448
457
|
|
|
@@ -542,7 +551,7 @@ export async function handleSystemAudit(
|
|
|
542
551
|
// and so the spinner caption keeps changing during the
|
|
543
552
|
// parallel-audit phase (which doesn't emit its own).
|
|
544
553
|
onProgress('Reading system state…');
|
|
545
|
-
const deps = await buildAuditDeps(onProgress);
|
|
554
|
+
const deps = await buildAuditDeps({ onProgress });
|
|
546
555
|
onProgress('Analyzing drift across categories…');
|
|
547
556
|
return runAudit(deps, onCategory);
|
|
548
557
|
},
|
|
@@ -559,7 +568,7 @@ export async function handleSystemAudit(
|
|
|
559
568
|
|
|
560
569
|
let deps: Awaited<ReturnType<typeof buildAuditDeps>>;
|
|
561
570
|
try {
|
|
562
|
-
deps = await buildAuditDeps();
|
|
571
|
+
deps = await buildAuditDeps({ quiet: json });
|
|
563
572
|
} catch (err) {
|
|
564
573
|
return {
|
|
565
574
|
success: false,
|
|
@@ -286,7 +286,11 @@ async function buildSnapshots(
|
|
|
286
286
|
* - `health` calls `runModuleHealthCheck`.
|
|
287
287
|
* - `snapshotCeliloDb` calls `createSystemStateBackup`.
|
|
288
288
|
*/
|
|
289
|
-
function buildOps(
|
|
289
|
+
function buildOps(
|
|
290
|
+
registry: RegistryClient,
|
|
291
|
+
wasDeployed: Set<string>,
|
|
292
|
+
quiet: boolean,
|
|
293
|
+
): OrchestratorOps {
|
|
290
294
|
return {
|
|
291
295
|
backup: async (moduleId, _updateId) => {
|
|
292
296
|
// IMPORTED modules don't have running state to back up; skip
|
|
@@ -362,9 +366,10 @@ function buildOps(registry: RegistryClient, wasDeployed: Set<string>): Orchestra
|
|
|
362
366
|
return { status: 'no-checks', detail: 'not deployed; health check skipped' };
|
|
363
367
|
}
|
|
364
368
|
// We can call the existing health-runner directly today — that's
|
|
365
|
-
// already a clean service.
|
|
369
|
+
// already a clean service. quiet keeps the check's gauge and plain
|
|
370
|
+
// log lines off stdout when the caller asked for JSON (celilo#1362).
|
|
366
371
|
const db = getDb();
|
|
367
|
-
const r = await runModuleHealthCheck(id, db);
|
|
372
|
+
const r = await runModuleHealthCheck(id, db, { quiet });
|
|
368
373
|
return { status: r.status, detail: r.error };
|
|
369
374
|
},
|
|
370
375
|
snapshotCeliloDb: async (_updateId) => {
|
|
@@ -494,7 +499,7 @@ export async function handleSystemUpdate(
|
|
|
494
499
|
// Build audit deps. (Mostly mirrors system-audit.ts; could be refactored
|
|
495
500
|
// into a shared helper in Phase 5.)
|
|
496
501
|
const migrationsFolder = findMigrationsFolderSafe();
|
|
497
|
-
const healthResults = await runAllHealthChecks(db);
|
|
502
|
+
const healthResults = await runAllHealthChecks(db, { quiet: json });
|
|
498
503
|
|
|
499
504
|
// Module integrity, shallow. Local and milliseconds; the host plane is one
|
|
500
505
|
// SSH per system and belongs to `module verify --deep`.
|
|
@@ -619,7 +624,7 @@ export async function handleSystemUpdate(
|
|
|
619
624
|
// does no probing. `system audit` and the monitor sweep own this check.
|
|
620
625
|
diskSpace: { results: [] },
|
|
621
626
|
transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 },
|
|
622
|
-
trustedSources: { firewalls: [] },
|
|
627
|
+
trustedSources: { firewalls: [], unreachableFirewalls: [] },
|
|
623
628
|
};
|
|
624
629
|
|
|
625
630
|
if (dryRun) {
|
|
@@ -687,7 +692,7 @@ export async function handleSystemUpdate(
|
|
|
687
692
|
}
|
|
688
693
|
}
|
|
689
694
|
|
|
690
|
-
const ops = buildOps(registry, wasDeployed);
|
|
695
|
+
const ops = buildOps(registry, wasDeployed, json);
|
|
691
696
|
|
|
692
697
|
const result = await runSystemUpdate({
|
|
693
698
|
audit: auditDeps,
|
package/src/cli/completion.ts
CHANGED
|
@@ -515,7 +515,16 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
515
515
|
|
|
516
516
|
// Subscribers subcommands
|
|
517
517
|
if (command === 'subscribers' && currentIndex === 1) {
|
|
518
|
-
const subcommands = [
|
|
518
|
+
const subcommands = [
|
|
519
|
+
'list',
|
|
520
|
+
'add',
|
|
521
|
+
'remove',
|
|
522
|
+
'test',
|
|
523
|
+
'serve',
|
|
524
|
+
'status',
|
|
525
|
+
'install-daemon',
|
|
526
|
+
'uninstall-daemon',
|
|
527
|
+
];
|
|
519
528
|
return filterSuggestions(subcommands, args[1] || '');
|
|
520
529
|
}
|
|
521
530
|
|
package/src/cli/fuel-gauge.ts
CHANGED
|
@@ -56,11 +56,19 @@ export class FuelGauge {
|
|
|
56
56
|
private linesDrawn = 0; // Track exactly how many lines were drawn last frame
|
|
57
57
|
private startTime = Date.now();
|
|
58
58
|
private lastOutputTime = Date.now();
|
|
59
|
+
/**
|
|
60
|
+
* Whether this gauge may draw frames on its output stream. A redirected
|
|
61
|
+
* or piped stream is a data channel, not a terminal (celilo#699), so the
|
|
62
|
+
* gauge tracks state silently there instead of writing frames into
|
|
63
|
+
* whatever file or protocol lies downstream (celilo#1362).
|
|
64
|
+
*/
|
|
65
|
+
private readonly interactive: boolean;
|
|
59
66
|
|
|
60
67
|
constructor(title: string, options: FuelGaugeOptions = {}) {
|
|
61
68
|
this.title = title;
|
|
62
69
|
this.output = options.output || stdout;
|
|
63
70
|
this.skipAnimation = options.skipAnimation || false;
|
|
71
|
+
this.interactive = !this.skipAnimation && Boolean(this.output.isTTY);
|
|
64
72
|
this.onBackground = options.onBackground;
|
|
65
73
|
}
|
|
66
74
|
|
|
@@ -68,8 +76,8 @@ export class FuelGauge {
|
|
|
68
76
|
* Start the fuel-gauge animation
|
|
69
77
|
*/
|
|
70
78
|
start(): void {
|
|
71
|
-
if (this.skipAnimation) {
|
|
72
|
-
// Test mode: just track state
|
|
79
|
+
if (this.skipAnimation || !this.interactive) {
|
|
80
|
+
// Test mode or non-interactive output: just track state
|
|
73
81
|
this.running = true;
|
|
74
82
|
return;
|
|
75
83
|
}
|
|
@@ -188,7 +196,7 @@ export class FuelGauge {
|
|
|
188
196
|
|
|
189
197
|
this.running = false;
|
|
190
198
|
|
|
191
|
-
if (this.
|
|
199
|
+
if (!this.interactive) {
|
|
192
200
|
return;
|
|
193
201
|
}
|
|
194
202
|
|
|
@@ -233,7 +241,7 @@ export class FuelGauge {
|
|
|
233
241
|
stopSilent(): void {
|
|
234
242
|
if (!this.running) return;
|
|
235
243
|
this.running = false;
|
|
236
|
-
if (this.
|
|
244
|
+
if (!this.interactive) return;
|
|
237
245
|
const display = getActiveDisplay();
|
|
238
246
|
if (display) {
|
|
239
247
|
display.abandon();
|
package/src/cli/index.ts
CHANGED
|
@@ -115,6 +115,10 @@ import { handleServiceSetCredentials } from './commands/service-set-credentials'
|
|
|
115
115
|
import { handleServiceVerify } from './commands/service-verify';
|
|
116
116
|
import { handleStatus } from './commands/status';
|
|
117
117
|
import { handleSubscribersAdd } from './commands/subscribers-add';
|
|
118
|
+
import {
|
|
119
|
+
handleSubscribersInstallDaemon,
|
|
120
|
+
handleSubscribersUninstallDaemon,
|
|
121
|
+
} from './commands/subscribers-install-daemon';
|
|
118
122
|
import { handleSubscribersList } from './commands/subscribers-list';
|
|
119
123
|
import { handleSubscribersRemove } from './commands/subscribers-remove';
|
|
120
124
|
import { handleSubscribersServe } from './commands/subscribers-serve';
|
|
@@ -365,6 +369,10 @@ Subcommands:
|
|
|
365
369
|
test <url> Fire a synthetic event at a subscriber; report delivery
|
|
366
370
|
serve --secret <hmac-secret> Run the receiver daemon (verifies + emits to local bus)
|
|
367
371
|
[--port <p>] [--no-dispatch]
|
|
372
|
+
install-daemon --secret <s> Write a supervisor unit for the receiver (celilo#1304:
|
|
373
|
+
[--system] [--port <p>] nothing else installs it). Runs serve --no-dispatch so the
|
|
374
|
+
[--dispatch] [--print] standing dispatcher keeps owning hook delivery.
|
|
375
|
+
uninstall-daemon [--system] Remove the receiver supervisor unit
|
|
368
376
|
status Per-subscriber delivery history (success rate, recent failures)
|
|
369
377
|
--help, -h Show this help
|
|
370
378
|
|
|
@@ -1909,6 +1917,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1909
1917
|
return handleSubscribersTest(parsed.args);
|
|
1910
1918
|
case 'serve':
|
|
1911
1919
|
return handleSubscribersServe(parsed.args, parsed.flags);
|
|
1920
|
+
case 'install-daemon':
|
|
1921
|
+
return handleSubscribersInstallDaemon(parsed.args, parsed.flags);
|
|
1922
|
+
case 'uninstall-daemon':
|
|
1923
|
+
return handleSubscribersUninstallDaemon(parsed.args, parsed.flags);
|
|
1912
1924
|
case 'status':
|
|
1913
1925
|
return handleSubscribersStatus();
|
|
1914
1926
|
default:
|