@celilo/cli 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/AGENTS.md +7 -4
  2. package/CELILO_CORE_MODULES.md +7 -6
  3. package/CELILO_SUBSYSTEMS.md +52 -6
  4. package/MODULE_PRIMITIVES.md +164 -0
  5. package/README.md +5 -5
  6. package/drizzle/0015_port_forwards.sql +12 -0
  7. package/drizzle/meta/_journal.json +7 -0
  8. package/package.json +13 -5
  9. package/schemas/system_config.json +1 -1
  10. package/src/ansible/inventory.test.ts +1 -1
  11. package/src/ansible/inventory.ts +3 -3
  12. package/src/api/protocol.test.ts +1 -1
  13. package/src/api/remote-client.test.ts +1 -1
  14. package/src/api/serve.ts +5 -5
  15. package/src/capabilities/public-web-publish.test.ts +209 -0
  16. package/src/capabilities/validation.test.ts +1 -1
  17. package/src/capabilities/well-known.test.ts +1 -1
  18. package/src/capabilities/well-known.ts +2 -2
  19. package/src/cli/command-tree-parser.ts +3 -1
  20. package/src/cli/commands/api.ts +1 -1
  21. package/src/cli/commands/apt-upgrade.test.ts +33 -0
  22. package/src/cli/commands/apt-upgrade.ts +63 -0
  23. package/src/cli/commands/backup-pull.ts +1 -1
  24. package/src/cli/commands/commands-json.ts +29 -0
  25. package/src/cli/commands/completion.ts +1 -1
  26. package/src/cli/commands/events.ts +1 -1
  27. package/src/cli/commands/module-changeset.ts +1 -1
  28. package/src/cli/commands/module-import.ts +1 -1
  29. package/src/cli/commands/module-list.ts +16 -2
  30. package/src/cli/commands/module-publish.ts +1 -1
  31. package/src/cli/commands/module-remove.ts +26 -1
  32. package/src/cli/commands/module-types.ts +1 -1
  33. package/src/cli/commands/module-upgrade.ts +1 -1
  34. package/src/cli/commands/module-version.ts +1 -1
  35. package/src/cli/commands/module-where.test.ts +26 -0
  36. package/src/cli/commands/module-where.ts +130 -0
  37. package/src/cli/commands/proxmox-node-list.ts +1 -1
  38. package/src/cli/commands/publish/alpha.test.ts +1 -1
  39. package/src/cli/commands/publish/alpha.ts +3 -3
  40. package/src/cli/commands/publish/changesets.ts +1 -1
  41. package/src/cli/commands/publish/global-install.ts +6 -2
  42. package/src/cli/commands/publish/helpers.ts +21 -14
  43. package/src/cli/commands/publish/index.ts +4 -4
  44. package/src/cli/commands/publish/plan.ts +3 -3
  45. package/src/cli/commands/publish/preflight.ts +2 -2
  46. package/src/cli/commands/publish/types.ts +2 -2
  47. package/src/cli/commands/publish/workspace.test.ts +1 -1
  48. package/src/cli/commands/publish/workspace.ts +2 -2
  49. package/src/cli/commands/registry-owner.test.ts +166 -0
  50. package/src/cli/commands/registry-owner.ts +124 -0
  51. package/src/cli/commands/registry-token.test.ts +109 -0
  52. package/src/cli/commands/registry-token.ts +194 -0
  53. package/src/cli/commands/restore.ts +1 -1
  54. package/src/cli/commands/service-add-proxmox.ts +1 -1
  55. package/src/cli/commands/service-list.ts +15 -2
  56. package/src/cli/commands/subscribers-list.ts +1 -1
  57. package/src/cli/commands/system-apply-config-equivalence.test.ts +1 -1
  58. package/src/cli/commands/system-apply-config.ts +1 -1
  59. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  60. package/src/cli/commands/system-init.ts +2 -2
  61. package/src/cli/commands/token.test.ts +26 -0
  62. package/src/cli/commands/token.ts +160 -0
  63. package/src/cli/completion.ts +21 -0
  64. package/src/cli/generate-zsh-completion.test.ts +22 -4
  65. package/src/cli/generate-zsh-completion.ts +7 -3
  66. package/src/cli/index.ts +170 -7
  67. package/src/cli/parser.ts +1 -1
  68. package/src/cli/restore-command.test.ts +1 -1
  69. package/src/db/client.ts +1 -1
  70. package/src/db/schema.ts +50 -7
  71. package/src/hooks/capability-loader-firewall.test.ts +4 -3
  72. package/src/hooks/capability-loader.ts +100 -10
  73. package/src/hooks/define-hook.test.ts +24 -0
  74. package/src/hooks/executor.test.ts +1 -1
  75. package/src/hooks/executor.ts +2 -2
  76. package/src/hooks/types.ts +1 -1
  77. package/src/manifest/contracts/v1.ts +2 -2
  78. package/src/manifest/schema.ts +13 -13
  79. package/src/manifest/template-validator.ts +1 -1
  80. package/src/module/packaging/build.ts +19 -0
  81. package/src/module/packaging/workspace-deps.test.ts +94 -0
  82. package/src/module/packaging/workspace-deps.ts +185 -0
  83. package/src/module/versioning/changeset-version.ts +1 -1
  84. package/src/policy/no-hand-built-ssh.test.ts +90 -0
  85. package/src/registry/client.test.ts +86 -0
  86. package/src/registry/client.ts +67 -1
  87. package/src/services/api-access.ts +1 -1
  88. package/src/services/aspect-approvals.ts +1 -1
  89. package/src/services/aspect-runner.ts +3 -3
  90. package/src/services/aspect-template-resolver.test.ts +1 -1
  91. package/src/services/aspect-template-resolver.ts +1 -1
  92. package/src/services/build-bus/delivery-events.ts +1 -1
  93. package/src/services/build-bus/fan-out.ts +1 -1
  94. package/src/services/build-bus/hook-dispatch.ts +1 -1
  95. package/src/services/build-bus/receiver-server.ts +1 -1
  96. package/src/services/build-bus/status.test.ts +1 -1
  97. package/src/services/build-bus/status.ts +1 -1
  98. package/src/services/build-bus/subscriber-store.ts +1 -1
  99. package/src/services/bus-interview.ts +1 -1
  100. package/src/services/celilo-events.ts +1 -1
  101. package/src/services/celilo-mgmt-hooks.test.ts +1 -1
  102. package/src/services/cross-module-read.ts +1 -1
  103. package/src/services/deploy-posture.ts +1 -1
  104. package/src/services/deployed-systems.test.ts +1 -1
  105. package/src/services/deployed-systems.ts +4 -4
  106. package/src/services/dns-provider-backfill.ts +2 -2
  107. package/src/services/events-daemon.ts +1 -1
  108. package/src/services/machine-pool.ts +3 -3
  109. package/src/services/module-deploy.ts +93 -8
  110. package/src/services/module-subscriptions.ts +1 -1
  111. package/src/services/module-types-generator.ts +1 -1
  112. package/src/services/module-validator/git-hygiene.ts +1 -1
  113. package/src/services/port-forwards.test.ts +92 -0
  114. package/src/services/port-forwards.ts +86 -0
  115. package/src/services/proxmox-reconcile.ts +2 -2
  116. package/src/services/public-web-republish.test.ts +189 -0
  117. package/src/services/public-web-republish.ts +84 -0
  118. package/src/services/restore-from-file.ts +1 -1
  119. package/src/services/restore-preflight.ts +1 -1
  120. package/src/services/system-identity.ts +1 -1
  121. package/src/services/system-init.test.ts +2 -2
  122. package/src/services/system-init.ts +2 -2
  123. package/src/services/web-route-cleanup.test.ts +250 -0
  124. package/src/services/web-route-cleanup.ts +144 -0
  125. package/src/templates/generator.ts +2 -2
  126. package/src/types/infrastructure.ts +1 -1
  127. package/src/variables/computed/computed-integration.test.ts +1 -1
  128. package/src/variables/computed/evaluate.ts +1 -1
  129. package/src/variables/computed/parse.ts +1 -1
  130. package/src/variables/context.test.ts +1 -1
  131. package/src/variables/context.ts +5 -5
  132. package/src/variables/lxc-nameserver.test.ts +1 -1
  133. package/src/variables/resolver.ts +2 -2
  134. package/src/variables/types.ts +1 -1
  135. package/src/api/protocol.ts +0 -159
  136. package/src/api/remote-client.ts +0 -218
  137. package/src/cli/command-registry.ts +0 -1488
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Registry module-ownership administration (ce-1ch, SECURE_MODULE_PUBLISH.md D-C/D-E).
3
+ *
4
+ * The registry-server owns the allow/deny decision; these commands are the
5
+ * first-class operator front door for the module-owner table:
6
+ * celilo registry owner list — dump the whole table
7
+ * celilo registry owner show <module> — who owns one module name
8
+ * celilo registry owner set <module> <sub> — reassign (admin overwrite)
9
+ *
10
+ * Auth: the admin/bootstrap publish token is read from the local
11
+ * publish_tokens list (resolveAdminToken) — these run on-mgr where that secret
12
+ * lives, never resurfacing the raw admin value to a laptop. The registry URL
13
+ * comes from --registry / CELILO_REGISTRY_URL / the built-in default.
14
+ */
15
+
16
+ import { RegistryClient } from '../../registry/client';
17
+ import { getArg, getFlag, validateRequiredArgs } from '../parser';
18
+ import type { CommandResult } from '../types';
19
+ import { resolveAdminToken } from './registry-token';
20
+
21
+ type CommandError = Extract<CommandResult, { success: false }>;
22
+
23
+ function isError(v: unknown): v is CommandError {
24
+ return typeof v === 'object' && v !== null && 'success' in v && v.success === false;
25
+ }
26
+
27
+ function clientFrom(flags: Record<string, string | boolean>): RegistryClient {
28
+ const registryUrl = getFlag(flags, 'registry', '');
29
+ return new RegistryClient(registryUrl || undefined);
30
+ }
31
+
32
+ function registryError(err: unknown): CommandError {
33
+ return {
34
+ success: false,
35
+ error: `Registry request failed: ${err instanceof Error ? err.message : String(err)}`,
36
+ };
37
+ }
38
+
39
+ /** `celilo registry owner list` — dump the module-owner table. */
40
+ export async function handleRegistryOwnerList(
41
+ flags: Record<string, string | boolean>,
42
+ ): Promise<CommandResult> {
43
+ const token = await resolveAdminToken();
44
+ if (isError(token)) return token;
45
+
46
+ const client = clientFrom(flags);
47
+ try {
48
+ const owners = await client.listOwners(token);
49
+ if (owners.length === 0) {
50
+ return { success: true, message: 'No module ownerships recorded yet' };
51
+ }
52
+ const nameWidth = Math.max(6, ...owners.map((o) => o.moduleName.length));
53
+ const subWidth = Math.max(5, ...owners.map((o) => o.ownerSub.length));
54
+ const header = `${'MODULE'.padEnd(nameWidth)} ${'OWNER'.padEnd(subWidth)} CLAIMED`;
55
+ const rows = owners
56
+ .slice()
57
+ .sort((a, b) => a.moduleName.localeCompare(b.moduleName))
58
+ .map(
59
+ (o) => `${o.moduleName.padEnd(nameWidth)} ${o.ownerSub.padEnd(subWidth)} ${o.claimedAt}`,
60
+ );
61
+ return { success: true, message: [header, ...rows].join('\n') };
62
+ } catch (err) {
63
+ return registryError(err);
64
+ }
65
+ }
66
+
67
+ /** `celilo registry owner show <module>` — the owner of one module name. */
68
+ export async function handleRegistryOwnerShow(
69
+ args: string[],
70
+ flags: Record<string, string | boolean>,
71
+ ): Promise<CommandResult> {
72
+ const argErr = validateRequiredArgs(args, 1);
73
+ if (argErr) {
74
+ return { success: false, error: `${argErr}\n\nUsage: celilo registry owner show <module>` };
75
+ }
76
+ const name = getArg(args, 0)?.trim();
77
+ if (!name) return { success: false, error: 'Module name is required' };
78
+
79
+ const token = await resolveAdminToken();
80
+ if (isError(token)) return token;
81
+
82
+ const client = clientFrom(flags);
83
+ try {
84
+ const owner = await client.getOwner(name, token);
85
+ if (!owner) {
86
+ return { success: true, message: `${name} is unclaimed (first verified publisher owns it)` };
87
+ }
88
+ return {
89
+ success: true,
90
+ message: `${name}\n owner: ${owner.ownerSub}\n claimed: ${owner.claimedAt}\n via: ${owner.sourceGroup}`,
91
+ };
92
+ } catch (err) {
93
+ return registryError(err);
94
+ }
95
+ }
96
+
97
+ /** `celilo registry owner set <module> <sub>` — admin reassignment. */
98
+ export async function handleRegistryOwnerSet(
99
+ args: string[],
100
+ flags: Record<string, string | boolean>,
101
+ ): Promise<CommandResult> {
102
+ const argErr = validateRequiredArgs(args, 2);
103
+ if (argErr) {
104
+ return {
105
+ success: false,
106
+ error: `${argErr}\n\nUsage: celilo registry owner set <module> <owner-sub>`,
107
+ };
108
+ }
109
+ const name = getArg(args, 0)?.trim();
110
+ const ownerSub = getArg(args, 1)?.trim();
111
+ if (!name) return { success: false, error: 'Module name is required' };
112
+ if (!ownerSub) return { success: false, error: 'Owner subject is required' };
113
+
114
+ const token = await resolveAdminToken();
115
+ if (isError(token)) return token;
116
+
117
+ const client = clientFrom(flags);
118
+ try {
119
+ const owner = await client.setOwner(name, ownerSub, token);
120
+ return { success: true, message: `Reassigned ${owner.moduleName} → ${owner.ownerSub}` };
121
+ } catch (err) {
122
+ return registryError(err);
123
+ }
124
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Regression tests for F2 (SECURE_MODULE_PUBLISH.md): `module secret set
3
+ * celilo-registry publish_tokens` clobbers the whole newline-separated list.
4
+ * `registry token add`/`rm` must read-modify-write instead.
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
8
+ import { existsSync } from 'node:fs';
9
+ import { mkdtemp, rm } from 'node:fs/promises';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import { closeDb, getDb } from '../../db/client';
13
+ import { modules } from '../../db/schema';
14
+ import { CrossModuleDataManager } from '../../services/cross-module-data-manager';
15
+ import { handleRegistryTokenAdd, handleRegistryTokenRm } from './registry-token';
16
+
17
+ /** Read back the stored publish_tokens list via the production read path. */
18
+ async function readStoredTokens(): Promise<string[]> {
19
+ const manager = new CrossModuleDataManager();
20
+ await manager.initialize();
21
+ const raw = manager.getSecret('celilo-registry', 'publish_tokens');
22
+ if (!raw) return [];
23
+ return raw
24
+ .split('\n')
25
+ .map((l) => l.trim())
26
+ .filter((l) => l.length > 0);
27
+ }
28
+
29
+ describe('registry token add/rm (F2 clobber fix)', () => {
30
+ let dataDir: string;
31
+
32
+ beforeEach(async () => {
33
+ dataDir = await mkdtemp(join(tmpdir(), 'celilo-registry-token-'));
34
+ process.env.CELILO_DATA_DIR = dataDir;
35
+ process.env.CELILO_DB_PATH = join(dataDir, 'celilo.db');
36
+
37
+ // Seed the celilo-registry module row (FK target for the secrets row).
38
+ getDb()
39
+ .insert(modules)
40
+ .values({
41
+ id: 'celilo-registry',
42
+ name: 'Celilo Registry',
43
+ version: '1.0.0',
44
+ sourcePath: '/test',
45
+ manifestData: {},
46
+ })
47
+ .run();
48
+ });
49
+
50
+ afterEach(async () => {
51
+ closeDb();
52
+ process.env.CELILO_DATA_DIR = undefined;
53
+ process.env.CELILO_DB_PATH = undefined;
54
+ if (existsSync(dataDir)) {
55
+ await rm(dataDir, { recursive: true, force: true });
56
+ }
57
+ });
58
+
59
+ test('add appends without clobbering existing tokens (F2 regression)', async () => {
60
+ await handleRegistryTokenAdd(['token-a']);
61
+ await handleRegistryTokenAdd(['token-b']);
62
+ await handleRegistryTokenAdd(['token-c']);
63
+
64
+ const result = await handleRegistryTokenAdd(['token-d']);
65
+ expect(result.success).toBe(true);
66
+
67
+ // The whole point of F2: adding token-d must NOT wipe a/b/c.
68
+ expect((await readStoredTokens()).sort()).toEqual(['token-a', 'token-b', 'token-c', 'token-d']);
69
+ });
70
+
71
+ test('rm removes one line, leaves the others', async () => {
72
+ await handleRegistryTokenAdd(['token-a']);
73
+ await handleRegistryTokenAdd(['token-b']);
74
+ await handleRegistryTokenAdd(['token-c']);
75
+
76
+ const result = await handleRegistryTokenRm(['token-b']);
77
+ expect(result.success).toBe(true);
78
+
79
+ expect((await readStoredTokens()).sort()).toEqual(['token-a', 'token-c']);
80
+ });
81
+
82
+ test('add is idempotent — no duplicate line', async () => {
83
+ await handleRegistryTokenAdd(['token-a']);
84
+ const result = await handleRegistryTokenAdd(['token-a']);
85
+ expect(result.success).toBe(true);
86
+
87
+ expect(await readStoredTokens()).toEqual(['token-a']);
88
+ });
89
+
90
+ test('rm of an absent token errors', async () => {
91
+ await handleRegistryTokenAdd(['token-a']);
92
+ const result = await handleRegistryTokenRm(['token-x']);
93
+ expect(result.success).toBe(false);
94
+ });
95
+
96
+ test('add requires a token argument', async () => {
97
+ const result = await handleRegistryTokenAdd([]);
98
+ expect(result.success).toBe(false);
99
+ });
100
+
101
+ test('errors when celilo-registry module is not installed', async () => {
102
+ getDb().delete(modules).run();
103
+ const result = await handleRegistryTokenAdd(['token-a']);
104
+ expect(result.success).toBe(false);
105
+ if (!result.success) {
106
+ expect(result.error).toContain('celilo-registry');
107
+ }
108
+ });
109
+ });
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Registry publish-token management (append-safe)
3
+ *
4
+ * `celilo module secret set celilo-registry publish_tokens <v>` OVERWRITES the
5
+ * whole value. Because publish_tokens is a newline-separated list, that clobbers
6
+ * every other holder (external CI, scoped bootstrap tokens) whenever you rotate
7
+ * or add one token. These commands do a read-modify-write on the list instead.
8
+ *
9
+ * Runs on-mgr where the master key lives; never resurfaces the raw admin value
10
+ * to a laptop. Runtime-minted scoped tokens are handled elsewhere — this is the
11
+ * human-managed bootstrap/admin list only.
12
+ */
13
+
14
+ import { and, eq } from 'drizzle-orm';
15
+ import { getDb } from '../../db/client';
16
+ import { modules, secrets } from '../../db/schema';
17
+ import { decryptSecret, encryptSecret } from '../../secrets/encryption';
18
+ import { getOrCreateMasterKey } from '../../secrets/master-key';
19
+ import { getArg, validateRequiredArgs } from '../parser';
20
+ import type { CommandResult } from '../types';
21
+
22
+ const REGISTRY_MODULE_ID = 'celilo-registry';
23
+ const PUBLISH_TOKENS_SECRET = 'publish_tokens';
24
+
25
+ type CommandError = Extract<CommandResult, { success: false }>;
26
+
27
+ /** Split a newline-separated token blob into trimmed, non-empty lines. */
28
+ function parseTokens(raw: string | null): string[] {
29
+ if (!raw) return [];
30
+ return raw
31
+ .split('\n')
32
+ .map((line) => line.trim())
33
+ .filter((line) => line.length > 0);
34
+ }
35
+
36
+ interface TokenStore {
37
+ masterKey: Buffer;
38
+ /** Existing secrets row id, or null if publish_tokens isn't set yet. */
39
+ rowId: number | null;
40
+ tokens: string[];
41
+ }
42
+
43
+ /**
44
+ * Read the current publish_tokens list (decrypted). Errors if celilo-registry
45
+ * isn't installed locally — these commands must run where the module lives.
46
+ */
47
+ async function readTokenStore(): Promise<TokenStore | CommandError> {
48
+ const db = getDb();
49
+
50
+ const registryModule = db.select().from(modules).where(eq(modules.id, REGISTRY_MODULE_ID)).get();
51
+ if (!registryModule) {
52
+ return {
53
+ success: false,
54
+ error: `Module not found: ${REGISTRY_MODULE_ID}\n\nRun these commands on the host where celilo-registry is deployed (where the master key lives).`,
55
+ };
56
+ }
57
+
58
+ let masterKey: Buffer;
59
+ try {
60
+ masterKey = await getOrCreateMasterKey();
61
+ } catch (err) {
62
+ return { success: false, error: 'Failed to access master key', details: err };
63
+ }
64
+
65
+ const row = db
66
+ .select()
67
+ .from(secrets)
68
+ .where(and(eq(secrets.moduleId, REGISTRY_MODULE_ID), eq(secrets.name, PUBLISH_TOKENS_SECRET)))
69
+ .get();
70
+
71
+ const tokens = row
72
+ ? parseTokens(
73
+ decryptSecret(
74
+ { encryptedValue: row.encryptedValue, iv: row.iv, authTag: row.authTag },
75
+ masterKey,
76
+ ),
77
+ )
78
+ : [];
79
+
80
+ return { masterKey, rowId: row?.id ?? null, tokens };
81
+ }
82
+
83
+ function isError(v: TokenStore | CommandError): v is CommandError {
84
+ return 'success' in v && v.success === false;
85
+ }
86
+
87
+ /** Encrypt the list and upsert the publish_tokens row. */
88
+ function writeTokens(store: TokenStore, tokens: string[]): void {
89
+ const db = getDb();
90
+ const encrypted = encryptSecret(tokens.join('\n'), store.masterKey);
91
+
92
+ if (store.rowId !== null) {
93
+ db.update(secrets)
94
+ .set({
95
+ encryptedValue: encrypted.encryptedValue,
96
+ iv: encrypted.iv,
97
+ authTag: encrypted.authTag,
98
+ updatedAt: new Date(),
99
+ })
100
+ .where(eq(secrets.id, store.rowId))
101
+ .run();
102
+ return;
103
+ }
104
+
105
+ db.insert(secrets)
106
+ .values({
107
+ moduleId: REGISTRY_MODULE_ID,
108
+ name: PUBLISH_TOKENS_SECRET,
109
+ encryptedValue: encrypted.encryptedValue,
110
+ iv: encrypted.iv,
111
+ authTag: encrypted.authTag,
112
+ })
113
+ .run();
114
+ }
115
+
116
+ /**
117
+ * Resolve the local admin/bootstrap publish token (ce-1ch) — the first
118
+ * bootstrap line with no package scope (a bare `<token>`), which the admin
119
+ * owner-management endpoints require. Runs on-mgr where publish_tokens lives.
120
+ * Returns the raw token or a CommandError describing what's missing.
121
+ */
122
+ export async function resolveAdminToken(): Promise<string | CommandError> {
123
+ const store = await readTokenStore();
124
+ if (isError(store)) return store;
125
+ // A scoped line is `<token> <package>`; the admin/bootstrap token is a bare
126
+ // single-field line. Pick the first one.
127
+ const admin = store.tokens.find((line) => line.split(/\s+/).length === 1);
128
+ if (!admin) {
129
+ return {
130
+ success: false,
131
+ error:
132
+ 'No admin publish token found in publish_tokens.\n\nAdd one with: celilo registry token add <admin-token>',
133
+ };
134
+ }
135
+ return admin;
136
+ }
137
+
138
+ /**
139
+ * Handle `celilo registry token add <token>`.
140
+ * Appends a token to publish_tokens without clobbering existing holders.
141
+ * Idempotent — adding a token that's already present is a no-op.
142
+ */
143
+ export async function handleRegistryTokenAdd(args: string[]): Promise<CommandResult> {
144
+ const err = validateRequiredArgs(args, 1);
145
+ if (err) {
146
+ return { success: false, error: `${err}\n\nUsage: celilo registry token add <token>` };
147
+ }
148
+ const token = getArg(args, 0)?.trim();
149
+ if (!token) {
150
+ return { success: false, error: 'Token value is required' };
151
+ }
152
+
153
+ const store = await readTokenStore();
154
+ if (isError(store)) return store;
155
+
156
+ if (store.tokens.includes(token)) {
157
+ return { success: true, message: 'Token already present — no change' };
158
+ }
159
+
160
+ writeTokens(store, [...store.tokens, token]);
161
+ return {
162
+ success: true,
163
+ message: `Added publish token (${store.tokens.length + 1} total)`,
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Handle `celilo registry token rm <token>`.
169
+ * Removes one token from publish_tokens, leaving the others intact.
170
+ */
171
+ export async function handleRegistryTokenRm(args: string[]): Promise<CommandResult> {
172
+ const err = validateRequiredArgs(args, 1);
173
+ if (err) {
174
+ return { success: false, error: `${err}\n\nUsage: celilo registry token rm <token>` };
175
+ }
176
+ const token = getArg(args, 0)?.trim();
177
+ if (!token) {
178
+ return { success: false, error: 'Token value is required' };
179
+ }
180
+
181
+ const store = await readTokenStore();
182
+ if (isError(store)) return store;
183
+
184
+ if (!store.tokens.includes(token)) {
185
+ return { success: false, error: 'Token not found in publish_tokens' };
186
+ }
187
+
188
+ const remaining = store.tokens.filter((t) => t !== token);
189
+ writeTokens(store, remaining);
190
+ return {
191
+ success: true,
192
+ message: `Removed publish token (${remaining.length} remaining)`,
193
+ };
194
+ }
@@ -12,7 +12,7 @@
12
12
  * Calls restoreFromArtifactFile, then runs Drizzle migrations on the
13
13
  * restored DB so a backup from an older celilo still opens cleanly.
14
14
  *
15
- * Phase 4 of v2/SYSTEM_BACKUP_TERRAFORM_STATE.md.
15
+ * Phase 4 of openspec/specs/management-server-backup/spec.md.
16
16
  */
17
17
 
18
18
  import { existsSync } from 'node:fs';
@@ -146,7 +146,7 @@ export async function handleServiceAddProxmox(
146
146
 
147
147
  // VM template for `requires.system.type: vm` modules.
148
148
  // Auto-detect existing templates on the node; offer to build if none found.
149
- // See v2/PROXMOX_VM_TEMPLATE.md.
149
+ // See reference/PROXMOX_VM_TEMPLATE.md.
150
150
  let vmTemplate: string | undefined;
151
151
  const existingTemplates = await detectExistingVmTemplates(credentials, targetNode);
152
152
  if (existingTemplates.length > 0) {
@@ -22,8 +22,6 @@ export async function handleServiceList(
22
22
  flags: Record<string, boolean | string> = {},
23
23
  ): Promise<CommandResult> {
24
24
  try {
25
- celiloIntro('Container Services');
26
-
27
25
  // Apply zone filter if provided
28
26
  const filters: ContainerServiceFilters = {};
29
27
  if (flags.zone && typeof flags.zone === 'string') {
@@ -32,6 +30,21 @@ export async function handleServiceList(
32
30
 
33
31
  const services = await listContainerServices(filters);
34
32
 
33
+ // Machine-readable output (consumed by @celilo/mcp auto-detect). Emit the
34
+ // fields detection needs — provider drives which tool groups surface.
35
+ if (flags.json) {
36
+ const payload = services.map((s) => ({
37
+ serviceId: s.serviceId,
38
+ name: s.name,
39
+ provider: s.providerName,
40
+ zones: s.zones,
41
+ verified: s.verified,
42
+ }));
43
+ return { success: true, message: JSON.stringify(payload), rawOutput: true };
44
+ }
45
+
46
+ celiloIntro('Container Services');
47
+
35
48
  if (services.length === 0) {
36
49
  console.log('No container services configured.\n');
37
50
  console.log('Add a service:');
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `celilo subscribers list` — show registered build-bus subscribers.
3
3
  *
4
- * Reads the static-config subscriber list ([[v2/BUILD_BUS.md]] Phase
4
+ * Reads the static-config subscriber list ([[openspec/changes/build-bus-poll-cd/proposal.md]] Phase
5
5
  * 2-lite — registry-server switchboard is deferred). Doesn't print
6
6
  * the HMAC secret in cleartext — only a truncated hash so operators
7
7
  * can disambiguate subscribers without leaking credentials into
@@ -1,5 +1,5 @@
1
1
  /**
2
- * End-to-end equivalence test for v2/MANAGEMENT_AS_NETAPP.md Phase 2.
2
+ * End-to-end equivalence test for openspec/specs/management-as-module/spec.md Phase 2.
3
3
  *
4
4
  * The spec calls for "celilo system apply-config" to write the same
5
5
  * systemConfig state that the legacy `celilo system init` produces,
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `celilo system apply-config` — headless write to systemConfig.
3
3
  *
4
- * Phase 2 of v2/MANAGEMENT_AS_NETAPP.md: the celilo-mgmt module's
4
+ * Phase 2 of openspec/specs/management-as-module/spec.md: the celilo-mgmt module's
5
5
  * on_install hook needs a CLI surface it can shell out to that writes
6
6
  * the operator-chosen network/DNS/SSH config without the interactive
7
7
  * framing of `celilo system init`. This is that command.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Phase 5 of v2/MANAGEMENT_AS_NETAPP.md — confirm `celilo system init`
2
+ * Phase 5 of openspec/specs/management-as-module/spec.md — confirm `celilo system init`
3
3
  * surfaces a deprecation banner pointing at the new paths
4
4
  * (bootstrap.sh + `system apply-config`), and that
5
5
  * CELILO_SUPPRESS_DEPRECATION=1 silences it.
@@ -45,7 +45,7 @@ export async function handleSystemInit(
45
45
  const cliOverrides = parseOverrides(args);
46
46
  const db = getDb();
47
47
 
48
- // Phase 5 of v2/MANAGEMENT_AS_NETAPP.md — surface that this command
48
+ // Phase 5 of openspec/specs/management-as-module/spec.md — surface that this command
49
49
  // is on its way out so operators have time to migrate to the new
50
50
  // paths. Keeps working unchanged; the banner just points at the
51
51
  // replacements. CELILO_SUPPRESS_DEPRECATION=1 silences for callers
@@ -172,7 +172,7 @@ async function initInteractive(cliOverrides: Record<string, string> = {}): Promi
172
172
 
173
173
  // Network and DNS addressing are intentionally NOT prompted here.
174
174
  // Network topology is no longer owned by `system init`
175
- // (v2/NETWORK_CONFIG_TO_FIREWALL.md): the `internal` zone and DNS are
175
+ // (openspec/specs/progressive-zone-disclosure/spec.md): the `internal` zone and DNS are
176
176
  // discovered when celilo-mgmt is deployed, and `dmz`/`app`/`secure`
177
177
  // come from a firewall module. The only thing left to capture
178
178
  // interactively is the SSH key celilo uses to reach managed machines.
@@ -0,0 +1,26 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { handleTokenList, handleTokenObtain, handleTokenRevoke } from './token';
3
+
4
+ // These cover the argument-validation paths that short-circuit BEFORE the idp
5
+ // capability is loaded — no DB or authentik needed. The idp-side behavior
6
+ // (mint / list / revoke) is covered in modules/authentik/scripts/idp-functions.test.ts.
7
+
8
+ describe('celilo token — argument validation', () => {
9
+ test('obtain requires a username', async () => {
10
+ const result = await handleTokenObtain([], {});
11
+ expect(result.success).toBe(false);
12
+ if (!result.success) expect(result.error).toMatch(/Username required/);
13
+ });
14
+
15
+ test('list requires a username', async () => {
16
+ const result = await handleTokenList([], {});
17
+ expect(result.success).toBe(false);
18
+ if (!result.success) expect(result.error).toMatch(/Username required/);
19
+ });
20
+
21
+ test('revoke requires an identifier', async () => {
22
+ const result = await handleTokenRevoke([], {});
23
+ expect(result.success).toBe(false);
24
+ if (!result.success) expect(result.error).toMatch(/Identifier required/);
25
+ });
26
+ });