@celilo/cli 0.11.0-alpha.0 → 0.12.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/AGENTS.md +7 -4
- package/CELILO_CORE_MODULES.md +7 -6
- package/CELILO_SUBSYSTEMS.md +47 -5
- package/MODULE_PRIMITIVES.md +164 -0
- package/README.md +5 -5
- package/drizzle/0015_port_forwards.sql +12 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +8 -8
- package/schemas/system_config.json +1 -1
- package/src/ansible/inventory.test.ts +1 -1
- package/src/ansible/inventory.ts +3 -3
- package/src/capabilities/public-web-publish.test.ts +209 -0
- package/src/capabilities/validation.test.ts +1 -1
- package/src/capabilities/well-known.test.ts +1 -1
- package/src/capabilities/well-known.ts +2 -2
- package/src/cli/commands/api.ts +1 -1
- package/src/cli/commands/backup-pull.ts +1 -1
- package/src/cli/commands/events.ts +1 -1
- package/src/cli/commands/module-changeset.ts +1 -1
- package/src/cli/commands/module-import.ts +1 -1
- package/src/cli/commands/module-publish.ts +1 -1
- package/src/cli/commands/module-remove.ts +26 -1
- package/src/cli/commands/module-types.ts +1 -1
- package/src/cli/commands/module-upgrade.ts +1 -1
- package/src/cli/commands/module-version.ts +1 -1
- package/src/cli/commands/module-where.test.ts +26 -0
- package/src/cli/commands/module-where.ts +130 -0
- package/src/cli/commands/proxmox-node-list.ts +1 -1
- package/src/cli/commands/publish/alpha.test.ts +1 -1
- package/src/cli/commands/publish/alpha.ts +3 -3
- package/src/cli/commands/publish/changesets.ts +1 -1
- package/src/cli/commands/publish/global-install.ts +6 -2
- package/src/cli/commands/publish/helpers.ts +21 -14
- package/src/cli/commands/publish/index.ts +4 -4
- package/src/cli/commands/publish/plan.ts +3 -3
- package/src/cli/commands/publish/preflight.ts +2 -2
- package/src/cli/commands/publish/types.ts +2 -2
- package/src/cli/commands/publish/workspace.test.ts +1 -1
- package/src/cli/commands/publish/workspace.ts +2 -2
- package/src/cli/commands/registry-owner.test.ts +166 -0
- package/src/cli/commands/registry-owner.ts +124 -0
- package/src/cli/commands/registry-token.test.ts +109 -0
- package/src/cli/commands/registry-token.ts +194 -0
- package/src/cli/commands/restore.ts +1 -1
- package/src/cli/commands/service-add-proxmox.ts +1 -1
- package/src/cli/commands/subscribers-list.ts +1 -1
- package/src/cli/commands/system-apply-config-equivalence.test.ts +1 -1
- package/src/cli/commands/system-apply-config.ts +1 -1
- package/src/cli/commands/system-init-deprecation.test.ts +1 -1
- package/src/cli/commands/system-init.ts +2 -2
- package/src/cli/commands/token.test.ts +26 -0
- package/src/cli/commands/token.ts +160 -0
- package/src/cli/completion.ts +19 -0
- package/src/cli/index.ts +155 -4
- package/src/cli/restore-command.test.ts +1 -1
- package/src/db/client.ts +1 -1
- package/src/db/schema.ts +50 -7
- package/src/hooks/capability-loader-firewall.test.ts +4 -3
- package/src/hooks/capability-loader.ts +100 -10
- package/src/hooks/define-hook.test.ts +24 -0
- package/src/hooks/executor.test.ts +1 -1
- package/src/hooks/executor.ts +2 -2
- package/src/hooks/types.ts +1 -1
- package/src/manifest/contracts/v1.ts +2 -2
- package/src/manifest/schema.ts +13 -13
- package/src/manifest/template-validator.ts +1 -1
- package/src/module/packaging/build.ts +19 -0
- package/src/module/packaging/workspace-deps.test.ts +94 -0
- package/src/module/packaging/workspace-deps.ts +185 -0
- package/src/module/versioning/changeset-version.ts +1 -1
- package/src/policy/no-hand-built-ssh.test.ts +90 -0
- package/src/registry/client.test.ts +86 -0
- package/src/registry/client.ts +67 -1
- package/src/services/api-access.ts +1 -1
- package/src/services/aspect-approvals.ts +1 -1
- package/src/services/aspect-runner.ts +3 -3
- package/src/services/aspect-template-resolver.test.ts +1 -1
- package/src/services/aspect-template-resolver.ts +1 -1
- package/src/services/build-bus/delivery-events.ts +1 -1
- package/src/services/build-bus/fan-out.ts +1 -1
- package/src/services/build-bus/hook-dispatch.ts +1 -1
- package/src/services/build-bus/receiver-server.ts +1 -1
- package/src/services/build-bus/status.test.ts +1 -1
- package/src/services/build-bus/status.ts +1 -1
- package/src/services/build-bus/subscriber-store.ts +1 -1
- package/src/services/bus-interview.ts +1 -1
- package/src/services/celilo-events.ts +1 -1
- package/src/services/celilo-mgmt-hooks.test.ts +1 -1
- package/src/services/cross-module-read.ts +1 -1
- package/src/services/deploy-posture.ts +1 -1
- package/src/services/deployed-systems.test.ts +1 -1
- package/src/services/deployed-systems.ts +4 -4
- package/src/services/dns-provider-backfill.ts +2 -2
- package/src/services/events-daemon.ts +1 -1
- package/src/services/machine-pool.ts +3 -3
- package/src/services/module-deploy.ts +93 -8
- package/src/services/module-subscriptions.ts +1 -1
- package/src/services/module-types-generator.ts +1 -1
- package/src/services/module-validator/git-hygiene.ts +1 -1
- package/src/services/port-forwards.test.ts +92 -0
- package/src/services/port-forwards.ts +86 -0
- package/src/services/proxmox-reconcile.ts +2 -2
- package/src/services/public-web-republish.test.ts +189 -0
- package/src/services/public-web-republish.ts +84 -0
- package/src/services/restore-from-file.ts +1 -1
- package/src/services/restore-preflight.ts +1 -1
- package/src/services/system-identity.ts +1 -1
- package/src/services/system-init.test.ts +2 -2
- package/src/services/system-init.ts +2 -2
- package/src/services/web-route-cleanup.test.ts +250 -0
- package/src/services/web-route-cleanup.ts +144 -0
- package/src/templates/generator.ts +2 -2
- package/src/types/infrastructure.ts +1 -1
- package/src/variables/computed/computed-integration.test.ts +1 -1
- package/src/variables/computed/evaluate.ts +1 -1
- package/src/variables/computed/parse.ts +1 -1
- package/src/variables/context.test.ts +1 -1
- package/src/variables/context.ts +5 -5
- package/src/variables/lxc-nameserver.test.ts +1 -1
- package/src/variables/resolver.ts +2 -2
- package/src/variables/types.ts +1 -1
package/src/cli/index.ts
CHANGED
|
@@ -77,11 +77,11 @@ import { handleModuleUpdate } from './commands/module-update';
|
|
|
77
77
|
import { handleModuleUpgrade } from './commands/module-upgrade';
|
|
78
78
|
import { moduleVerify } from './commands/module-verify';
|
|
79
79
|
import { handleModuleVersion } from './commands/module-version';
|
|
80
|
+
import { handleModuleWhere } from './commands/module-where';
|
|
80
81
|
import { handlePackage } from './commands/package';
|
|
81
82
|
import { handleProxmoxInstanceList } from './commands/proxmox-instance-list';
|
|
82
83
|
import { handleProxmoxInstanceResize } from './commands/proxmox-instance-resize';
|
|
83
84
|
import { handleProxmoxNodeList } from './commands/proxmox-node-list';
|
|
84
|
-
import { main as runPublish } from './commands/publish';
|
|
85
85
|
import { handleSecretList } from './commands/secret-list';
|
|
86
86
|
import { handleSecretSet } from './commands/secret-set';
|
|
87
87
|
import { handleServiceAddDigitalOcean } from './commands/service-add-digitalocean';
|
|
@@ -193,6 +193,8 @@ Commands:
|
|
|
193
193
|
ipam Manage IP address and VMID allocations and reservations
|
|
194
194
|
proxmox Proxmox cluster introspection (proxmox node list)
|
|
195
195
|
publish Publish workspace packages to npm and modules to celilo.computer
|
|
196
|
+
registry Administer the module registry (append-safe publish-token management)
|
|
197
|
+
token Manage contributor identity tokens (idp-issued per-user publish tokens)
|
|
196
198
|
subscribers Manage build-bus subscribers (cross-machine publish-event delivery)
|
|
197
199
|
api Manage remote-API access (principals, grants, authorized_keys)
|
|
198
200
|
completion Generate shell completion scripts (bash/zsh)
|
|
@@ -301,7 +303,7 @@ Description:
|
|
|
301
303
|
Modules declare \`subscriptions:\` in their manifests; \`celilo module deploy\`
|
|
302
304
|
emits lifecycle events that subscribers react to.
|
|
303
305
|
|
|
304
|
-
See infra/
|
|
306
|
+
See infra/openspec/specs/event-bus/spec.md for the full design.
|
|
305
307
|
|
|
306
308
|
Examples:
|
|
307
309
|
celilo events run # foreground dispatcher
|
|
@@ -340,7 +342,7 @@ Description:
|
|
|
340
342
|
package name glob). The publisher signs every event with the
|
|
341
343
|
subscriber's secret; the receiver verifies before reacting.
|
|
342
344
|
|
|
343
|
-
This is the static-config first cut of
|
|
345
|
+
This is the static-config first cut of openspec/changes/build-bus-poll-cd/proposal.md — operators
|
|
344
346
|
manage subscribers explicitly. Auto-discovery via the celilo
|
|
345
347
|
registry switchboard is a follow-on.
|
|
346
348
|
|
|
@@ -433,6 +435,79 @@ Examples:
|
|
|
433
435
|
return { success: true, message: helpText };
|
|
434
436
|
}
|
|
435
437
|
|
|
438
|
+
function displayRegistryHelp(): CommandResult {
|
|
439
|
+
const helpText = `
|
|
440
|
+
Celilo - Module Registry Administration
|
|
441
|
+
|
|
442
|
+
Usage:
|
|
443
|
+
celilo registry <subcommand> [args...]
|
|
444
|
+
|
|
445
|
+
Subcommands:
|
|
446
|
+
token add <token> Append a publish token (append-safe; never clobbers others)
|
|
447
|
+
token rm <token> Remove one publish token, leaving the rest intact
|
|
448
|
+
owner list List the module-owner table (who may publish what)
|
|
449
|
+
owner show <module> Show the owner of one module name
|
|
450
|
+
owner set <module> <sub> Reassign a module name to a new owner (admin)
|
|
451
|
+
|
|
452
|
+
Description:
|
|
453
|
+
The celilo-registry module's publish_tokens secret is a newline-separated list
|
|
454
|
+
of bootstrap/admin publish tokens. Editing it with 'module secret set' OVERWRITES
|
|
455
|
+
the whole list, clobbering every other holder. These commands read-modify-write
|
|
456
|
+
the list instead.
|
|
457
|
+
|
|
458
|
+
The module-owner table (owner *) gates which module NAMES a verified publisher
|
|
459
|
+
may publish: the first verified publisher of an unclaimed name owns it
|
|
460
|
+
(first-publish-claims); thereafter only that owner or an admin may publish it.
|
|
461
|
+
'owner set' is the admin reassignment. These read the admin token from the local
|
|
462
|
+
publish_tokens list; pass --registry to target a non-default registry.
|
|
463
|
+
|
|
464
|
+
Run on the host where celilo-registry is deployed (where the master key lives).
|
|
465
|
+
|
|
466
|
+
Examples:
|
|
467
|
+
celilo registry token add ci-token-abc123
|
|
468
|
+
celilo registry owner list
|
|
469
|
+
celilo registry owner show homebridge
|
|
470
|
+
celilo registry owner set homebridge alice
|
|
471
|
+
`;
|
|
472
|
+
|
|
473
|
+
return { success: true, message: helpText.trim() };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function displayTokenHelp(): CommandResult {
|
|
477
|
+
const helpText = `
|
|
478
|
+
Celilo - Contributor Identity Tokens (idp-issued, per-user)
|
|
479
|
+
|
|
480
|
+
Usage:
|
|
481
|
+
celilo token <subcommand> [args...]
|
|
482
|
+
|
|
483
|
+
Subcommands:
|
|
484
|
+
obtain --user <username> Mint (or return) a per-user API token for publishing
|
|
485
|
+
list --user <username> List a user's API tokens (metadata only, no secret)
|
|
486
|
+
revoke <identifier> Revoke a token by identifier; next publish denied
|
|
487
|
+
|
|
488
|
+
Options:
|
|
489
|
+
--user <username> The idp user the token is bound to
|
|
490
|
+
--identifier <id> Token identifier (default: celilo-publish-<user>)
|
|
491
|
+
--description <text> Human-readable description (obtain only)
|
|
492
|
+
|
|
493
|
+
Description:
|
|
494
|
+
Issues per-user API tokens from the idp (authentik) so a module contributor
|
|
495
|
+
authenticates publishes AS THEMSELVES — no admin/shared token on their machine.
|
|
496
|
+
The token is consumed by 'celilo author init'. Shown once at mint; the idp
|
|
497
|
+
stores it hashed and celilo persists nothing.
|
|
498
|
+
|
|
499
|
+
Distinct from 'celilo registry token add/rm', which manages the raw bootstrap
|
|
500
|
+
publish_tokens list. Run on the host where the idp is deployed (celilo-mgr).
|
|
501
|
+
|
|
502
|
+
Examples:
|
|
503
|
+
celilo token obtain --user alice
|
|
504
|
+
celilo token list --user alice
|
|
505
|
+
celilo token revoke celilo-publish-alice
|
|
506
|
+
`;
|
|
507
|
+
|
|
508
|
+
return { success: true, message: helpText.trim() };
|
|
509
|
+
}
|
|
510
|
+
|
|
436
511
|
function displayCapabilityHelp(): CommandResult {
|
|
437
512
|
const helpText = `
|
|
438
513
|
Celilo - Capability Management
|
|
@@ -1206,6 +1281,71 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1206
1281
|
};
|
|
1207
1282
|
}
|
|
1208
1283
|
|
|
1284
|
+
if (parsed.command === 'registry') {
|
|
1285
|
+
if (parsed.flags.help || parsed.flags.h || !parsed.subcommand) {
|
|
1286
|
+
return displayRegistryHelp();
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
const registryFlagError = checkFlags('registry', parsed.subcommand, parsed.flags, parsed.args);
|
|
1290
|
+
if (registryFlagError) return registryFlagError;
|
|
1291
|
+
|
|
1292
|
+
if (parsed.subcommand === 'token') {
|
|
1293
|
+
const action = parsed.args[0];
|
|
1294
|
+
const tokenArgs = parsed.args.slice(1);
|
|
1295
|
+
const { handleRegistryTokenAdd, handleRegistryTokenRm } = await import(
|
|
1296
|
+
'./commands/registry-token'
|
|
1297
|
+
);
|
|
1298
|
+
if (action === 'add') return handleRegistryTokenAdd(tokenArgs);
|
|
1299
|
+
if (action === 'rm') return handleRegistryTokenRm(tokenArgs);
|
|
1300
|
+
return {
|
|
1301
|
+
success: false,
|
|
1302
|
+
error:
|
|
1303
|
+
'Token action required (add or rm)\n\nUsage:\n celilo registry token add <token>\n celilo registry token rm <token>',
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
if (parsed.subcommand === 'owner') {
|
|
1308
|
+
const action = parsed.args[0];
|
|
1309
|
+
const ownerArgs = parsed.args.slice(1);
|
|
1310
|
+
const { handleRegistryOwnerList, handleRegistryOwnerShow, handleRegistryOwnerSet } =
|
|
1311
|
+
await import('./commands/registry-owner');
|
|
1312
|
+
if (action === 'list') return handleRegistryOwnerList(parsed.flags);
|
|
1313
|
+
if (action === 'show') return handleRegistryOwnerShow(ownerArgs, parsed.flags);
|
|
1314
|
+
if (action === 'set') return handleRegistryOwnerSet(ownerArgs, parsed.flags);
|
|
1315
|
+
return {
|
|
1316
|
+
success: false,
|
|
1317
|
+
error:
|
|
1318
|
+
'Owner action required (list, show, or set)\n\nUsage:\n celilo registry owner list\n celilo registry owner show <module>\n celilo registry owner set <module> <owner-sub>',
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
return {
|
|
1323
|
+
success: false,
|
|
1324
|
+
error: `Unknown registry subcommand: ${parsed.subcommand}\n\nRun "celilo registry --help" for usage`,
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
if (parsed.command === 'token') {
|
|
1329
|
+
if (parsed.flags.help || parsed.flags.h || !parsed.subcommand) {
|
|
1330
|
+
return displayTokenHelp();
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
const tokenFlagError = checkFlags('token', parsed.subcommand, parsed.flags, parsed.args);
|
|
1334
|
+
if (tokenFlagError) return tokenFlagError;
|
|
1335
|
+
|
|
1336
|
+
const { handleTokenObtain, handleTokenList, handleTokenRevoke } = await import(
|
|
1337
|
+
'./commands/token'
|
|
1338
|
+
);
|
|
1339
|
+
if (parsed.subcommand === 'obtain') return handleTokenObtain(parsed.args, parsed.flags);
|
|
1340
|
+
if (parsed.subcommand === 'list') return handleTokenList(parsed.args, parsed.flags);
|
|
1341
|
+
if (parsed.subcommand === 'revoke') return handleTokenRevoke(parsed.args, parsed.flags);
|
|
1342
|
+
|
|
1343
|
+
return {
|
|
1344
|
+
success: false,
|
|
1345
|
+
error: `Unknown token subcommand: ${parsed.subcommand}\n\nRun "celilo token --help" for usage`,
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1209
1349
|
if (parsed.command === 'publish') {
|
|
1210
1350
|
if (parsed.flags.help || parsed.flags.h) {
|
|
1211
1351
|
return displayPublishHelp();
|
|
@@ -1218,6 +1358,15 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1218
1358
|
// flag identity since the parser canonicalises those.
|
|
1219
1359
|
const publishIdx = process.argv.indexOf('publish');
|
|
1220
1360
|
const publishArgv = publishIdx >= 0 ? process.argv.slice(publishIdx + 1) : [...parsed.args];
|
|
1361
|
+
// Lazy-load the publish command: it's monorepo-only release tooling whose
|
|
1362
|
+
// transitive import chain reaches scripts/workspace-graph.ts, which does NOT
|
|
1363
|
+
// ship in @celilo/cli's files[]. A static top-level import pulled that into
|
|
1364
|
+
// EVERY command's load graph, so `celilo --version` in a consumer install
|
|
1365
|
+
// crashed on the unresolvable module (WORKSPACE_PACKAGE_GRAPH.md — the D3
|
|
1366
|
+
// failure, deeper than the module-load throw). Loading it only when the
|
|
1367
|
+
// operator actually runs `celilo publish` (which only happens in the
|
|
1368
|
+
// monorepo) keeps every other command free of the dependency.
|
|
1369
|
+
const { main: runPublish } = await import('./commands/publish');
|
|
1221
1370
|
await runPublish(publishArgv);
|
|
1222
1371
|
// runPublish handles its own console output (multi-phase, multi-line);
|
|
1223
1372
|
// returning an empty success message tells the outer CLI loop to skip
|
|
@@ -1306,6 +1455,8 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1306
1455
|
return handleModuleList(parsed.flags);
|
|
1307
1456
|
case 'status':
|
|
1308
1457
|
return handleModuleStatus(parsed.args);
|
|
1458
|
+
case 'where':
|
|
1459
|
+
return handleModuleWhere(parsed.args, parsed.flags);
|
|
1309
1460
|
case 'logs':
|
|
1310
1461
|
return handleModuleLogs(parsed.args, parsed.flags);
|
|
1311
1462
|
case 'health':
|
|
@@ -1697,7 +1848,7 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1697
1848
|
}
|
|
1698
1849
|
|
|
1699
1850
|
if (parsed.command === 'restore') {
|
|
1700
|
-
// Phase 4 of
|
|
1851
|
+
// Phase 4 of openspec/specs/management-server-backup/spec.md — top-level
|
|
1701
1852
|
// fresh-bootstrap restore. Distinct from `celilo backup restore
|
|
1702
1853
|
// <id>` (which restores from a configured storage destination
|
|
1703
1854
|
// using an existing backup record); this one takes a local file
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for the `celilo restore` top-level command (Phase 4 of
|
|
3
|
-
*
|
|
3
|
+
* openspec/specs/management-server-backup/spec.md).
|
|
4
4
|
*
|
|
5
5
|
* Covers the CLI surface: routing, argument parsing, pre-flight
|
|
6
6
|
* gating, error propagation. The actual file-direct restore +
|
package/src/db/client.ts
CHANGED
|
@@ -92,7 +92,7 @@ export function createDbClient(config?: Partial<DatabaseConfig>) {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
// One-time upgrade backfill for the target_ip → module_systems refactor
|
|
95
|
-
// (
|
|
95
|
+
// (openspec/specs/module-systems-addressing/spec.md). migrate() above has ensured the
|
|
96
96
|
// module_systems table exists (migration 0007). A deployment created before
|
|
97
97
|
// the refactor has its host data only in module_configs /
|
|
98
98
|
// ip_allocations / module_infrastructure and an EMPTY module_systems, so its
|
package/src/db/schema.ts
CHANGED
|
@@ -318,7 +318,7 @@ export const machines = sqliteTable('machines', {
|
|
|
318
318
|
* Appliance machines (e.g., greenwave = ISP modem) where celilo
|
|
319
319
|
* has no shell-level access — only API calls. Base-module aspects
|
|
320
320
|
* cannot Ansible to these systems, so the aspect runner skips
|
|
321
|
-
* them. See
|
|
321
|
+
* them. See openspec/specs/base-module-aspects/spec.md D8.
|
|
322
322
|
*/
|
|
323
323
|
apiOnly: integer('api_only', { mode: 'boolean' }).notNull().default(false),
|
|
324
324
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
@@ -345,7 +345,7 @@ export const moduleInfrastructure = sqliteTable('module_infrastructure', {
|
|
|
345
345
|
* driver registers an entry that has no SSH surface (rare today,
|
|
346
346
|
* but exists for future API-only providers). Aspect runner reads
|
|
347
347
|
* this to decide whether the system is reachable via Ansible.
|
|
348
|
-
* See
|
|
348
|
+
* See openspec/specs/base-module-aspects/spec.md D8.
|
|
349
349
|
*/
|
|
350
350
|
apiOnly: integer('api_only', { mode: 'boolean' }).notNull().default(false),
|
|
351
351
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
@@ -358,7 +358,7 @@ export const moduleInfrastructure = sqliteTable('module_infrastructure', {
|
|
|
358
358
|
* deployment-STATE counterpart to module_configs (declared inputs): it records,
|
|
359
359
|
* per deployed system, the hostname + IPv4 + zone + where it lives. Replaces the
|
|
360
360
|
* old scalar `target_ip`/`vmid` rows in module_configs, which baked in the
|
|
361
|
-
* one-module-one-host assumption. See
|
|
361
|
+
* one-module-one-host assumption. See openspec/specs/module-systems-addressing/spec.md.
|
|
362
362
|
*
|
|
363
363
|
* - 0 rows: API-only modules (e.g. namecheap — no host).
|
|
364
364
|
* - 1 row: the common case (technitium, homebridge, …).
|
|
@@ -368,7 +368,7 @@ export const moduleInfrastructure = sqliteTable('module_infrastructure', {
|
|
|
368
368
|
* the manifest's `requires.systems[].name` — what templates reference via
|
|
369
369
|
* `$infra:<name>.…`. `hostname` is the runtime DNS hostname (often == name, but
|
|
370
370
|
* user/well-known-assignable), used by DNS and events. See
|
|
371
|
-
*
|
|
371
|
+
* openspec/specs/module-systems-addressing/spec.md.
|
|
372
372
|
*/
|
|
373
373
|
export const moduleSystems = sqliteTable(
|
|
374
374
|
'module_systems',
|
|
@@ -443,6 +443,49 @@ export const webRoutes = sqliteTable(
|
|
|
443
443
|
}),
|
|
444
444
|
);
|
|
445
445
|
|
|
446
|
+
/**
|
|
447
|
+
* Port-forward registry — the desired-state store for the `firewall` capability
|
|
448
|
+
* (openspec/changes/unified-management-no-ssh/proposal.md). One row per exposed forward. `exposeService`
|
|
449
|
+
* upserts, `unexposeService` deletes, and the firewall provider's converge
|
|
450
|
+
* renders the COMPLETE ruleset for a firewall from these rows and applies it
|
|
451
|
+
* atomically via `iptables-restore` — replacing the old per-rule `iptables -A`
|
|
452
|
+
* (non-idempotent) + the "read the box back with iptables -L" source of truth.
|
|
453
|
+
*
|
|
454
|
+
* Keyed by `firewall_ip` (the converge target) so multiple firewalls each render
|
|
455
|
+
* their own set. Shared-core (not a per-module JSON file) so any firewall
|
|
456
|
+
* provider reconciles against the one canonical store.
|
|
457
|
+
*/
|
|
458
|
+
export const portForwards = sqliteTable(
|
|
459
|
+
'port_forwards',
|
|
460
|
+
{
|
|
461
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
462
|
+
/** The firewall host this forward is installed on (config.firewallIp). */
|
|
463
|
+
firewallIp: text('firewall_ip').notNull(),
|
|
464
|
+
/** Backend IP the forward targets. */
|
|
465
|
+
internalIp: text('internal_ip').notNull(),
|
|
466
|
+
/** Port — external == internal (no translation today). */
|
|
467
|
+
port: integer('port').notNull(),
|
|
468
|
+
protocol: text('protocol').$type<'TCP' | 'UDP'>().notNull(),
|
|
469
|
+
/** Dedicated INTERNAL ingress IP (ISS-0156); NULL for the normal public path. */
|
|
470
|
+
ingressIp: text('ingress_ip'),
|
|
471
|
+
description: text('description').notNull().default(''),
|
|
472
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull().default(sql`(unixepoch())`),
|
|
473
|
+
},
|
|
474
|
+
(table) => ({
|
|
475
|
+
// One row per (firewall, backend, port, protocol, ingress). The store also
|
|
476
|
+
// delete-then-inserts on this tuple so a re-expose is an idempotent upsert
|
|
477
|
+
// (NULL ingress_ip is compared with `is null` there, since SQLite treats
|
|
478
|
+
// NULLs as distinct in a unique index).
|
|
479
|
+
forwardUnique: uniqueIndex('port_forwards_unique_idx').on(
|
|
480
|
+
table.firewallIp,
|
|
481
|
+
table.internalIp,
|
|
482
|
+
table.port,
|
|
483
|
+
table.protocol,
|
|
484
|
+
table.ingressIp,
|
|
485
|
+
),
|
|
486
|
+
}),
|
|
487
|
+
);
|
|
488
|
+
|
|
446
489
|
/**
|
|
447
490
|
* DNS registration ledger — one row per (provider, fqdn) the framework
|
|
448
491
|
* has successfully registered via dns_registrar.registerHost. Written
|
|
@@ -509,7 +552,7 @@ export const dnsInternalRecords = sqliteTable(
|
|
|
509
552
|
/**
|
|
510
553
|
* In-zone split-horizon answer (caddy's zone-routable IP), when this is a
|
|
511
554
|
* caddy-fronted hostname that needs source-based views (ISS-0156,
|
|
512
|
-
*
|
|
555
|
+
* openspec/specs/internal-dns-zone-views/spec.md). NULL for records with no zone override
|
|
513
556
|
* (per-system identity, plain A records). This column is the durable
|
|
514
557
|
* desired-state the resolver's view config is reconciled from.
|
|
515
558
|
*/
|
|
@@ -621,7 +664,7 @@ export const moduleOperations = sqliteTable('module_operations', {
|
|
|
621
664
|
* per module version. Cascade delete on module removal so stale
|
|
622
665
|
* approvals don't linger.
|
|
623
666
|
*
|
|
624
|
-
* See
|
|
667
|
+
* See openspec/specs/base-module-aspects/spec.md D2 + D7.
|
|
625
668
|
*/
|
|
626
669
|
export const aspectApprovals = sqliteTable(
|
|
627
670
|
'aspect_approvals',
|
|
@@ -655,7 +698,7 @@ export const aspectApprovals = sqliteTable(
|
|
|
655
698
|
);
|
|
656
699
|
|
|
657
700
|
/**
|
|
658
|
-
* Remote API principals (see
|
|
701
|
+
* Remote API principals (see openspec/changes/replace-ssh-cli-api/proposal.md).
|
|
659
702
|
*
|
|
660
703
|
* Each row is one API identity: a name, one SSH public key, and the set of
|
|
661
704
|
* operations it may run. celilo renders these into the API account's
|
|
@@ -47,12 +47,13 @@ export default defineCapabilityFunction({
|
|
|
47
47
|
`;
|
|
48
48
|
|
|
49
49
|
// Mock iptables firewall factory — kept on the legacy
|
|
50
|
-
// `createFirewall(config, upstreamFirewall)` shape because its
|
|
50
|
+
// `createFirewall(config, store, upstreamFirewall, logger)` shape because its
|
|
51
51
|
// upstream-injection signature doesn't fit the
|
|
52
52
|
// defineCapabilityFunction `{ config, secrets, logger }` context.
|
|
53
|
-
// buildFirewallChain
|
|
53
|
+
// buildFirewallChain injects the port-forward store (2nd arg) and wires the
|
|
54
|
+
// upstream (3rd arg) manually. The mock ignores store (no converge here).
|
|
54
55
|
const MOCK_IPTABLES_MODULE = `
|
|
55
|
-
export function createFirewall(config, upstreamFirewall) {
|
|
56
|
+
export function createFirewall(config, store, upstreamFirewall, logger) {
|
|
56
57
|
return {
|
|
57
58
|
exposeService: async (opts) => {
|
|
58
59
|
if (!upstreamFirewall) {
|
|
@@ -21,18 +21,20 @@ import type {
|
|
|
21
21
|
DnsInternalCapability,
|
|
22
22
|
DnsRegistrarCapability,
|
|
23
23
|
HookLogger,
|
|
24
|
+
PortForwardStore,
|
|
24
25
|
RouteOps,
|
|
25
26
|
RouteReadView,
|
|
26
27
|
} from '@celilo/capabilities';
|
|
27
28
|
import { and, eq } from 'drizzle-orm';
|
|
28
29
|
import type { DbClient } from '../db/client';
|
|
29
|
-
import { capabilities, modules, secrets, webRoutes } from '../db/schema';
|
|
30
|
+
import { capabilities, modules, secrets, systemConfig, webRoutes } from '../db/schema';
|
|
30
31
|
import { decryptSecret } from '../secrets/encryption';
|
|
31
32
|
import { getOrCreateMasterKey } from '../secrets/master-key';
|
|
32
33
|
import { emitWebRoutesChangedAndWait } from '../services/celilo-events';
|
|
33
34
|
import { getModuleSystems } from '../services/deployed-systems';
|
|
34
35
|
import { withDnsInternalLedger } from '../services/dns-internal-records';
|
|
35
36
|
import { withDnsRegistrationLedger } from '../services/dns-registrations';
|
|
37
|
+
import { buildPortForwardStore } from '../services/port-forwards';
|
|
36
38
|
import { resolveComputedFields } from '../variables/computed/evaluate';
|
|
37
39
|
import { containsComputedMarker } from '../variables/computed/marker';
|
|
38
40
|
import { buildProviderLookup } from '../variables/computed/provider-lookup';
|
|
@@ -288,6 +290,10 @@ export async function loadCapabilityFunctions(
|
|
|
288
290
|
exported,
|
|
289
291
|
providerConfig,
|
|
290
292
|
providerSecrets,
|
|
293
|
+
// Single firewall provider (no upstream chain) still needs the injected
|
|
294
|
+
// port-forward store — the chain path isn't taken when there's one provider.
|
|
295
|
+
capName === 'firewall' ? buildPortForwardStore(db) : undefined,
|
|
296
|
+
capName === 'firewall' ? loadFirewallZones(db) : undefined,
|
|
291
297
|
);
|
|
292
298
|
|
|
293
299
|
if (capabilityInterface) {
|
|
@@ -326,6 +332,17 @@ export async function loadCapabilityFunctions(
|
|
|
326
332
|
debugLog(`public_web: using firewall natIp ${firewallNatIp} for internal DNS`);
|
|
327
333
|
}
|
|
328
334
|
|
|
335
|
+
// Caddy's external (WAN) IP for the D1 public A record (M1 / #328). Caddy
|
|
336
|
+
// learns it from its own firewall.exposeService at install and persists it
|
|
337
|
+
// as the `public_ip` hook output (stored as a caddy secret). register_route
|
|
338
|
+
// points a new hostname's PUBLIC record here, so it's reachable from one
|
|
339
|
+
// deploy — no per-hostname DNAT (shared :443 ingress).
|
|
340
|
+
const caddyExternalIp =
|
|
341
|
+
typeof providerSecrets.public_ip === 'string' ? providerSecrets.public_ip : undefined;
|
|
342
|
+
if (caddyExternalIp) {
|
|
343
|
+
debugLog(`public_web: using caddy externalIp ${caddyExternalIp} for public DNS`);
|
|
344
|
+
}
|
|
345
|
+
|
|
329
346
|
// Caddy's configured hostnames — public_web rejects routes for any
|
|
330
347
|
// hostname not in this list, throwing a structured error that runs
|
|
331
348
|
// caddy's `managed_hostname` ensure interview. Empty list means
|
|
@@ -437,6 +454,8 @@ export async function loadCapabilityFunctions(
|
|
|
437
454
|
routeOps,
|
|
438
455
|
dnsInternal: result.dns_internal as DnsInternalCapability | undefined,
|
|
439
456
|
firewallNatIp,
|
|
457
|
+
dnsRegistrar: result.dns_registrar as DnsRegistrarCapability | undefined,
|
|
458
|
+
externalIp: caddyExternalIp,
|
|
440
459
|
hostnames: caddyHostnames,
|
|
441
460
|
caddyModuleId: provider.moduleId,
|
|
442
461
|
dnsManagedDomains,
|
|
@@ -525,16 +544,28 @@ function buildCapabilityInterface(
|
|
|
525
544
|
factory: (...args: unknown[]) => unknown,
|
|
526
545
|
config: Record<string, unknown>,
|
|
527
546
|
_secrets: Record<string, string>,
|
|
547
|
+
store?: PortForwardStore,
|
|
548
|
+
zones?: FirewallZones,
|
|
528
549
|
): unknown {
|
|
529
550
|
if (capabilityName === 'firewall') {
|
|
530
|
-
// iptables firewall factory:
|
|
531
|
-
//
|
|
532
|
-
// another
|
|
551
|
+
// iptables firewall factory: NAT config + the injected port-forward store
|
|
552
|
+
// (createFirewall(config, store, upstream?, logger?)). Upstream is injected
|
|
553
|
+
// by buildFirewallChain when this module is downstream of another provider.
|
|
554
|
+
// The firewall is only built through buildFirewallChain, which always passes
|
|
555
|
+
// a store — guard so a mis-route surfaces loudly rather than as store=undefined.
|
|
533
556
|
if (config.firewall_ip && config.nat_ip) {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
557
|
+
if (!store) {
|
|
558
|
+
throw new Error('firewall capability requires an injected port-forward store');
|
|
559
|
+
}
|
|
560
|
+
return factory(
|
|
561
|
+
{
|
|
562
|
+
firewallIp: config.firewall_ip as string,
|
|
563
|
+
natIp: config.nat_ip as string,
|
|
564
|
+
zoneTiers: zones?.zoneTiers ?? [],
|
|
565
|
+
trustedSubnets: zones?.trustedSubnets ?? [],
|
|
566
|
+
},
|
|
567
|
+
store,
|
|
568
|
+
);
|
|
538
569
|
}
|
|
539
570
|
return null;
|
|
540
571
|
}
|
|
@@ -624,6 +655,40 @@ async function resolveRegistrarDomainList(
|
|
|
624
655
|
* on the legacy `createFirewall(config, upstream)` path because its
|
|
625
656
|
* upstream-injection signature doesn't fit the new shape.
|
|
626
657
|
*/
|
|
658
|
+
/** Most-exposed → most-protected SEGMENTED tiers for the firewall default-DROP matrix. */
|
|
659
|
+
const ZONE_TIER_ORDER = ['dmz', 'app', 'secure'] as const;
|
|
660
|
+
|
|
661
|
+
interface FirewallZones {
|
|
662
|
+
/** Ordered segmented tiers (dmz→app→secure adjacency) for the data-plane matrix. */
|
|
663
|
+
zoneTiers: Array<{ name: string; subnet: string }>;
|
|
664
|
+
/** Trusted LAN subnets (internal) that reach every tier — celilo's control plane. */
|
|
665
|
+
trustedSubnets: string[];
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function readZoneSubnet(db: DbClient, zone: string): string | undefined {
|
|
669
|
+
const row = db
|
|
670
|
+
.select()
|
|
671
|
+
.from(systemConfig)
|
|
672
|
+
.where(eq(systemConfig.key, `network.${zone}.subnet`))
|
|
673
|
+
.get();
|
|
674
|
+
return row?.value ?? undefined;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Read the firewall zone matrix inputs from system config (network.<zone>.subnet):
|
|
679
|
+
* the segmented tiers [dmz, app, secure] and the trusted internal LAN. Zones with
|
|
680
|
+
* no configured subnet are omitted — their traffic stays denied (fail-closed).
|
|
681
|
+
*/
|
|
682
|
+
function loadFirewallZones(db: DbClient): FirewallZones {
|
|
683
|
+
const zoneTiers: Array<{ name: string; subnet: string }> = [];
|
|
684
|
+
for (const zone of ZONE_TIER_ORDER) {
|
|
685
|
+
const subnet = readZoneSubnet(db, zone);
|
|
686
|
+
if (subnet) zoneTiers.push({ name: zone, subnet });
|
|
687
|
+
}
|
|
688
|
+
const internal = readZoneSubnet(db, 'internal');
|
|
689
|
+
return { zoneTiers, trustedSubnets: internal ? [internal] : [] };
|
|
690
|
+
}
|
|
691
|
+
|
|
627
692
|
async function buildFirewallChain(
|
|
628
693
|
allProviders: Array<{
|
|
629
694
|
id: number;
|
|
@@ -638,6 +703,13 @@ async function buildFirewallChain(
|
|
|
638
703
|
logger: HookLogger,
|
|
639
704
|
debugLog: (msg: string) => void,
|
|
640
705
|
): Promise<unknown> {
|
|
706
|
+
// The shared-core port-forward registry, injected into every firewall provider
|
|
707
|
+
// in the chain so exposeService/converge reconcile against the one canonical
|
|
708
|
+
// store (openspec/changes/unified-management-no-ssh/proposal.md).
|
|
709
|
+
const store = buildPortForwardStore(db);
|
|
710
|
+
// Zone matrix for the default-DROP posture — same set for every layer in the chain.
|
|
711
|
+
const zones = loadFirewallZones(db);
|
|
712
|
+
|
|
641
713
|
// Find the provider with has_external (the leaf — has direct internet access)
|
|
642
714
|
const hasExternal = allProviders.find((p) => {
|
|
643
715
|
const data = (typeof p.data === 'string' ? JSON.parse(p.data) : p.data) as Record<
|
|
@@ -686,7 +758,14 @@ async function buildFirewallChain(
|
|
|
686
758
|
);
|
|
687
759
|
} else {
|
|
688
760
|
// Legacy factory path
|
|
689
|
-
leafFirewall = buildCapabilityInterface(
|
|
761
|
+
leafFirewall = buildCapabilityInterface(
|
|
762
|
+
'firewall',
|
|
763
|
+
leafExported,
|
|
764
|
+
leafConfig,
|
|
765
|
+
leafSecrets,
|
|
766
|
+
store,
|
|
767
|
+
zones,
|
|
768
|
+
);
|
|
690
769
|
if (leafFirewall) {
|
|
691
770
|
leafFirewall = wrapWithLogging(leafFirewall as object, logger, 'firewall');
|
|
692
771
|
}
|
|
@@ -741,7 +820,18 @@ async function buildFirewallChain(
|
|
|
741
820
|
// celilo's ProgressDisplay instead of dumping to stderr. Older
|
|
742
821
|
// iptables modules ignore it — the factory's signature is
|
|
743
822
|
// backward-compatible.
|
|
744
|
-
const downstreamFirewall = provFactory(
|
|
823
|
+
const downstreamFirewall = provFactory(
|
|
824
|
+
{
|
|
825
|
+
firewallIp,
|
|
826
|
+
natIp,
|
|
827
|
+
dryRun,
|
|
828
|
+
zoneTiers: zones.zoneTiers,
|
|
829
|
+
trustedSubnets: zones.trustedSubnets,
|
|
830
|
+
},
|
|
831
|
+
store,
|
|
832
|
+
currentUpstream,
|
|
833
|
+
logger,
|
|
834
|
+
);
|
|
745
835
|
|
|
746
836
|
debugLog(`firewall chain: wired ${provider.moduleId} → ${hasExternal.moduleId}`);
|
|
747
837
|
// Wrap each downstream layer with auto-logging.
|
|
@@ -95,6 +95,12 @@ const fakeIdp: IdpCapability = {
|
|
|
95
95
|
async create_token() {
|
|
96
96
|
return { token: 'fake-token', created: true };
|
|
97
97
|
},
|
|
98
|
+
async list_tokens() {
|
|
99
|
+
return [];
|
|
100
|
+
},
|
|
101
|
+
async revoke_token() {
|
|
102
|
+
return { revoked: true };
|
|
103
|
+
},
|
|
98
104
|
};
|
|
99
105
|
|
|
100
106
|
describe('defineHook', () => {
|
|
@@ -260,6 +266,12 @@ describe('defineCapabilityFunction', () => {
|
|
|
260
266
|
async create_token() {
|
|
261
267
|
return { token: 'fake-token', created: true };
|
|
262
268
|
},
|
|
269
|
+
async list_tokens() {
|
|
270
|
+
return [];
|
|
271
|
+
},
|
|
272
|
+
async revoke_token() {
|
|
273
|
+
return { revoked: true };
|
|
274
|
+
},
|
|
263
275
|
}),
|
|
264
276
|
});
|
|
265
277
|
|
|
@@ -323,6 +335,12 @@ describe('defineCapabilityFunction', () => {
|
|
|
323
335
|
async create_token(): Promise<{ token: string; created: boolean }> {
|
|
324
336
|
return { token: 'tok', created: true };
|
|
325
337
|
},
|
|
338
|
+
async list_tokens() {
|
|
339
|
+
return [];
|
|
340
|
+
},
|
|
341
|
+
async revoke_token(): Promise<{ revoked: boolean }> {
|
|
342
|
+
return { revoked: true };
|
|
343
|
+
},
|
|
326
344
|
};
|
|
327
345
|
},
|
|
328
346
|
});
|
|
@@ -459,6 +477,12 @@ void defineCapabilityFunction({
|
|
|
459
477
|
async create_token() {
|
|
460
478
|
return { token: 'x', created: true };
|
|
461
479
|
},
|
|
480
|
+
async list_tokens() {
|
|
481
|
+
return [];
|
|
482
|
+
},
|
|
483
|
+
async revoke_token() {
|
|
484
|
+
return { revoked: true };
|
|
485
|
+
},
|
|
462
486
|
}),
|
|
463
487
|
});
|
|
464
488
|
|
|
@@ -469,7 +469,7 @@ describe('Hook Executor', () => {
|
|
|
469
469
|
// cross_module_read is a privilege granted by the framework to the
|
|
470
470
|
// hooks that use it (backup/restore), not a provider-loaded
|
|
471
471
|
// capability. It must not block other hooks like on_install
|
|
472
|
-
// (regression: celilo-mgmt deploy,
|
|
472
|
+
// (regression: celilo-mgmt deploy, openspec/specs/progressive-zone-disclosure/spec.md).
|
|
473
473
|
expect(checkRequiredCapabilities('on_install', ['cross_module_read'], {})).toBeNull();
|
|
474
474
|
// Real missing providers still flagged alongside a privilege.
|
|
475
475
|
const mixed = checkRequiredCapabilities('on_install', ['cross_module_read', 'idp'], {});
|
package/src/hooks/executor.ts
CHANGED
|
@@ -202,7 +202,7 @@ async function runScript(
|
|
|
202
202
|
|
|
203
203
|
if (!isCompiledHook(module.default)) {
|
|
204
204
|
throw new Error(
|
|
205
|
-
`Hook script ${scriptPath} does not use defineHook(). As of HOOK_API_V2 Phase 8, all hook scripts must wrap their handler with defineHook from @celilo/capabilities so the executor can verify the brand and apply pre-flight checks. See
|
|
205
|
+
`Hook script ${scriptPath} does not use defineHook(). As of HOOK_API_V2 Phase 8, all hook scripts must wrap their handler with defineHook from @celilo/capabilities so the executor can verify the brand and apply pre-flight checks. See reference/MODULE_DEVELOPMENT_GUIDE.md "Hooks" section for the migration pattern.`,
|
|
206
206
|
);
|
|
207
207
|
}
|
|
208
208
|
|
|
@@ -263,7 +263,7 @@ export interface InvokeHookOptions {
|
|
|
263
263
|
capabilities?: Record<string, unknown>;
|
|
264
264
|
/**
|
|
265
265
|
* The module's 0..N deployed systems, injected as `ctx.systems`
|
|
266
|
-
* (
|
|
266
|
+
* (openspec/specs/module-systems-addressing/spec.md). Callers load these from the DB via
|
|
267
267
|
* `getModuleSystems` — the executor stays decoupled from the database, the
|
|
268
268
|
* same way `capabilities` is loaded by the caller. Defaults to `[]`.
|
|
269
269
|
*/
|
package/src/hooks/types.ts
CHANGED
|
@@ -55,7 +55,7 @@ export interface HookContext {
|
|
|
55
55
|
/** Module secret values (decrypted) */
|
|
56
56
|
secrets: Record<string, string>;
|
|
57
57
|
/**
|
|
58
|
-
* The 0..N systems this module has deployed onto (
|
|
58
|
+
* The 0..N systems this module has deployed onto (openspec/specs/module-systems-addressing/spec.md).
|
|
59
59
|
* Always an array — no singular convenience, so the 0/1/N reality stays visible.
|
|
60
60
|
*/
|
|
61
61
|
systems: DeployedSystem[];
|
|
@@ -145,7 +145,7 @@ export const V1_HOOKS: ContractHooks = {
|
|
|
145
145
|
},
|
|
146
146
|
},
|
|
147
147
|
/**
|
|
148
|
-
* Per-system DNS lifecycle hook (
|
|
148
|
+
* Per-system DNS lifecycle hook (openspec/specs/internal-dns-split-horizon/spec.md D5).
|
|
149
149
|
*
|
|
150
150
|
* A dns_internal provider declares this hook; celilo's internal-dns bridge
|
|
151
151
|
* invokes it once per host when a system.created/destroyed event fires,
|
|
@@ -198,7 +198,7 @@ export const V1_HOOKS: ContractHooks = {
|
|
|
198
198
|
* Build-bus upstream publish hook. The executor passes the
|
|
199
199
|
* PublishEvent fields as env vars (CELILO_EVENT_PAYLOAD,
|
|
200
200
|
* CELILO_EVENT_PACKAGE_NAME, etc.) rather than as named inputs
|
|
201
|
-
* here — see
|
|
201
|
+
* here — see openspec/changes/build-bus-poll-cd/proposal.md Phase 4 + the hook-dispatch executor.
|
|
202
202
|
* No structured outputs; hook reports success via exit code.
|
|
203
203
|
*/
|
|
204
204
|
on_upstream_publish: {
|