@celilo/cli 0.21.0 → 0.23.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 +5 -4
- package/CELILO_SUBSYSTEMS.md +34 -2
- package/drizzle/0024_module_pause.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +5 -6
- package/src/__integration__/container-services-cli.integration.test.ts +8 -2
- package/src/api/remote-client.test.ts +6 -5
- package/src/api/serve.ts +41 -7
- package/src/api-clients/proxmox.ts +34 -0
- package/src/cli/commands/alerts-sweep.ts +2 -0
- package/src/cli/commands/events.test.ts +66 -0
- package/src/cli/commands/events.ts +106 -3
- package/src/cli/commands/module-deploy.ts +2 -2
- package/src/cli/commands/module-health.ts +1 -0
- package/src/cli/commands/module-import.ts +3 -3
- package/src/cli/commands/module-list.ts +12 -1
- package/src/cli/commands/module-pause.ts +317 -0
- package/src/cli/commands/module-remove.ts +78 -40
- package/src/cli/commands/module-status.ts +3 -4
- package/src/cli/commands/module-update.test.ts +1 -1
- package/src/cli/commands/proxmox-template-selection.ts +1 -1
- package/src/cli/commands/status.ts +25 -3
- package/src/cli/completion.ts +5 -0
- package/src/cli/fuel-gauge.ts +4 -4
- package/src/cli/index.ts +49 -20
- package/src/cli/json-output.test.ts +162 -0
- package/src/cli/prompts.ts +53 -74
- package/src/cli/service-credential.ts +3 -3
- package/src/cli/stdout-is-undecorated.test.ts +94 -0
- package/src/cli/types.ts +7 -2
- package/src/db/schema.ts +73 -15
- package/src/hooks/run-named-hook.ts +28 -0
- package/src/services/alerting/suppression.test.ts +5 -0
- package/src/services/alerting/suppression.ts +18 -1
- package/src/services/alerting/sweep-runner.test.ts +1 -0
- package/src/services/alerting/sweep-runner.ts +11 -1
- package/src/services/bus-interview.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/dns-registrations.ts +12 -0
- package/src/services/fleet-checks.test.ts +46 -0
- package/src/services/fleet-checks.ts +63 -6
- package/src/services/module-deploy.ts +1 -1
- package/src/services/module-pause-observability.test.ts +224 -0
- package/src/services/module-pause-quiescence.test.ts +163 -0
- package/src/services/module-pause.test.ts +573 -0
- package/src/services/module-pause.ts +544 -0
- package/src/services/remove-guard.test.ts +175 -0
- package/src/services/remove-guard.ts +109 -0
- package/src/services/terminal-responder.ts +16 -16
- package/src/services/update/dep-graph.test.ts +33 -4
- package/src/services/update/dep-graph.ts +39 -17
- package/src/services/zone-detector.ts +2 -39
- package/src/test-utils/cli.ts +15 -14
- package/src/test-utils/integration-guard.ts +26 -0
- package/src/test-utils/setup-test-db.ts +13 -23
|
@@ -24,6 +24,11 @@ import {
|
|
|
24
24
|
} from '../../services/celilo-events';
|
|
25
25
|
import { getContainerService, getServiceCredentials } from '../../services/container-service';
|
|
26
26
|
import { completeOperation, failOperation, startOperation } from '../../services/module-operations';
|
|
27
|
+
import {
|
|
28
|
+
type DependentCandidate,
|
|
29
|
+
describeRemovalRefusal,
|
|
30
|
+
findRemovalBlockers,
|
|
31
|
+
} from '../../services/remove-guard';
|
|
27
32
|
import { cleanupWebRoutesForModule } from '../../services/web-route-cleanup';
|
|
28
33
|
import { getArg, hasFlag, validateRequiredArgs } from '../parser';
|
|
29
34
|
import { log } from '../prompts';
|
|
@@ -81,23 +86,26 @@ export async function handleModuleRemove(
|
|
|
81
86
|
.all();
|
|
82
87
|
|
|
83
88
|
if (providedCapabilities.length > 0) {
|
|
84
|
-
const providedNames =
|
|
89
|
+
const providedNames = providedCapabilities.map((c) => c.capabilityName);
|
|
85
90
|
const otherModules = db.select().from(modules).where(ne(modules.id, moduleId)).all();
|
|
86
91
|
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
92
|
+
const candidates: DependentCandidate[] = [];
|
|
93
|
+
for (const m of otherModules) {
|
|
94
|
+
const parsed = ModuleManifestSchema.safeParse(m.manifestData);
|
|
95
|
+
if (!parsed.success) continue;
|
|
96
|
+
candidates.push({
|
|
97
|
+
id: m.id,
|
|
98
|
+
manifest: parsed.data,
|
|
99
|
+
paused: m.state === 'PAUSED',
|
|
100
|
+
deployed: !(['IMPORTED', 'VALIDATED', 'CONFIGURED'] as string[]).includes(m.state),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const blockers = findRemovalBlockers(providedNames, candidates);
|
|
105
|
+
if (blockers.length > 0) {
|
|
98
106
|
return {
|
|
99
107
|
success: false,
|
|
100
|
-
error:
|
|
108
|
+
error: describeRemovalRefusal(moduleId, blockers),
|
|
101
109
|
};
|
|
102
110
|
}
|
|
103
111
|
}
|
|
@@ -163,9 +171,9 @@ async function performModuleRemove(
|
|
|
163
171
|
// prompt on hook failure.
|
|
164
172
|
const manifestForHook = module.manifestData as { hooks?: { on_uninstall?: unknown } } | undefined;
|
|
165
173
|
if (manifestForHook?.hooks?.on_uninstall) {
|
|
166
|
-
// Match build-stream's TTY detection: in non-TTY contexts (tests,
|
|
167
|
-
//
|
|
168
|
-
//
|
|
174
|
+
// Match build-stream's TTY detection: in non-TTY contexts (tests, pipes,
|
|
175
|
+
// CI) skipAnimation prevents the gauge's setInterval and raw-stdin
|
|
176
|
+
// handlers from blocking process exit.
|
|
169
177
|
const isInteractive = process.stdout.isTTY && process.stdin.isTTY;
|
|
170
178
|
const gauge = new FuelGauge(`${moduleId}: on_uninstall`, {
|
|
171
179
|
skipAnimation: !isInteractive,
|
|
@@ -173,35 +181,65 @@ async function performModuleRemove(
|
|
|
173
181
|
gauge.start();
|
|
174
182
|
const logger = createGaugeLogger(gauge, moduleId, 'on_uninstall');
|
|
175
183
|
const hookResult = await runNamedHook(moduleId, 'on_uninstall', db, logger, {});
|
|
184
|
+
|
|
176
185
|
if (hookResult.success) {
|
|
177
186
|
gauge.stop(true);
|
|
178
187
|
} else {
|
|
179
188
|
gauge.stop(false);
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
189
|
+
const reason = hookResult.error ?? 'unknown error';
|
|
190
|
+
|
|
191
|
+
if (!force) {
|
|
192
|
+
// STOP, and leave the module ERRORED. Do not ask.
|
|
193
|
+
//
|
|
194
|
+
// `on_uninstall` is what withdraws a module's cross-module state —
|
|
195
|
+
// caddy unexposes its ports and clears its Caddyfile, a firewall
|
|
196
|
+
// provider withdraws its forwards. If it failed, that state is still
|
|
197
|
+
// out there and we do not know how much of it the hook managed before
|
|
198
|
+
// dying. Deleting the module now orphans whatever is left, with no
|
|
199
|
+
// record of what to go clean up.
|
|
200
|
+
//
|
|
201
|
+
// This previously asked "continue removing anyway?" in a TTY and
|
|
202
|
+
// silently continued when not one. Both were wrong. The prompt offers
|
|
203
|
+
// the orphaning decision at the least informed possible moment — inside
|
|
204
|
+
// a Y/N, before anyone has looked at the machine — and the non-TTY
|
|
205
|
+
// default made orphaning the norm for every scripted removal.
|
|
206
|
+
//
|
|
207
|
+
// The module is marked ERROR rather than left reading INSTALLED,
|
|
208
|
+
// because the previous behaviour recorded the failure only in a
|
|
209
|
+
// `module_operations` row: `module list` and `system doctor` both
|
|
210
|
+
// showed the module as perfectly healthy while its teardown had failed.
|
|
211
|
+
//
|
|
212
|
+
// The way forward is deliberate: go look, clean up by hand, then
|
|
213
|
+
// re-run with --force, which means "I have remediated; drop the
|
|
214
|
+
// record."
|
|
215
|
+
db.update(modules)
|
|
216
|
+
.set({
|
|
217
|
+
state: 'ERROR',
|
|
218
|
+
errorMessage: `on_uninstall failed: ${reason}`,
|
|
219
|
+
updatedAt: new Date(),
|
|
220
|
+
})
|
|
221
|
+
.where(eq(modules.id, moduleId))
|
|
222
|
+
.run();
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
success: false,
|
|
226
|
+
error: [
|
|
227
|
+
`Cannot remove '${moduleId}': its on_uninstall hook failed, so cross-module state it registered (port forwards, DNS records, web routes) may still exist.`,
|
|
228
|
+
'',
|
|
229
|
+
` ${reason}`,
|
|
230
|
+
'',
|
|
231
|
+
`'${moduleId}' is now marked ERROR and has NOT been removed. Inspect what the hook left behind, clean it up, then re-run:`,
|
|
232
|
+
` celilo module remove ${moduleId} --force`,
|
|
233
|
+
'',
|
|
234
|
+
'--force means "I have remediated this by hand; delete the record anyway".',
|
|
235
|
+
].join('\n'),
|
|
236
|
+
};
|
|
204
237
|
}
|
|
238
|
+
|
|
239
|
+
log.warn(`on_uninstall failed: ${reason}`);
|
|
240
|
+
log.info(
|
|
241
|
+
'--force set: continuing removal. Any state the hook left behind is yours to clean up.',
|
|
242
|
+
);
|
|
205
243
|
}
|
|
206
244
|
}
|
|
207
245
|
|
|
@@ -62,10 +62,9 @@ export async function handleModuleStatus(args: string[]): Promise<CommandResult>
|
|
|
62
62
|
.where(eq(capabilities.moduleId, moduleId))
|
|
63
63
|
.all();
|
|
64
64
|
|
|
65
|
-
// Build sections as multi-line blocks joined by \n\n.
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
// per-line extra spacing.
|
|
65
|
+
// Build sections as multi-line blocks joined by \n\n. index.ts writes the
|
|
66
|
+
// whole message to stdout verbatim, so the blank line between sections is
|
|
67
|
+
// exactly what the operator sees.
|
|
69
68
|
const sections: string[] = [];
|
|
70
69
|
|
|
71
70
|
// Section 1: Module metadata
|
|
@@ -143,7 +143,7 @@ description: fixture
|
|
|
143
143
|
});
|
|
144
144
|
|
|
145
145
|
test('quiet=true suppresses the per-call log lines (caller renders its own)', async () => {
|
|
146
|
-
// We can't easily intercept
|
|
146
|
+
// We can't easily intercept the log helpers' output without adding test
|
|
147
147
|
// hooks, so instead we exercise that the call simply succeeds and
|
|
148
148
|
// returns the structured outcome — the sweep relies on this to
|
|
149
149
|
// render its own output without duplicates. A non-quiet call
|
|
@@ -60,7 +60,7 @@ function compareDescending(a: ParsedUbuntu, b: ParsedUbuntu): number {
|
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
62
|
* Build the option list used by the select prompt and tests.
|
|
63
|
-
* Exported so tests can assert on filtering/sorting without
|
|
63
|
+
* Exported so tests can assert on filtering/sorting without driving a prompt.
|
|
64
64
|
*/
|
|
65
65
|
export function buildUbuntuOptions(appliances: ProxmoxAppliance[]): ParsedUbuntu[] {
|
|
66
66
|
return appliances
|
|
@@ -10,6 +10,7 @@ import { eq } from 'drizzle-orm';
|
|
|
10
10
|
import { getDb } from '../../db/client';
|
|
11
11
|
import { capabilities, moduleConfigs, modules, systemConfig, systemSecrets } from '../../db/schema';
|
|
12
12
|
import { type ModuleManifest, getSingularSystemSpec } from '../../manifest/schema';
|
|
13
|
+
import { formatPausedDuration } from '../../services/module-pause';
|
|
13
14
|
import type { CommandResult } from '../types';
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -26,10 +27,21 @@ async function determineModuleStatus(
|
|
|
26
27
|
generatedPath: string,
|
|
27
28
|
dbState: string,
|
|
28
29
|
): Promise<{
|
|
29
|
-
status:
|
|
30
|
+
status:
|
|
31
|
+
| 'IMPORTED'
|
|
32
|
+
| 'CONFIGURED'
|
|
33
|
+
| 'GENERATED'
|
|
34
|
+
| 'DEPLOYED'
|
|
35
|
+
| 'VERIFIED'
|
|
36
|
+
| 'NEEDS_UPDATE'
|
|
37
|
+
| 'PAUSED';
|
|
30
38
|
missingCount?: number;
|
|
31
39
|
}> {
|
|
32
|
-
// Deployed states come directly from the DB — don't infer from filesystem
|
|
40
|
+
// Deployed states come directly from the DB — don't infer from filesystem.
|
|
41
|
+
// PAUSED is checked FIRST: a paused module still has a generated/ directory
|
|
42
|
+
// and a full config, so every derivation below it would report it as an
|
|
43
|
+
// ordinary deployed module and the pause would be invisible here.
|
|
44
|
+
if (dbState === 'PAUSED') return { status: 'PAUSED' };
|
|
33
45
|
if (dbState === 'VERIFIED') return { status: 'VERIFIED' };
|
|
34
46
|
if (dbState === 'INSTALLED') return { status: 'DEPLOYED' };
|
|
35
47
|
|
|
@@ -120,6 +132,9 @@ export async function handleStatus(): Promise<CommandResult> {
|
|
|
120
132
|
);
|
|
121
133
|
|
|
122
134
|
// Status icon
|
|
135
|
+
// A paused module is deliberately out of service, so it must never
|
|
136
|
+
// carry the same ✓ as a healthy one — that is exactly how a pause turns
|
|
137
|
+
// into an outage nobody notices.
|
|
123
138
|
const icon =
|
|
124
139
|
statusInfo.status === 'VERIFIED' ||
|
|
125
140
|
statusInfo.status === 'DEPLOYED' ||
|
|
@@ -128,7 +143,14 @@ export async function handleStatus(): Promise<CommandResult> {
|
|
|
128
143
|
: '⚠';
|
|
129
144
|
|
|
130
145
|
lines.push(` ${icon} ${module.id} (v${module.version})`);
|
|
131
|
-
|
|
146
|
+
if (statusInfo.status === 'PAUSED') {
|
|
147
|
+
// State plus AGE — a pause with alerting suppressed is only safe if
|
|
148
|
+
// how long it has run is impossible to miss (design D7).
|
|
149
|
+
lines.push(` Status: PAUSED (${formatPausedDuration(module.pausedAt)})`);
|
|
150
|
+
if (module.pauseReason) lines.push(` Paused: ${module.pauseReason}`);
|
|
151
|
+
} else {
|
|
152
|
+
lines.push(` Status: ${statusInfo.status}`);
|
|
153
|
+
}
|
|
132
154
|
|
|
133
155
|
// Type: VPS or Container
|
|
134
156
|
const zone = getSingularSystemSpec(manifest)?.zone;
|
package/src/cli/completion.ts
CHANGED
|
@@ -113,6 +113,7 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
113
113
|
'list-subscribers',
|
|
114
114
|
'resync-subscriptions',
|
|
115
115
|
'list-pending',
|
|
116
|
+
'list-failed',
|
|
116
117
|
'list-unanswered',
|
|
117
118
|
'drain',
|
|
118
119
|
'run',
|
|
@@ -193,6 +194,8 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
193
194
|
'terraform-unlock',
|
|
194
195
|
'types',
|
|
195
196
|
'validate',
|
|
197
|
+
'pause',
|
|
198
|
+
'unpause',
|
|
196
199
|
];
|
|
197
200
|
return filterSuggestions(subcommands, args[1] || '');
|
|
198
201
|
}
|
|
@@ -436,6 +439,8 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
436
439
|
'terraform-unlock',
|
|
437
440
|
'verify',
|
|
438
441
|
'audit', // deprecation alias for `verify`
|
|
442
|
+
'pause',
|
|
443
|
+
'unpause',
|
|
439
444
|
];
|
|
440
445
|
if (moduleCommands.includes(args[1] || '')) {
|
|
441
446
|
const db = getDb();
|
package/src/cli/fuel-gauge.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { stdout } from 'node:process';
|
|
13
|
-
import
|
|
13
|
+
import { log } from '@celilo/cli-display';
|
|
14
14
|
import { getActiveDisplay } from './prompts';
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -173,7 +173,7 @@ export class FuelGauge {
|
|
|
173
173
|
if (!this.running) return;
|
|
174
174
|
|
|
175
175
|
this.cleanup();
|
|
176
|
-
|
|
176
|
+
log.info(`${this.title} (backgrounded)`);
|
|
177
177
|
|
|
178
178
|
if (this.onBackground) {
|
|
179
179
|
this.onBackground();
|
|
@@ -208,9 +208,9 @@ export class FuelGauge {
|
|
|
208
208
|
this.cleanup();
|
|
209
209
|
|
|
210
210
|
if (success) {
|
|
211
|
-
|
|
211
|
+
log.success(this.title);
|
|
212
212
|
} else {
|
|
213
|
-
|
|
213
|
+
log.error(this.title);
|
|
214
214
|
console.log('');
|
|
215
215
|
console.log(colors.dim('Last output:'));
|
|
216
216
|
const errorLines = this.outputLines.slice(-this.errorDisplayLines);
|
package/src/cli/index.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Orchestration function (Rule 10.1) - routes commands to handlers
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { log as uiLog } from '@celilo/cli-display';
|
|
7
8
|
import {
|
|
8
9
|
COMMANDS,
|
|
9
10
|
type CommandDef,
|
|
@@ -11,7 +12,6 @@ import {
|
|
|
11
12
|
resolveRemote,
|
|
12
13
|
runRemoteClient,
|
|
13
14
|
} from '@celilo/core';
|
|
14
|
-
import * as p from '@clack/prompts';
|
|
15
15
|
import { CLIServerRequestSchema, parseJsonWithValidation } from '../validation/schemas';
|
|
16
16
|
import {
|
|
17
17
|
handleApiAuthorizedKeys,
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
handleEventsEmit,
|
|
33
33
|
handleEventsFail,
|
|
34
34
|
handleEventsInstallDaemon,
|
|
35
|
+
handleEventsListFailed,
|
|
35
36
|
handleEventsListPending,
|
|
36
37
|
handleEventsListSubscribers,
|
|
37
38
|
handleEventsListUnanswered,
|
|
@@ -76,6 +77,7 @@ import { handleModuleJournal } from './commands/module-journal';
|
|
|
76
77
|
import { handleModuleList } from './commands/module-list';
|
|
77
78
|
import { handleModuleLogs } from './commands/module-logs';
|
|
78
79
|
import { handleModuleOperations } from './commands/module-operations';
|
|
80
|
+
import { handleModulePause, handleModuleUnpause } from './commands/module-pause';
|
|
79
81
|
import { handleModulePublish } from './commands/module-publish';
|
|
80
82
|
import { handleModuleRemove } from './commands/module-remove';
|
|
81
83
|
import { handleModuleSearch } from './commands/module-search';
|
|
@@ -300,6 +302,7 @@ Subcommands:
|
|
|
300
302
|
list-subscribers List persistent bus subscribers
|
|
301
303
|
resync-subscriptions Rebuild subscribers from deployed modules' manifests (after a restore/migration)
|
|
302
304
|
list-pending [--subscriber] List pending deliveries
|
|
305
|
+
list-failed [--subscriber] List failed/abandoned deliveries with a true total
|
|
303
306
|
drain [--concurrency N] Process pending deliveries once and return
|
|
304
307
|
run [--poll-ms N] Run the long-running dispatcher (foreground)
|
|
305
308
|
emit <type> [<payload>] Emit an event (operator/test path)
|
|
@@ -576,6 +579,20 @@ Subcommands:
|
|
|
576
579
|
|
|
577
580
|
remove <id> Remove a module and all its data
|
|
578
581
|
|
|
582
|
+
pause <id> Quiesce a module without uninstalling it (container keeps running)
|
|
583
|
+
Options:
|
|
584
|
+
--cascade Also pause every dependent module, consumers first
|
|
585
|
+
--stop-infra Also stop the module's container or service
|
|
586
|
+
--reason <text> Why the pause was taken, recorded on the module
|
|
587
|
+
--dry-run Print the ordered plan and change nothing
|
|
588
|
+
--yes Skip the cascade confirmation
|
|
589
|
+
|
|
590
|
+
unpause <id> Redeploy a paused module, rebinding it to current providers
|
|
591
|
+
Options:
|
|
592
|
+
--cascade Also unpause every dependent module, providers first
|
|
593
|
+
--dry-run Print the ordered plan and change nothing
|
|
594
|
+
--yes Skip the cascade confirmation
|
|
595
|
+
|
|
579
596
|
verify <id> Verify module integrity (signature + checksums)
|
|
580
597
|
(legacy alias: 'audit')
|
|
581
598
|
|
|
@@ -647,6 +664,12 @@ Examples:
|
|
|
647
664
|
celilo module publish ./modules/* # publish every module in a dir
|
|
648
665
|
celilo module list
|
|
649
666
|
celilo module remove homebridge
|
|
667
|
+
|
|
668
|
+
# Swap a capability provider (e.g. a replaced edge router):
|
|
669
|
+
celilo module pause --cascade greenwave --reason "ISP swapped the router"
|
|
670
|
+
celilo module remove greenwave
|
|
671
|
+
celilo module import axon && celilo module deploy axon
|
|
672
|
+
celilo module unpause --cascade axon
|
|
650
673
|
celilo module verify homebridge
|
|
651
674
|
celilo module config set homebridge hostname myhost
|
|
652
675
|
celilo module config set homebridge container_ip "192.168.0.110/24"
|
|
@@ -1395,8 +1418,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1395
1418
|
const { main: runPublish } = await import('./commands/publish');
|
|
1396
1419
|
await runPublish(publishArgv);
|
|
1397
1420
|
// runPublish handles its own console output (multi-phase, multi-line);
|
|
1398
|
-
// returning an empty success message tells the outer CLI loop to
|
|
1399
|
-
//
|
|
1421
|
+
// returning an empty success message tells the outer CLI loop to write
|
|
1422
|
+
// nothing more and exit 0 cleanly. Any failure inside runPublish
|
|
1400
1423
|
// calls process.exit() directly and never returns here.
|
|
1401
1424
|
return { success: true, message: '' };
|
|
1402
1425
|
}
|
|
@@ -1425,6 +1448,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1425
1448
|
return handleEventsResyncSubscriptions();
|
|
1426
1449
|
case 'list-pending':
|
|
1427
1450
|
return handleEventsListPending(parsed.args, parsed.flags);
|
|
1451
|
+
case 'list-failed':
|
|
1452
|
+
return handleEventsListFailed(parsed.args, parsed.flags);
|
|
1428
1453
|
case 'list-unanswered':
|
|
1429
1454
|
return handleEventsListUnanswered(parsed.args, parsed.flags);
|
|
1430
1455
|
case 'drain':
|
|
@@ -1495,6 +1520,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1495
1520
|
return handleModuleHealth(parsed.args, parsed.flags);
|
|
1496
1521
|
case 'operations':
|
|
1497
1522
|
return handleModuleOperations(parsed.args, parsed.flags);
|
|
1523
|
+
case 'pause':
|
|
1524
|
+
return handleModulePause(parsed.args, parsed.flags);
|
|
1525
|
+
case 'unpause':
|
|
1526
|
+
return handleModuleUnpause(parsed.args, parsed.flags);
|
|
1498
1527
|
case 'remove':
|
|
1499
1528
|
return handleModuleRemove(parsed.args, parsed.flags);
|
|
1500
1529
|
case 'update':
|
|
@@ -2461,31 +2490,31 @@ export async function main(): Promise<void> {
|
|
|
2461
2490
|
process.exit(0);
|
|
2462
2491
|
}
|
|
2463
2492
|
|
|
2464
|
-
// Script-friendly commands bypass clack and write directly to stdout
|
|
2465
|
-
if (result.rawOutput) {
|
|
2466
|
-
process.stdout.write(`${result.message}\n`);
|
|
2467
|
-
process.exit(0);
|
|
2468
|
-
}
|
|
2469
|
-
|
|
2470
2493
|
// Empty message → command already emitted its own output (e.g. via
|
|
2471
|
-
// ProgressDisplay) and doesn't want
|
|
2494
|
+
// ProgressDisplay) and doesn't want anything written on top.
|
|
2472
2495
|
if (!result.message) {
|
|
2473
2496
|
process.exit(0);
|
|
2474
2497
|
}
|
|
2475
2498
|
|
|
2476
|
-
//
|
|
2477
|
-
//
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2499
|
+
// A successful command's message IS its result, so it goes to stdout
|
|
2500
|
+
// verbatim — no glyph, no `│ ` prefix, no re-wrapping on `\n\n`.
|
|
2501
|
+
//
|
|
2502
|
+
// `rawOutput` no longer selects a different destination; it is kept
|
|
2503
|
+
// because it still records which commands are contractually
|
|
2504
|
+
// machine-readable, and because JSON payloads must never acquire a
|
|
2505
|
+
// decoration if this branch ever grows one again (celilo#698).
|
|
2506
|
+
//
|
|
2507
|
+
// The old path split the message on `\n\n` and fed each section to the
|
|
2508
|
+
// clack renderer, which prefixed every line. That is what made
|
|
2509
|
+
// `line.startsWith('<module-id> ')` over `celilo module list` match
|
|
2510
|
+
// nothing — read in celilo#695 as a missing module rather than as a
|
|
2511
|
+
// parse failure, at the cost of a full e2e run.
|
|
2512
|
+
process.stdout.write(`${result.message}\n`);
|
|
2485
2513
|
process.exit(0);
|
|
2486
2514
|
}
|
|
2487
2515
|
|
|
2488
|
-
|
|
2516
|
+
// Diagnostics go to stderr so a caller can separate them from the result.
|
|
2517
|
+
uiLog.error(`Error: ${result.error}`);
|
|
2489
2518
|
if (result.details) {
|
|
2490
2519
|
console.error('Details:', result.details);
|
|
2491
2520
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recurrence gate for celilo#698 — a command that emits JSON must emit JSON
|
|
3
|
+
* that parses, with no preprocessing.
|
|
4
|
+
*
|
|
5
|
+
* `cli/index.ts` already implements the rule: a `CommandResult` carrying
|
|
6
|
+
* `rawOutput: true` is written straight to stdout, while everything else goes
|
|
7
|
+
* through the decorating renderer that prefixes each line with `│ ` and wraps
|
|
8
|
+
* it in ANSI colour. A JSON payload that forgets the flag therefore reaches
|
|
9
|
+
* stdout as something no `JSON.parse` will accept — and the workaround
|
|
10
|
+
* (`| sed 's/\x1b\[[0-9;]*m//g'`) got written into CLAUDE.md instead of the fix.
|
|
11
|
+
*
|
|
12
|
+
* Two gates here, deliberately different in kind:
|
|
13
|
+
*
|
|
14
|
+
* 1. `emits parseable JSON` — spawns the REAL CLI and parses its raw stdout.
|
|
15
|
+
* This is the honest end-to-end check, and it is the one that fails today
|
|
16
|
+
* without the fix. It cannot use `CLIContext`: that harness drives the CLI
|
|
17
|
+
* in `CLI_SERVER_MODE`, which returns `result.message` over a protocol and
|
|
18
|
+
* never exercises the stdout renderer where the bug lives.
|
|
19
|
+
*
|
|
20
|
+
* 2. `every JSON CommandResult sets rawOutput` — a static scan, so a NEW
|
|
21
|
+
* command that forgets the flag fails even though nobody added it to the
|
|
22
|
+
* table above.
|
|
23
|
+
*
|
|
24
|
+
* Lives under `src/` rather than `test-integration/` for one blunt reason:
|
|
25
|
+
* `test-integration/` is run by no CI workflow (the `validate` job runs
|
|
26
|
+
* `test:unit`, which is `bun test src/`), so a gate placed there would never
|
|
27
|
+
* have failed anything. See celilo#703.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
31
|
+
import { spawnSync } from 'node:child_process';
|
|
32
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
import { type IntegrationTestContext, setupIntegrationTest } from '@/test-utils/integration';
|
|
35
|
+
|
|
36
|
+
const COMMANDS_DIR = join(import.meta.dir, 'commands');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Commands whose stdout is a JSON document. Each is spawned as a real process
|
|
40
|
+
* and its stdout handed to `JSON.parse` verbatim.
|
|
41
|
+
*
|
|
42
|
+
* `events` verbs share one `jsonResult()` helper, so a few representatives
|
|
43
|
+
* cover all 16 of its call sites; the rest are the distinct `--json` surfaces.
|
|
44
|
+
*/
|
|
45
|
+
const JSON_COMMANDS = [
|
|
46
|
+
'events status',
|
|
47
|
+
'events tail --limit 5',
|
|
48
|
+
'events list-subscribers',
|
|
49
|
+
'module list --json',
|
|
50
|
+
'module health --json',
|
|
51
|
+
'system audit --json',
|
|
52
|
+
'alerts list --json',
|
|
53
|
+
'commands --json',
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
describe('celilo#698 — JSON commands emit parseable JSON', () => {
|
|
57
|
+
let ctx: IntegrationTestContext;
|
|
58
|
+
|
|
59
|
+
beforeAll(async () => {
|
|
60
|
+
ctx = await setupIntegrationTest();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterAll(async () => {
|
|
64
|
+
await ctx.cleanup();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
for (const command of JSON_COMMANDS) {
|
|
68
|
+
test(`celilo ${command} emits parseable JSON`, () => {
|
|
69
|
+
const result = spawnSync('bun', ['run', 'src/cli/index.ts', ...command.split(' ')], {
|
|
70
|
+
encoding: 'utf8',
|
|
71
|
+
env: {
|
|
72
|
+
...process.env,
|
|
73
|
+
CELILO_DB_PATH: ctx.dbPath,
|
|
74
|
+
CELILO_DATA_DIR: ctx.dataDir,
|
|
75
|
+
CELILO_SUPPRESS_DEPRECATION: '1',
|
|
76
|
+
},
|
|
77
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
78
|
+
timeout: 60_000,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(
|
|
82
|
+
result.status,
|
|
83
|
+
`celilo ${command} exited ${result.status}\nstderr: ${result.stderr}`,
|
|
84
|
+
).toBe(0);
|
|
85
|
+
|
|
86
|
+
// Verbatim: no ANSI stripping, no prefix removal, no line filtering.
|
|
87
|
+
// If this throws, the payload went through the decorating renderer.
|
|
88
|
+
expect(() => JSON.parse(result.stdout)).not.toThrow();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Walk from the index of a `{` to the index just past its matching `}`,
|
|
95
|
+
* skipping over string literals so a brace inside a string doesn't unbalance
|
|
96
|
+
* the count.
|
|
97
|
+
*/
|
|
98
|
+
function objectEnd(source: string, open: number): number {
|
|
99
|
+
let depth = 0;
|
|
100
|
+
for (let i = open; i < source.length; i++) {
|
|
101
|
+
const char = source[i];
|
|
102
|
+
if (char === '{') {
|
|
103
|
+
depth++;
|
|
104
|
+
} else if (char === '}') {
|
|
105
|
+
depth--;
|
|
106
|
+
if (depth === 0) return i + 1;
|
|
107
|
+
} else if (char === '"' || char === "'" || char === '`') {
|
|
108
|
+
const quote = char;
|
|
109
|
+
i++;
|
|
110
|
+
while (i < source.length && source[i] !== quote) {
|
|
111
|
+
if (source[i] === '\\') i++;
|
|
112
|
+
i++;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return -1;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Index of the `{` opening the object literal that encloses `index`. */
|
|
120
|
+
function enclosingObjectStart(source: string, index: number): number {
|
|
121
|
+
let depth = 0;
|
|
122
|
+
for (let i = index; i >= 0; i--) {
|
|
123
|
+
if (source[i] === '}') {
|
|
124
|
+
depth++;
|
|
125
|
+
} else if (source[i] === '{') {
|
|
126
|
+
if (depth === 0) return i;
|
|
127
|
+
depth--;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return -1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
describe('celilo#698 recurrence gate — every JSON CommandResult sets rawOutput', () => {
|
|
134
|
+
const files = readdirSync(COMMANDS_DIR).filter(
|
|
135
|
+
(f) => f.endsWith('.ts') && !f.endsWith('.test.ts'),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
test('command files exist to scan', () => {
|
|
139
|
+
expect(files.length).toBeGreaterThan(0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
for (const file of files) {
|
|
143
|
+
test(`${file} sets rawOutput on every JSON message`, () => {
|
|
144
|
+
const source = readFileSync(join(COMMANDS_DIR, file), 'utf8');
|
|
145
|
+
const pattern = /\bmessage:\s*JSON\.stringify\b/g;
|
|
146
|
+
let match: RegExpExecArray | null = pattern.exec(source);
|
|
147
|
+
|
|
148
|
+
while (match !== null) {
|
|
149
|
+
const open = enclosingObjectStart(source, match.index);
|
|
150
|
+
const literal = open < 0 ? '' : source.slice(open, objectEnd(source, open));
|
|
151
|
+
const line = source.slice(0, match.index).split('\n').length;
|
|
152
|
+
|
|
153
|
+
expect(
|
|
154
|
+
/\brawOutput\b/.test(literal),
|
|
155
|
+
`${file}:${line} returns a JSON message without rawOutput: true. Without the flag the payload goes through the decorating renderer and no longer parses. Add \`rawOutput: true\` to the same result object.`,
|
|
156
|
+
).toBe(true);
|
|
157
|
+
|
|
158
|
+
match = pattern.exec(source);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
});
|