@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
|
@@ -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
|
|
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
|
|
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 ([[
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
// (
|
|
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
|
+
}
|
package/src/cli/completion.ts
CHANGED
|
@@ -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',
|