@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.
Files changed (121) hide show
  1. package/AGENTS.md +7 -4
  2. package/CELILO_CORE_MODULES.md +7 -6
  3. package/CELILO_SUBSYSTEMS.md +47 -5
  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 +8 -8
  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/capabilities/public-web-publish.test.ts +209 -0
  13. package/src/capabilities/validation.test.ts +1 -1
  14. package/src/capabilities/well-known.test.ts +1 -1
  15. package/src/capabilities/well-known.ts +2 -2
  16. package/src/cli/commands/api.ts +1 -1
  17. package/src/cli/commands/backup-pull.ts +1 -1
  18. package/src/cli/commands/events.ts +1 -1
  19. package/src/cli/commands/module-changeset.ts +1 -1
  20. package/src/cli/commands/module-import.ts +1 -1
  21. package/src/cli/commands/module-publish.ts +1 -1
  22. package/src/cli/commands/module-remove.ts +26 -1
  23. package/src/cli/commands/module-types.ts +1 -1
  24. package/src/cli/commands/module-upgrade.ts +1 -1
  25. package/src/cli/commands/module-version.ts +1 -1
  26. package/src/cli/commands/module-where.test.ts +26 -0
  27. package/src/cli/commands/module-where.ts +130 -0
  28. package/src/cli/commands/proxmox-node-list.ts +1 -1
  29. package/src/cli/commands/publish/alpha.test.ts +1 -1
  30. package/src/cli/commands/publish/alpha.ts +3 -3
  31. package/src/cli/commands/publish/changesets.ts +1 -1
  32. package/src/cli/commands/publish/global-install.ts +6 -2
  33. package/src/cli/commands/publish/helpers.ts +21 -14
  34. package/src/cli/commands/publish/index.ts +4 -4
  35. package/src/cli/commands/publish/plan.ts +3 -3
  36. package/src/cli/commands/publish/preflight.ts +2 -2
  37. package/src/cli/commands/publish/types.ts +2 -2
  38. package/src/cli/commands/publish/workspace.test.ts +1 -1
  39. package/src/cli/commands/publish/workspace.ts +2 -2
  40. package/src/cli/commands/registry-owner.test.ts +166 -0
  41. package/src/cli/commands/registry-owner.ts +124 -0
  42. package/src/cli/commands/registry-token.test.ts +109 -0
  43. package/src/cli/commands/registry-token.ts +194 -0
  44. package/src/cli/commands/restore.ts +1 -1
  45. package/src/cli/commands/service-add-proxmox.ts +1 -1
  46. package/src/cli/commands/subscribers-list.ts +1 -1
  47. package/src/cli/commands/system-apply-config-equivalence.test.ts +1 -1
  48. package/src/cli/commands/system-apply-config.ts +1 -1
  49. package/src/cli/commands/system-init-deprecation.test.ts +1 -1
  50. package/src/cli/commands/system-init.ts +2 -2
  51. package/src/cli/commands/token.test.ts +26 -0
  52. package/src/cli/commands/token.ts +160 -0
  53. package/src/cli/completion.ts +19 -0
  54. package/src/cli/index.ts +155 -4
  55. package/src/cli/restore-command.test.ts +1 -1
  56. package/src/db/client.ts +1 -1
  57. package/src/db/schema.ts +50 -7
  58. package/src/hooks/capability-loader-firewall.test.ts +4 -3
  59. package/src/hooks/capability-loader.ts +100 -10
  60. package/src/hooks/define-hook.test.ts +24 -0
  61. package/src/hooks/executor.test.ts +1 -1
  62. package/src/hooks/executor.ts +2 -2
  63. package/src/hooks/types.ts +1 -1
  64. package/src/manifest/contracts/v1.ts +2 -2
  65. package/src/manifest/schema.ts +13 -13
  66. package/src/manifest/template-validator.ts +1 -1
  67. package/src/module/packaging/build.ts +19 -0
  68. package/src/module/packaging/workspace-deps.test.ts +94 -0
  69. package/src/module/packaging/workspace-deps.ts +185 -0
  70. package/src/module/versioning/changeset-version.ts +1 -1
  71. package/src/policy/no-hand-built-ssh.test.ts +90 -0
  72. package/src/registry/client.test.ts +86 -0
  73. package/src/registry/client.ts +67 -1
  74. package/src/services/api-access.ts +1 -1
  75. package/src/services/aspect-approvals.ts +1 -1
  76. package/src/services/aspect-runner.ts +3 -3
  77. package/src/services/aspect-template-resolver.test.ts +1 -1
  78. package/src/services/aspect-template-resolver.ts +1 -1
  79. package/src/services/build-bus/delivery-events.ts +1 -1
  80. package/src/services/build-bus/fan-out.ts +1 -1
  81. package/src/services/build-bus/hook-dispatch.ts +1 -1
  82. package/src/services/build-bus/receiver-server.ts +1 -1
  83. package/src/services/build-bus/status.test.ts +1 -1
  84. package/src/services/build-bus/status.ts +1 -1
  85. package/src/services/build-bus/subscriber-store.ts +1 -1
  86. package/src/services/bus-interview.ts +1 -1
  87. package/src/services/celilo-events.ts +1 -1
  88. package/src/services/celilo-mgmt-hooks.test.ts +1 -1
  89. package/src/services/cross-module-read.ts +1 -1
  90. package/src/services/deploy-posture.ts +1 -1
  91. package/src/services/deployed-systems.test.ts +1 -1
  92. package/src/services/deployed-systems.ts +4 -4
  93. package/src/services/dns-provider-backfill.ts +2 -2
  94. package/src/services/events-daemon.ts +1 -1
  95. package/src/services/machine-pool.ts +3 -3
  96. package/src/services/module-deploy.ts +93 -8
  97. package/src/services/module-subscriptions.ts +1 -1
  98. package/src/services/module-types-generator.ts +1 -1
  99. package/src/services/module-validator/git-hygiene.ts +1 -1
  100. package/src/services/port-forwards.test.ts +92 -0
  101. package/src/services/port-forwards.ts +86 -0
  102. package/src/services/proxmox-reconcile.ts +2 -2
  103. package/src/services/public-web-republish.test.ts +189 -0
  104. package/src/services/public-web-republish.ts +84 -0
  105. package/src/services/restore-from-file.ts +1 -1
  106. package/src/services/restore-preflight.ts +1 -1
  107. package/src/services/system-identity.ts +1 -1
  108. package/src/services/system-init.test.ts +2 -2
  109. package/src/services/system-init.ts +2 -2
  110. package/src/services/web-route-cleanup.test.ts +250 -0
  111. package/src/services/web-route-cleanup.ts +144 -0
  112. package/src/templates/generator.ts +2 -2
  113. package/src/types/infrastructure.ts +1 -1
  114. package/src/variables/computed/computed-integration.test.ts +1 -1
  115. package/src/variables/computed/evaluate.ts +1 -1
  116. package/src/variables/computed/parse.ts +1 -1
  117. package/src/variables/context.test.ts +1 -1
  118. package/src/variables/context.ts +5 -5
  119. package/src/variables/lxc-nameserver.test.ts +1 -1
  120. package/src/variables/resolver.ts +2 -2
  121. package/src/variables/types.ts +1 -1
@@ -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) {
@@ -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
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Contributor identity tokens (idp-issued, per-user)
3
+ *
4
+ * `celilo token obtain|list|revoke` mints, lists, and revokes per-user API
5
+ * tokens from the idp (authentik) so a module contributor authenticates
6
+ * publishes AS THEMSELVES — no admin/shared token ever reaches their machine
7
+ * (SECURE_MODULE_PUBLISH.md). The token consumed by `celilo author init`.
8
+ *
9
+ * Distinct from `celilo registry token add/rm`, which manages the raw
10
+ * bootstrap `publish_tokens` list — a different trust/storage model. Keep the
11
+ * two families separate.
12
+ *
13
+ * Runs on-mgr where the idp provider + bootstrap token live (same model as
14
+ * registry-token.ts). The token string is shown once at mint; the idp stores
15
+ * it hashed and celilo persists nothing.
16
+ */
17
+
18
+ import type { IdpCapability } from '@celilo/capabilities';
19
+ import { getDb } from '../../db/client';
20
+ import { loadCapabilityFunctions } from '../../hooks/capability-loader';
21
+ import { createCapturingLogger } from '../../hooks/logger';
22
+ import { getArg, getFlag } from '../parser';
23
+ import type { CommandError, CommandResult } from '../types';
24
+
25
+ /** Default identifier for a contributor's publish token — stable per user so obtain is idempotent. */
26
+ function defaultIdentifier(username: string): string {
27
+ return `celilo-publish-${username}`;
28
+ }
29
+
30
+ function isError(v: unknown): v is CommandError {
31
+ return typeof v === 'object' && v !== null && 'success' in v && v.success === false;
32
+ }
33
+
34
+ /**
35
+ * Load the idp capability. Runs where the idp provider (authentik) is
36
+ * deployed; errors clearly when it isn't. The capturing logger discards the
37
+ * capability's own progress markers so command output stays clean.
38
+ */
39
+ async function loadIdp(): Promise<IdpCapability | CommandError> {
40
+ const db = getDb();
41
+ const { logger } = createCapturingLogger();
42
+ const caps = await loadCapabilityFunctions('celilo-token', db, logger);
43
+ const idp = caps.idp as IdpCapability | undefined;
44
+ if (!idp) {
45
+ return {
46
+ success: false,
47
+ error:
48
+ 'No idp provider found. Run this on the host where the idp (authentik) is deployed (celilo-mgr).',
49
+ };
50
+ }
51
+ return idp;
52
+ }
53
+
54
+ /** Resolve the target username from `--user` (or first positional arg). */
55
+ function resolveUser(args: string[], flags: Record<string, string | boolean>): string | undefined {
56
+ const flag = getFlag(flags, 'user').trim();
57
+ if (flag) return flag;
58
+ return getArg(args, 0)?.trim();
59
+ }
60
+
61
+ /**
62
+ * `celilo token obtain --user <username> [--identifier <id>] [--description <text>]`
63
+ * Mints (or returns the existing) per-user token. Idempotent on identifier.
64
+ */
65
+ export async function handleTokenObtain(
66
+ args: string[],
67
+ flags: Record<string, string | boolean>,
68
+ ): Promise<CommandResult> {
69
+ const username = resolveUser(args, flags);
70
+ if (!username) {
71
+ return {
72
+ success: false,
73
+ error:
74
+ 'Username required\n\nUsage: celilo token obtain --user <username> [--identifier <id>]',
75
+ };
76
+ }
77
+ const identifier = getFlag(flags, 'identifier').trim() || defaultIdentifier(username);
78
+ const description = getFlag(flags, 'description').trim() || undefined;
79
+
80
+ const idp = await loadIdp();
81
+ if (isError(idp)) return idp;
82
+
83
+ const result = await idp.create_token({ username, identifier, description });
84
+ const verb = result.created ? 'Minted new' : 'Returned existing';
85
+
86
+ return {
87
+ success: true,
88
+ message: `${verb} per-user token for '${username}' (identifier: ${identifier})
89
+
90
+ ${result.token}
91
+
92
+ Treat as a secret — store it now (CELILO_PUBLISH_TOKEN, or \`celilo author init --token …\`).
93
+ celilo persists nothing; the idp stores it hashed.
94
+ Revoke with: celilo token revoke ${identifier}`,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * `celilo token list --user <username>`
100
+ * Lists the user's API tokens (metadata only — never the bearer string).
101
+ */
102
+ export async function handleTokenList(
103
+ args: string[],
104
+ flags: Record<string, string | boolean>,
105
+ ): Promise<CommandResult> {
106
+ const username = resolveUser(args, flags);
107
+ if (!username) {
108
+ return {
109
+ success: false,
110
+ error: 'Username required\n\nUsage: celilo token list --user <username>',
111
+ };
112
+ }
113
+
114
+ const idp = await loadIdp();
115
+ if (isError(idp)) return idp;
116
+
117
+ const tokens = await idp.list_tokens({ username });
118
+ if (tokens.length === 0) {
119
+ return { success: true, message: `No API tokens for '${username}'.` };
120
+ }
121
+
122
+ const lines = tokens.map((t) => {
123
+ const desc = t.description ? ` — ${t.description}` : '';
124
+ const exp = t.expiring && t.expires ? ` (expires ${t.expires})` : '';
125
+ return ` ${t.identifier}${desc}${exp}`;
126
+ });
127
+ return {
128
+ success: true,
129
+ message: `API tokens for '${username}':\n${lines.join('\n')}`,
130
+ };
131
+ }
132
+
133
+ /**
134
+ * `celilo token revoke <identifier>` (or `--identifier <id>`)
135
+ * Deletes a token at the idp; the next publish with it is denied.
136
+ */
137
+ export async function handleTokenRevoke(
138
+ args: string[],
139
+ flags: Record<string, string | boolean>,
140
+ ): Promise<CommandResult> {
141
+ const identifier = getArg(args, 0)?.trim() || getFlag(flags, 'identifier').trim();
142
+ if (!identifier) {
143
+ return {
144
+ success: false,
145
+ error: 'Identifier required\n\nUsage: celilo token revoke <identifier>',
146
+ };
147
+ }
148
+
149
+ const idp = await loadIdp();
150
+ if (isError(idp)) return idp;
151
+
152
+ const { revoked } = await idp.revoke_token({ identifier });
153
+ if (!revoked) {
154
+ return { success: false, error: `No token with identifier '${identifier}' found.` };
155
+ }
156
+ return {
157
+ success: true,
158
+ message: `Revoked token '${identifier}'. The next publish using it will be denied.`,
159
+ };
160
+ }
@@ -46,12 +46,14 @@ export async function getCompletions(words: string[], current: number): Promise<
46
46
  'package',
47
47
  'proxmox',
48
48
  'publish',
49
+ 'registry',
49
50
  'restore',
50
51
  'service',
51
52
  'status',
52
53
  'storage',
53
54
  'subscribers',
54
55
  'system',
56
+ 'token',
55
57
  'version',
56
58
  ];
57
59
  return filterSuggestions(commands, args[0] || '');
@@ -70,6 +72,22 @@ export async function getCompletions(words: string[], current: number): Promise<
70
72
  return filterSuggestions(subcommands, args[1] || '');
71
73
  }
72
74
 
75
+ // Registry subcommands
76
+ if (command === 'registry' && currentIndex === 1) {
77
+ return filterSuggestions(['token', 'owner'], args[1] || '');
78
+ }
79
+ if (command === 'registry' && args[1] === 'token' && currentIndex === 2) {
80
+ return filterSuggestions(['add', 'rm'], args[2] || '');
81
+ }
82
+ if (command === 'registry' && args[1] === 'owner' && currentIndex === 2) {
83
+ return filterSuggestions(['list', 'show', 'set'], args[2] || '');
84
+ }
85
+
86
+ // Token subcommands (contributor identity tokens)
87
+ if (command === 'token' && currentIndex === 1) {
88
+ return filterSuggestions(['obtain', 'list', 'revoke'], args[1] || '');
89
+ }
90
+
73
91
  // Capability info - complete with capability names
74
92
  if (command === 'capability' && args[1] === 'info' && currentIndex === 2) {
75
93
  const db = getDb();
@@ -161,6 +179,7 @@ export async function getCompletions(words: string[], current: number): Promise<
161
179
  'run-hook',
162
180
  'secret',
163
181
  'status',
182
+ 'where',
164
183
  'terraform-unlock',
165
184
  'types',
166
185
  'validate',