@celilo/cli 2.3.0 → 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 (90) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/package.json +3 -3
  3. package/src/api/serve.ts +13 -1
  4. package/src/cli/commands/module-health.test.ts +35 -0
  5. package/src/cli/commands/module-health.ts +11 -3
  6. package/src/cli/commands/module-publish.test.ts +22 -0
  7. package/src/cli/commands/module-publish.ts +14 -12
  8. package/src/cli/commands/module-update.ts +69 -17
  9. package/src/cli/commands/module-upgrade-gate.test.ts +154 -0
  10. package/src/cli/commands/module-upgrade.ts +43 -3
  11. package/src/cli/commands/publish/helpers.ts +4 -3
  12. package/src/cli/commands/publish/index.ts +13 -1
  13. package/src/cli/commands/publish/plan.test.ts +64 -0
  14. package/src/cli/commands/publish/plan.ts +52 -19
  15. package/src/cli/commands/publish/types.ts +16 -3
  16. package/src/cli/commands/subscribers-install-daemon.test.ts +44 -0
  17. package/src/cli/commands/subscribers-install-daemon.ts +107 -0
  18. package/src/cli/commands/subscribers-serve.test.ts +22 -0
  19. package/src/cli/commands/subscribers-serve.ts +22 -4
  20. package/src/cli/commands/system-audit.ts +14 -5
  21. package/src/cli/commands/system-update.ts +11 -6
  22. package/src/cli/completion.ts +10 -1
  23. package/src/cli/fuel-gauge.ts +12 -4
  24. package/src/cli/index.ts +12 -0
  25. package/src/cli/json-output.test.ts +81 -0
  26. package/src/cli/types.ts +9 -0
  27. package/src/hooks/broker.test.ts +9 -3
  28. package/src/hooks/executor.test.ts +5 -2
  29. package/src/hooks/hook-jail-toolchain-reach.test.ts +8 -3
  30. package/src/hooks/hook-jail-unreachability.test.ts +10 -2
  31. package/src/hooks/hook-trespass.test.ts +13 -4
  32. package/src/hooks/run-named-hook.ts +19 -16
  33. package/src/hooks/test-fixtures/artifact-writing-hook.ts +0 -1
  34. package/src/hooks/test-fixtures/capability-calling-hook.ts +20 -13
  35. package/src/hooks/test-fixtures/jail-probe-hook.ts +9 -1
  36. package/src/hooks/test-fixtures/jail-toolchain-hook.ts +7 -2
  37. package/src/hooks/test-fixtures/runaway-hook.ts +0 -1
  38. package/src/hooks/test-fixtures/sigterm-ignoring-hook.ts +0 -1
  39. package/src/hooks/test-fixtures/silent-hook.ts +0 -1
  40. package/src/hooks/test-fixtures/store-writing-hook.ts +20 -11
  41. package/src/hooks/test-fixtures/success-hook.ts +4 -4
  42. package/src/manifest/contracts/v1.ts +19 -14
  43. package/src/manifest/json-schema-roundtrip.test.ts +1 -1
  44. package/src/manifest/schema.ts +66 -16
  45. package/src/manifest/validate.test.ts +47 -0
  46. package/src/policy/capability-shape-baseline.ts +63 -21
  47. package/src/policy/capability-shape-drift.test.ts +53 -1
  48. package/src/policy/capability-shape.test.ts +105 -0
  49. package/src/policy/capability-shape.ts +283 -2
  50. package/src/registry/client.test.ts +65 -0
  51. package/src/registry/client.ts +7 -7
  52. package/src/secrets/storage.test.ts +69 -3
  53. package/src/secrets/storage.ts +71 -1
  54. package/src/services/audit/health.test.ts +58 -0
  55. package/src/services/audit/health.ts +15 -3
  56. package/src/services/audit/index.test.ts +1 -1
  57. package/src/services/audit/interface-classification.test.ts +16 -5
  58. package/src/services/audit/interface-classification.ts +25 -2
  59. package/src/services/audit/module-versions.ts +5 -1
  60. package/src/services/audit/public-dns.test.ts +20 -0
  61. package/src/services/audit/public-dns.ts +7 -2
  62. package/src/services/audit/recurrence-gate.test.ts +225 -0
  63. package/src/services/audit/trusted-sources.test.ts +17 -0
  64. package/src/services/audit/trusted-sources.ts +21 -0
  65. package/src/services/build-bus/hook-dispatch-executor.test.ts +269 -0
  66. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +102 -150
  67. package/src/services/build-bus/hook-dispatch-path.test.ts +95 -0
  68. package/src/services/build-bus/hook-dispatch.test.ts +86 -116
  69. package/src/services/build-bus/hook-dispatch.ts +99 -121
  70. package/src/services/build-bus/hook-dispatcher.ts +106 -28
  71. package/src/services/build-bus/receiver-daemon.test.ts +189 -0
  72. package/src/services/build-bus/receiver-daemon.ts +355 -0
  73. package/src/services/build-bus/self-update.ts +156 -0
  74. package/src/services/capability-compat.test.ts +90 -0
  75. package/src/services/capability-compat.ts +128 -0
  76. package/src/services/deploy-terraform.ts +38 -1
  77. package/src/services/events-daemon.test.ts +57 -0
  78. package/src/services/events-daemon.ts +76 -0
  79. package/src/services/firewall-reach.ts +21 -8
  80. package/src/services/fleet-checks.test.ts +37 -1
  81. package/src/services/fleet-checks.ts +31 -1
  82. package/src/services/health-runner.ts +43 -5
  83. package/src/services/module-deploy-prune.test.ts +89 -0
  84. package/src/services/module-deploy.ts +72 -125
  85. package/src/services/module-types-drift.test.ts +1 -1
  86. package/src/services/module-validator/capability-versions.test.ts +13 -2
  87. package/src/services/terraform-safety.test.ts +83 -0
  88. package/src/services/terraform-safety.ts +53 -0
  89. package/src/services/update/orchestrator.test.ts +1 -1
  90. 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 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(opts.skippedModules) : [],
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. The sweep
67
- * stops at its first failed module, so one such module would otherwise
68
- * take every module after it, and the release's .deb, down with it.
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.secret;
31
- if (typeof secret !== 'string' || !secret) {
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 HookRunResult[]; the receiver's
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(onProgress?: (msg: string) => void) {
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, { onProgress });
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: { firewalls: collectFirewallReach(db) },
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(registry: RegistryClient, wasDeployed: Set<string>): OrchestratorOps {
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,
@@ -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 = ['list', 'add', 'remove', 'test', 'serve', 'status'];
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
 
@@ -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.skipAnimation) {
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.skipAnimation) return;
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: