@celilo/cli 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CELILO_CORE_MODULES.md +2 -2
- package/CELILO_SUBSYSTEMS.md +61 -9
- package/drizzle/0024_module_pause.sql +20 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +7 -7
- 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-act.ts +1 -1
- package/src/cli/commands/alerts-sweep.ts +2 -0
- package/src/cli/commands/backup-create.ts +26 -11
- package/src/cli/commands/backup-list.test.ts +83 -0
- package/src/cli/commands/backup-list.ts +67 -3
- package/src/cli/commands/backup-prune.ts +17 -17
- package/src/cli/commands/backup-sweep.ts +20 -8
- package/src/cli/commands/events.ts +34 -3
- package/src/cli/commands/firewall-interface-list.test.ts +85 -0
- package/src/cli/commands/firewall-interface-list.ts +123 -0
- package/src/cli/commands/machine-add.ts +30 -2
- package/src/cli/commands/module-config.test.ts +64 -2
- package/src/cli/commands/module-config.ts +159 -8
- 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 +127 -4
- package/src/cli/commands/module-update.test.ts +1 -1
- package/src/cli/commands/monitor.ts +116 -19
- package/src/cli/commands/proxmox-template-selection.ts +1 -1
- package/src/cli/commands/status.ts +25 -3
- package/src/cli/commands/system-migrate.ts +14 -0
- package/src/cli/commands/system-update.ts +4 -1
- package/src/cli/completion.ts +39 -9
- package/src/cli/fuel-gauge.ts +4 -4
- package/src/cli/index.ts +104 -22
- 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/tui/audit-state.ts +2 -0
- package/src/cli/types.ts +7 -2
- package/src/db/schema.ts +73 -15
- package/src/hooks/capability-loader.ts +130 -4
- package/src/hooks/run-named-hook.ts +28 -0
- package/src/hooks/types.ts +2 -1
- package/src/manifest/contracts/v1.ts +16 -0
- package/src/manifest/schema.ts +40 -65
- package/src/services/alerting/builtin-monitors.test.ts +18 -10
- package/src/services/alerting/cadence-migration.test.ts +155 -0
- package/src/services/alerting/cadence-migration.ts +90 -0
- package/src/services/alerting/coverage-source.ts +8 -11
- package/src/services/alerting/deploy-hooks.test.ts +16 -7
- package/src/services/alerting/deploy-hooks.ts +11 -5
- package/src/services/alerting/health-cadence.test.ts +58 -0
- package/src/services/alerting/health-cadence.ts +128 -0
- package/src/services/alerting/health-coverage.ts +18 -8
- package/src/services/alerting/monitors.ts +50 -15
- 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 +52 -3
- package/src/services/alerting/sweep-runner.ts +41 -8
- package/src/services/audit/backup-source.ts +24 -1
- package/src/services/audit/backups.test.ts +95 -10
- package/src/services/audit/backups.ts +40 -37
- package/src/services/audit/interface-classification.test.ts +220 -0
- package/src/services/audit/interface-classification.ts +167 -0
- package/src/services/audit/types.ts +2 -1
- package/src/services/backup-age-agreement.test.ts +118 -0
- package/src/services/backup-create.ts +36 -30
- package/src/services/backup-metadata.ts +52 -1
- package/src/services/backup-retention.test.ts +123 -0
- package/src/services/backup-retention.ts +66 -5
- package/src/services/backup-schedule.test.ts +166 -0
- package/src/services/backup-schedule.ts +105 -15
- package/src/services/backup-staging.ts +14 -1
- package/src/services/backup-sweep.test.ts +22 -3
- package/src/services/backup-sweep.ts +15 -5
- package/src/services/bus-interview.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +1 -1
- package/src/services/cadence.test.ts +97 -0
- package/src/services/cadence.ts +165 -0
- package/src/services/fleet-checks.ts +48 -0
- package/src/services/machine-detector.ts +23 -1
- package/src/services/module-config.ts +33 -0
- 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/storage-providers/s3.test.ts +96 -13
- package/src/services/storage-providers/s3.ts +48 -15
- 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.test.ts +34 -3
- package/src/services/zone-detector.ts +32 -49
- 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
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,
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
handleEventsTail,
|
|
49
49
|
handleEventsUninstallDaemon,
|
|
50
50
|
} from './commands/events';
|
|
51
|
+
import { handleFirewallInterfaceList } from './commands/firewall-interface-list';
|
|
51
52
|
import { handleHookRun } from './commands/hook-run';
|
|
52
53
|
import {
|
|
53
54
|
handleIpamIpListReservations,
|
|
@@ -68,7 +69,11 @@ import { moduleAudit } from './commands/module-audit';
|
|
|
68
69
|
import { handleModuleBuild } from './commands/module-build';
|
|
69
70
|
import { handleModuleChangeset } from './commands/module-changeset';
|
|
70
71
|
import { handleModuleCheck } from './commands/module-check';
|
|
71
|
-
import {
|
|
72
|
+
import {
|
|
73
|
+
handleModuleConfigGet,
|
|
74
|
+
handleModuleConfigSet,
|
|
75
|
+
handleModuleConfigUnset,
|
|
76
|
+
} from './commands/module-config';
|
|
72
77
|
import { handleModuleDeploy } from './commands/module-deploy';
|
|
73
78
|
import { handleModuleGenerate } from './commands/module-generate';
|
|
74
79
|
import { handleModuleHealth } from './commands/module-health';
|
|
@@ -77,6 +82,7 @@ import { handleModuleJournal } from './commands/module-journal';
|
|
|
77
82
|
import { handleModuleList } from './commands/module-list';
|
|
78
83
|
import { handleModuleLogs } from './commands/module-logs';
|
|
79
84
|
import { handleModuleOperations } from './commands/module-operations';
|
|
85
|
+
import { handleModulePause, handleModuleUnpause } from './commands/module-pause';
|
|
80
86
|
import { handleModulePublish } from './commands/module-publish';
|
|
81
87
|
import { handleModuleRemove } from './commands/module-remove';
|
|
82
88
|
import { handleModuleSearch } from './commands/module-search';
|
|
@@ -203,6 +209,7 @@ Commands:
|
|
|
203
209
|
storage Manage backup storage destinations
|
|
204
210
|
backup Create and manage backups
|
|
205
211
|
restore Restore a celilo-mgmt backup from a local file (fresh-bootstrap path)
|
|
212
|
+
firewall Inspect a firewall's interfaces (classification, on demand)
|
|
206
213
|
machine Manage machine pool (bring-your-own-hardware)
|
|
207
214
|
system Manage system configuration
|
|
208
215
|
apt-upgrade Upgrade the deb-installed celilo packages + apply migrations
|
|
@@ -578,11 +585,26 @@ Subcommands:
|
|
|
578
585
|
|
|
579
586
|
remove <id> Remove a module and all its data
|
|
580
587
|
|
|
588
|
+
pause <id> Quiesce a module without uninstalling it (container keeps running)
|
|
589
|
+
Options:
|
|
590
|
+
--cascade Also pause every dependent module, consumers first
|
|
591
|
+
--stop-infra Also stop the module's container or service
|
|
592
|
+
--reason <text> Why the pause was taken, recorded on the module
|
|
593
|
+
--dry-run Print the ordered plan and change nothing
|
|
594
|
+
--yes Skip the cascade confirmation
|
|
595
|
+
|
|
596
|
+
unpause <id> Redeploy a paused module, rebinding it to current providers
|
|
597
|
+
Options:
|
|
598
|
+
--cascade Also unpause every dependent module, providers first
|
|
599
|
+
--dry-run Print the ordered plan and change nothing
|
|
600
|
+
--yes Skip the cascade confirmation
|
|
601
|
+
|
|
581
602
|
verify <id> Verify module integrity (signature + checksums)
|
|
582
603
|
(legacy alias: 'audit')
|
|
583
604
|
|
|
584
605
|
config set <id> <key> <value> Set module configuration value
|
|
585
606
|
config get <id> [key] Get module configuration value(s)
|
|
607
|
+
config unset <id> <key> Remove an override; follow the manifest again
|
|
586
608
|
|
|
587
609
|
secret set <id> <key> <value> Set encrypted module secret
|
|
588
610
|
secret list <id> List module secrets
|
|
@@ -649,11 +671,19 @@ Examples:
|
|
|
649
671
|
celilo module publish ./modules/* # publish every module in a dir
|
|
650
672
|
celilo module list
|
|
651
673
|
celilo module remove homebridge
|
|
674
|
+
|
|
675
|
+
# Swap a capability provider (e.g. a replaced edge router):
|
|
676
|
+
celilo module pause --cascade greenwave --reason "ISP swapped the router"
|
|
677
|
+
celilo module remove greenwave
|
|
678
|
+
celilo module import axon && celilo module deploy axon
|
|
679
|
+
celilo module unpause --cascade axon
|
|
652
680
|
celilo module verify homebridge
|
|
653
681
|
celilo module config set homebridge hostname myhost
|
|
654
682
|
celilo module config set homebridge container_ip "192.168.0.110/24"
|
|
655
683
|
celilo module config get homebridge
|
|
656
684
|
celilo module config get homebridge hostname
|
|
685
|
+
celilo module config set caddy backup_schedule 6h
|
|
686
|
+
celilo module config unset caddy backup_schedule
|
|
657
687
|
celilo module show-config homebridge
|
|
658
688
|
celilo module show-zone homebridge
|
|
659
689
|
celilo module build caddy
|
|
@@ -896,6 +926,28 @@ Related Commands:
|
|
|
896
926
|
/**
|
|
897
927
|
* Display machine command help
|
|
898
928
|
*/
|
|
929
|
+
function displayFirewallHelp(): CommandResult {
|
|
930
|
+
console.log(`
|
|
931
|
+
celilo firewall — inspect a firewall's interfaces
|
|
932
|
+
|
|
933
|
+
Usage:
|
|
934
|
+
celilo firewall interface list [<hostname>]
|
|
935
|
+
|
|
936
|
+
Shows how celilo classifies every interface on a firewall: the zone it
|
|
937
|
+
matched, the external edge, or ALIEN when nothing accounts for it.
|
|
938
|
+
|
|
939
|
+
Read-only. It reads the recorded interface table and the declared zone
|
|
940
|
+
subnets and classifies in memory — it never touches the box, so it is safe
|
|
941
|
+
to run against a firewall whose converge is currently refusing.
|
|
942
|
+
|
|
943
|
+
With no hostname, every machine celilo classifies as a router is shown.
|
|
944
|
+
|
|
945
|
+
An interface celilo cannot attribute is resolved by DECLARING it:
|
|
946
|
+
celilo system config set network.<zone>.subnet <cidr>
|
|
947
|
+
`);
|
|
948
|
+
return { success: true, message: 'firewall help' };
|
|
949
|
+
}
|
|
950
|
+
|
|
899
951
|
function displayMachineHelp(): CommandResult {
|
|
900
952
|
const helpText = `
|
|
901
953
|
Celilo - Machine Pool Management
|
|
@@ -1397,8 +1449,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1397
1449
|
const { main: runPublish } = await import('./commands/publish');
|
|
1398
1450
|
await runPublish(publishArgv);
|
|
1399
1451
|
// runPublish handles its own console output (multi-phase, multi-line);
|
|
1400
|
-
// returning an empty success message tells the outer CLI loop to
|
|
1401
|
-
//
|
|
1452
|
+
// returning an empty success message tells the outer CLI loop to write
|
|
1453
|
+
// nothing more and exit 0 cleanly. Any failure inside runPublish
|
|
1402
1454
|
// calls process.exit() directly and never returns here.
|
|
1403
1455
|
return { success: true, message: '' };
|
|
1404
1456
|
}
|
|
@@ -1499,6 +1551,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1499
1551
|
return handleModuleHealth(parsed.args, parsed.flags);
|
|
1500
1552
|
case 'operations':
|
|
1501
1553
|
return handleModuleOperations(parsed.args, parsed.flags);
|
|
1554
|
+
case 'pause':
|
|
1555
|
+
return handleModulePause(parsed.args, parsed.flags);
|
|
1556
|
+
case 'unpause':
|
|
1557
|
+
return handleModuleUnpause(parsed.args, parsed.flags);
|
|
1502
1558
|
case 'remove':
|
|
1503
1559
|
return handleModuleRemove(parsed.args, parsed.flags);
|
|
1504
1560
|
case 'update':
|
|
@@ -1519,7 +1575,7 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1519
1575
|
if (!configSubcommand) {
|
|
1520
1576
|
return {
|
|
1521
1577
|
success: false,
|
|
1522
|
-
error: 'Config action required (set or
|
|
1578
|
+
error: 'Config action required (set, get or unset)\n\nRun "celilo help" for usage',
|
|
1523
1579
|
};
|
|
1524
1580
|
}
|
|
1525
1581
|
const configArgs = parsed.args.slice(1);
|
|
@@ -1529,6 +1585,9 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1529
1585
|
if (configSubcommand === 'get') {
|
|
1530
1586
|
return handleModuleConfigGet(configArgs);
|
|
1531
1587
|
}
|
|
1588
|
+
if (configSubcommand === 'unset') {
|
|
1589
|
+
return handleModuleConfigUnset(configArgs);
|
|
1590
|
+
}
|
|
1532
1591
|
return {
|
|
1533
1592
|
success: false,
|
|
1534
1593
|
error: `Unknown config action: ${configSubcommand}`,
|
|
@@ -2025,6 +2084,29 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
2025
2084
|
};
|
|
2026
2085
|
}
|
|
2027
2086
|
|
|
2087
|
+
if (parsed.command === 'firewall') {
|
|
2088
|
+
if (parsed.flags.help || parsed.flags.h || !parsed.subcommand) {
|
|
2089
|
+
return displayFirewallHelp();
|
|
2090
|
+
}
|
|
2091
|
+
if (parsed.subcommand === 'interface') {
|
|
2092
|
+
// `interface` is a group, not a leaf: `list` is its only verb today.
|
|
2093
|
+
// `acknowledge` and `enforce` were both deleted in the design amendments —
|
|
2094
|
+
// there is no policy toggle and no per-interface escape hatch.
|
|
2095
|
+
const verb = parsed.args[0];
|
|
2096
|
+
if (!verb || verb === 'list') {
|
|
2097
|
+
return handleFirewallInterfaceList(parsed.args.slice(1), parsed.flags);
|
|
2098
|
+
}
|
|
2099
|
+
return {
|
|
2100
|
+
success: false,
|
|
2101
|
+
error: `Unknown firewall interface subcommand: ${verb}\n\nRun "celilo firewall --help" for usage`,
|
|
2102
|
+
};
|
|
2103
|
+
}
|
|
2104
|
+
return {
|
|
2105
|
+
success: false,
|
|
2106
|
+
error: `Unknown firewall subcommand: ${parsed.subcommand}\n\nRun "celilo firewall --help" for usage`,
|
|
2107
|
+
};
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2028
2110
|
if (parsed.command === 'machine') {
|
|
2029
2111
|
// Handle machine --help
|
|
2030
2112
|
if (parsed.flags.help || parsed.flags.h) {
|
|
@@ -2465,31 +2547,31 @@ export async function main(): Promise<void> {
|
|
|
2465
2547
|
process.exit(0);
|
|
2466
2548
|
}
|
|
2467
2549
|
|
|
2468
|
-
// Script-friendly commands bypass clack and write directly to stdout
|
|
2469
|
-
if (result.rawOutput) {
|
|
2470
|
-
process.stdout.write(`${result.message}\n`);
|
|
2471
|
-
process.exit(0);
|
|
2472
|
-
}
|
|
2473
|
-
|
|
2474
2550
|
// Empty message → command already emitted its own output (e.g. via
|
|
2475
|
-
// ProgressDisplay) and doesn't want
|
|
2551
|
+
// ProgressDisplay) and doesn't want anything written on top.
|
|
2476
2552
|
if (!result.message) {
|
|
2477
2553
|
process.exit(0);
|
|
2478
2554
|
}
|
|
2479
2555
|
|
|
2480
|
-
//
|
|
2481
|
-
//
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2556
|
+
// A successful command's message IS its result, so it goes to stdout
|
|
2557
|
+
// verbatim — no glyph, no `│ ` prefix, no re-wrapping on `\n\n`.
|
|
2558
|
+
//
|
|
2559
|
+
// `rawOutput` no longer selects a different destination; it is kept
|
|
2560
|
+
// because it still records which commands are contractually
|
|
2561
|
+
// machine-readable, and because JSON payloads must never acquire a
|
|
2562
|
+
// decoration if this branch ever grows one again (celilo#698).
|
|
2563
|
+
//
|
|
2564
|
+
// The old path split the message on `\n\n` and fed each section to the
|
|
2565
|
+
// clack renderer, which prefixed every line. That is what made
|
|
2566
|
+
// `line.startsWith('<module-id> ')` over `celilo module list` match
|
|
2567
|
+
// nothing — read in celilo#695 as a missing module rather than as a
|
|
2568
|
+
// parse failure, at the cost of a full e2e run.
|
|
2569
|
+
process.stdout.write(`${result.message}\n`);
|
|
2489
2570
|
process.exit(0);
|
|
2490
2571
|
}
|
|
2491
2572
|
|
|
2492
|
-
|
|
2573
|
+
// Diagnostics go to stderr so a caller can separate them from the result.
|
|
2574
|
+
uiLog.error(`Error: ${result.error}`);
|
|
2493
2575
|
if (result.details) {
|
|
2494
2576
|
console.error('Details:', result.details);
|
|
2495
2577
|
}
|
|
@@ -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
|
+
});
|
package/src/cli/prompts.ts
CHANGED
|
@@ -1,10 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Prompt Utilities
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* A thin celilo-facing wrapper over the prompt/message primitives in
|
|
5
|
+
* `@celilo/cli-display`. Roughly 160 `log.*` call sites and every legacy
|
|
6
|
+
* direct prompt route through this module, which is what made replacing
|
|
7
|
+
* `@clack/prompts` a one-file change rather than a 160-file one (celilo#699).
|
|
8
|
+
*
|
|
9
|
+
* NOTE: new interactive decisions must NOT be added here. They belong on the
|
|
10
|
+
* event-bus interview (`services/bus-interview`), which a headless responder
|
|
11
|
+
* can answer; `test-integration/cli/headless-drivable.test.ts` enforces that
|
|
12
|
+
* no command reaches for these directly. What remains is the terminal renderer
|
|
13
|
+
* for a bus question, plus the legacy prompts that predate the interview.
|
|
4
14
|
*/
|
|
5
15
|
|
|
6
|
-
import {
|
|
7
|
-
|
|
16
|
+
import {
|
|
17
|
+
CANCEL,
|
|
18
|
+
cancel,
|
|
19
|
+
getActiveDisplay,
|
|
20
|
+
intro,
|
|
21
|
+
isCancel,
|
|
22
|
+
log,
|
|
23
|
+
note,
|
|
24
|
+
outro,
|
|
25
|
+
password,
|
|
26
|
+
setActiveDisplay,
|
|
27
|
+
text,
|
|
28
|
+
confirm as uiConfirm,
|
|
29
|
+
} from '@celilo/cli-display';
|
|
8
30
|
|
|
9
31
|
// Re-export so existing imports from `../cli/prompts` keep working.
|
|
10
32
|
// The singleton itself lives in @celilo/cli-display so module code
|
|
@@ -12,25 +34,26 @@ import * as p from '@clack/prompts';
|
|
|
12
34
|
// it via @celilo/capabilities's re-export.
|
|
13
35
|
export { getActiveDisplay, setActiveDisplay };
|
|
14
36
|
|
|
37
|
+
export { log };
|
|
38
|
+
|
|
15
39
|
/**
|
|
16
40
|
* Interactive prompt wrapper with celilo branding
|
|
17
41
|
*/
|
|
18
42
|
export async function celiloIntro(title: string): Promise<void> {
|
|
19
|
-
|
|
43
|
+
intro(title);
|
|
20
44
|
}
|
|
21
45
|
|
|
22
46
|
export async function celiloOutro(message: string): Promise<void> {
|
|
23
|
-
|
|
47
|
+
outro(message);
|
|
24
48
|
}
|
|
25
49
|
|
|
26
50
|
/**
|
|
27
|
-
* Prompt for text input with validation and help text
|
|
51
|
+
* Prompt for text input with validation and help text.
|
|
28
52
|
*
|
|
29
|
-
* Displays
|
|
30
|
-
* -
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* - Validation errors inline
|
|
53
|
+
* Displays the question, an input field with the placeholder, and any
|
|
54
|
+
* validation error inline. A cancel (Ctrl-C / Escape) exits the process, which
|
|
55
|
+
* is the behaviour every existing caller is written against: they treat the
|
|
56
|
+
* return as an unconditional `string`.
|
|
34
57
|
*/
|
|
35
58
|
export async function promptText(options: {
|
|
36
59
|
message: string;
|
|
@@ -38,19 +61,14 @@ export async function promptText(options: {
|
|
|
38
61
|
placeholder?: string;
|
|
39
62
|
validate?: (value: string | undefined) => string | Error | undefined;
|
|
40
63
|
}): Promise<string> {
|
|
41
|
-
const result = await
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
validate: options.validate,
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
if (p.isCancel(result)) {
|
|
49
|
-
p.cancel('Operation cancelled');
|
|
64
|
+
const result = await text(options);
|
|
65
|
+
|
|
66
|
+
if (isCancel(result)) {
|
|
67
|
+
cancel('Operation cancelled');
|
|
50
68
|
process.exit(0);
|
|
51
69
|
}
|
|
52
70
|
|
|
53
|
-
return result
|
|
71
|
+
return result;
|
|
54
72
|
}
|
|
55
73
|
|
|
56
74
|
/**
|
|
@@ -62,30 +80,25 @@ export async function promptPassword(options: {
|
|
|
62
80
|
validate?: (value: string | undefined) => string | Error | undefined;
|
|
63
81
|
}): Promise<string> {
|
|
64
82
|
while (true) {
|
|
65
|
-
const result = await
|
|
66
|
-
message: options.message,
|
|
67
|
-
validate: options.validate,
|
|
68
|
-
});
|
|
83
|
+
const result = await password(options);
|
|
69
84
|
|
|
70
|
-
if (
|
|
71
|
-
|
|
85
|
+
if (isCancel(result)) {
|
|
86
|
+
cancel('Operation cancelled');
|
|
72
87
|
process.exit(0);
|
|
73
88
|
}
|
|
74
89
|
|
|
75
|
-
const
|
|
76
|
-
message: `Confirm ${options.message}`,
|
|
77
|
-
});
|
|
90
|
+
const confirmation = await password({ message: `Confirm ${options.message}` });
|
|
78
91
|
|
|
79
|
-
if (
|
|
80
|
-
|
|
92
|
+
if (isCancel(confirmation)) {
|
|
93
|
+
cancel('Operation cancelled');
|
|
81
94
|
process.exit(0);
|
|
82
95
|
}
|
|
83
96
|
|
|
84
|
-
if (result ===
|
|
85
|
-
return result
|
|
97
|
+
if (result === confirmation) {
|
|
98
|
+
return result;
|
|
86
99
|
}
|
|
87
100
|
|
|
88
|
-
|
|
101
|
+
log.warn('Passwords do not match. Please try again.');
|
|
89
102
|
}
|
|
90
103
|
}
|
|
91
104
|
|
|
@@ -96,53 +109,19 @@ export async function promptConfirm(options: {
|
|
|
96
109
|
message: string;
|
|
97
110
|
initialValue?: boolean;
|
|
98
111
|
}): Promise<boolean> {
|
|
99
|
-
const result = await
|
|
100
|
-
message: options.message,
|
|
101
|
-
initialValue: options.initialValue,
|
|
102
|
-
});
|
|
112
|
+
const result = await uiConfirm(options);
|
|
103
113
|
|
|
104
|
-
if (
|
|
105
|
-
|
|
114
|
+
if (result === CANCEL) {
|
|
115
|
+
cancel('Operation cancelled');
|
|
106
116
|
process.exit(0);
|
|
107
117
|
}
|
|
108
118
|
|
|
109
|
-
return result
|
|
119
|
+
return result;
|
|
110
120
|
}
|
|
111
121
|
|
|
112
122
|
/**
|
|
113
123
|
* Show note/information box
|
|
114
124
|
*/
|
|
115
125
|
export function showNote(message: string, title?: string): void {
|
|
116
|
-
|
|
126
|
+
note(message, title);
|
|
117
127
|
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Show log messages
|
|
121
|
-
*/
|
|
122
|
-
export const log = {
|
|
123
|
-
success: (message: string) => {
|
|
124
|
-
const d = getActiveDisplay();
|
|
125
|
-
if (d) return d.subEvent(`\x1b[32m✔\x1b[0m ${message}`);
|
|
126
|
-
p.log.success(message);
|
|
127
|
-
},
|
|
128
|
-
error: (message: string) => {
|
|
129
|
-
const d = getActiveDisplay();
|
|
130
|
-
if (d) return d.subEvent(`\x1b[31m✗\x1b[0m ${message}`);
|
|
131
|
-
p.log.error(message);
|
|
132
|
-
},
|
|
133
|
-
warn: (message: string) => {
|
|
134
|
-
const d = getActiveDisplay();
|
|
135
|
-
if (d) return d.subEvent(`\x1b[33m⚠\x1b[0m ${message}`);
|
|
136
|
-
p.log.warn(message);
|
|
137
|
-
},
|
|
138
|
-
info: (message: string) => {
|
|
139
|
-
const d = getActiveDisplay();
|
|
140
|
-
if (d) return d.instantEvent(message);
|
|
141
|
-
p.log.info(message);
|
|
142
|
-
},
|
|
143
|
-
message: (message: string) => {
|
|
144
|
-
const d = getActiveDisplay();
|
|
145
|
-
if (d) return d.subEvent(message);
|
|
146
|
-
p.log.message(message);
|
|
147
|
-
},
|
|
148
|
-
};
|
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
* secrets therefore travel by **flag or env var**, never over the event bus.
|
|
9
9
|
*
|
|
10
10
|
* The resolution order is: explicit `--<flag>` → `$ENV` → (only when stdin is
|
|
11
|
-
* a TTY) a local
|
|
11
|
+
* a TTY) a local masked `password` prompt → otherwise a fail-fast error naming
|
|
12
12
|
* the flag and env var. This keeps a zero-TTY `service add` possible while the
|
|
13
13
|
* credential never lands on the bus, and the local password prompt is the one
|
|
14
|
-
*
|
|
14
|
+
* direct prompt the recurrence gate (D6) tolerates — precisely because a
|
|
15
15
|
* flag/env path always exists alongside it.
|
|
16
16
|
*/
|
|
17
17
|
|
|
@@ -29,7 +29,7 @@ export interface ServiceCredentialSpec {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* Resolve a service-credential secret from flag → env → (TTY)
|
|
32
|
+
* Resolve a service-credential secret from flag → env → (TTY) password prompt →
|
|
33
33
|
* error. Returns the secret value as a string. Throws an actionable error when
|
|
34
34
|
* no value is available on a non-TTY run.
|
|
35
35
|
*/
|