@dotlabshq/orbseal 0.1.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.
@@ -0,0 +1,1844 @@
1
+ #!/usr/bin/env node
2
+ // orbseal CLI — single executable, no external dependencies.
3
+ // Config: ~/.orbseal/config.json { token, apiBase }
4
+
5
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
6
+ import { homedir } from 'os';
7
+ import { join, dirname } from 'path';
8
+ import { fileURLToPath } from 'url';
9
+ import { createInterface } from 'readline';
10
+ import {
11
+ randomBytes, pbkdf2Sync, createHash,
12
+ createPrivateKey, createPublicKey,
13
+ } from 'node:crypto';
14
+
15
+ const __dir = dirname(fileURLToPath(import.meta.url));
16
+
17
+ // ─── Base58 ──────────────────────────────────────────────────────────────────
18
+
19
+ const B58_ALPHA = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
20
+
21
+ function b58enc(buf) {
22
+ let n = BigInt('0x' + Buffer.from(buf).toString('hex'));
23
+ let s = '';
24
+ while (n > 0n) { s = B58_ALPHA[Number(n % 58n)] + s; n /= 58n; }
25
+ for (const b of buf) { if (b) break; s = '1' + s; }
26
+ return s;
27
+ }
28
+
29
+ function b58dec(s) {
30
+ let n = 0n;
31
+ for (const ch of s) {
32
+ const i = B58_ALPHA.indexOf(ch);
33
+ if (i < 0) throw new Error(`Invalid base58 character: ${ch}`);
34
+ n = n * 58n + BigInt(i);
35
+ }
36
+ const hex = n.toString(16);
37
+ return Buffer.from(hex.length % 2 ? '0' + hex : hex, 'hex');
38
+ }
39
+
40
+ // Format: "orbpk-<base58>" / "orbsk-<base58>"
41
+ function encodeKey(prefix, raw32) { return `${prefix}-${b58enc(raw32)}`; }
42
+ function decodeKey(str) {
43
+ const dash = str.indexOf('-');
44
+ return b58dec(str.slice(dash + 1));
45
+ }
46
+ // Accept orbpk-... or raw base64 (backwards compat)
47
+ function parsePublicKey(s) {
48
+ if (s.startsWith('orbpk-')) return decodeKey(s);
49
+ return Buffer.from(s, 'base64'); // legacy base64
50
+ }
51
+
52
+ // ─── BIP39 mnemonic ───────────────────────────────────────────────────────────
53
+
54
+ async function loadWordlist() {
55
+ const { default: words } = await import(join(__dir, 'bip39en.mjs'));
56
+ return words;
57
+ }
58
+
59
+ async function mnemonicFromEntropy(entropy16) {
60
+ const words = await loadWordlist();
61
+ // SHA256 checksum — first 4 bits appended
62
+ const hash = createHash('sha256').update(entropy16).digest();
63
+ const bits =
64
+ [...entropy16].map(b => b.toString(2).padStart(8, '0')).join('') +
65
+ (hash[0] >> 4).toString(2).padStart(4, '0'); // 128 + 4 = 132 bits
66
+ return Array.from({ length: 12 }, (_, i) =>
67
+ words[parseInt(bits.slice(i * 11, (i + 1) * 11), 2)]
68
+ ).join(' ');
69
+ }
70
+
71
+ async function validateMnemonic(phrase) {
72
+ const words = await loadWordlist();
73
+ const parts = phrase.trim().split(/\s+/);
74
+ if (parts.length !== 12) return 'mnemonic must be exactly 12 words';
75
+ const bad = parts.filter(w => !words.includes(w));
76
+ if (bad.length) return `unknown words: ${bad.join(', ')}`;
77
+ return null;
78
+ }
79
+
80
+ // mnemonic → 32-byte seed (domain-separated from Bitcoin wallets)
81
+ function seedFromMnemonic(phrase) {
82
+ return pbkdf2Sync(phrase.normalize('NFKD'), 'orbseal', 2048, 32, 'sha512');
83
+ }
84
+
85
+ // ─── X25519 keypair from 32-byte seed ────────────────────────────────────────
86
+
87
+ const PKCS8_HEADER = Buffer.from('302e020100300506032b656e04220420', 'hex');
88
+
89
+ function keypairFromSeed(seed32) {
90
+ const pkcs8 = Buffer.concat([PKCS8_HEADER, seed32]);
91
+ const privObj = createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' });
92
+ const pubObj = createPublicKey(privObj);
93
+ const pubRaw = pubObj.export({ type: 'spki', format: 'der' }).slice(-32);
94
+ return {
95
+ publicKey: encodeKey('orbpk', pubRaw),
96
+ privateKey: encodeKey('orbsk', seed32),
97
+ publicKeyB64: pubRaw.toString('base64'), // for API use
98
+ };
99
+ }
100
+
101
+ async function keypairFromMnemonic(phrase) {
102
+ return keypairFromSeed(seedFromMnemonic(phrase));
103
+ }
104
+
105
+ // ─── Config ──────────────────────────────────────────────────────────────────
106
+
107
+ const CONFIG_DIR = join(homedir(), '.orbseal');
108
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
109
+ const KEY_FILE = join(CONFIG_DIR, 'key'); // stores 12-word mnemonic
110
+ const DEFAULT_API = 'https://api.orbseal.com';
111
+
112
+ /** Save mnemonic to ~/.orbseal/key (mode 600, owner-only). */
113
+ function saveMnemonic(mnemonic) {
114
+ mkdirSync(CONFIG_DIR, { recursive: true });
115
+ writeFileSync(KEY_FILE, mnemonic + '\n', { mode: 0o600 });
116
+ }
117
+
118
+ /** Load mnemonic from ~/.orbseal/key. Returns null if not found. */
119
+ function loadMnemonic() {
120
+ if (!existsSync(KEY_FILE)) return null;
121
+ try { return readFileSync(KEY_FILE, 'utf8').trim(); } catch { return null; }
122
+ }
123
+
124
+ /** Generate a fresh keypair, persist it, and return { mnemonic, kp }. */
125
+ async function generateAndSaveKeypair() {
126
+ const entropy = randomBytes(16);
127
+ const mnemonic = await mnemonicFromEntropy(entropy);
128
+ const kp = await keypairFromMnemonic(mnemonic);
129
+ saveMnemonic(mnemonic);
130
+ return { mnemonic, kp };
131
+ }
132
+
133
+ /** Display the recovery phrase with a safe-environment prompt. */
134
+ async function showRecoveryPhrase(mnemonic, kp) {
135
+ const words = mnemonic.split(' ');
136
+ console.log();
137
+ console.log(` ${c.bold}┌─ Recovery Phrase ────────────────────────────────────────┐${c.reset}`);
138
+ console.log(` ${c.bold}│${c.reset} ${c.bold}│${c.reset}`);
139
+ // Print words in 3 columns of 4
140
+ for (let row = 0; row < 4; row++) {
141
+ const trio = [0, 1, 2].map(col => {
142
+ const idx = row + col * 4;
143
+ const num = String(idx + 1).padStart(2, ' ');
144
+ const word = (words[idx] ?? '').padEnd(10, ' ');
145
+ return `${c.dim}${num}.${c.reset} ${c.yellow}${c.bold}${word}${c.reset}`;
146
+ }).join(' ');
147
+ console.log(` ${c.bold}│${c.reset} ${trio} ${c.bold}│${c.reset}`);
148
+ }
149
+ console.log(` ${c.bold}│${c.reset} ${c.bold}│${c.reset}`);
150
+ console.log(` ${c.bold}└────────────────────────────────────────────────────────────┘${c.reset}`);
151
+ console.log();
152
+ kv([
153
+ ['public key', c.cyan + kp.publicKey + c.reset],
154
+ ]);
155
+ console.log();
156
+ console.log(` ${c.dim}Your private key never leaves this machine. OrbSeal never sees it.${c.reset}`);
157
+ console.log(` ${c.dim}This phrase is saved at: ~/.orbseal/key${c.reset}`);
158
+ console.log(` ${c.dim}View again anytime: ${c.reset}orbseal keys show`);
159
+ console.log(` ${c.dim}Restore on new device: ${c.reset}orbseal keys restore`);
160
+ console.log();
161
+ await prompt(' Press Enter to confirm you have saved this phrase…');
162
+ }
163
+
164
+ function loadConfig() {
165
+ if (!existsSync(CONFIG_FILE)) return {};
166
+ try { return JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch { return {}; }
167
+ }
168
+
169
+ function saveConfig(cfg) {
170
+ mkdirSync(CONFIG_DIR, { recursive: true });
171
+ writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
172
+ }
173
+
174
+ // ─── HTTP ────────────────────────────────────────────────────────────────────
175
+
176
+ // Resolves workspace / project / env from args, falling back to saved context.
177
+ // Call: const [ws, proj, env] = resolveCtx(rest, 'ws', 'proj', 'env?')
178
+ // Slots marked with '?' are optional (won't fatal if missing from both).
179
+ function resolveCtx(positional, ...slots) {
180
+ const ctx = loadConfig().context ?? {};
181
+ const ctxVals = { ws: ctx.workspace, proj: ctx.project, env: ctx.env };
182
+ const result = [];
183
+ let posIdx = 0; // positional args consumed so far
184
+
185
+ for (const slot of slots) {
186
+ const optional = slot.endsWith('?');
187
+ const key = optional ? slot.slice(0, -1) : slot;
188
+
189
+ if (ctxVals[key]) {
190
+ // Context covers this slot — don't consume a positional arg
191
+ result.push(ctxVals[key]);
192
+ } else if (positional[posIdx] !== undefined) {
193
+ // No context for this slot — consume next positional arg
194
+ result.push(positional[posIdx++]);
195
+ } else if (!optional) {
196
+ const hint = ctx.workspace || ctx.project
197
+ ? `\n ${c.dim}Context: ${[ctx.workspace, ctx.project].filter(Boolean).join('/')}${c.reset}`
198
+ : '';
199
+ fatal(`'${key}' required — pass it as an argument or run ${c.bold}orbseal use <org>/<workspace>[/<project>]${c.reset}${hint}`);
200
+ } else {
201
+ result.push(null);
202
+ }
203
+ }
204
+ // Expose how many positional args were consumed so callers can slice the rest
205
+ result.consumed = posIdx;
206
+ return result;
207
+ }
208
+
209
+ async function api(method, path, body, contentType) {
210
+ const cfg = loadConfig();
211
+ const token = cfg.token;
212
+ const base = (cfg.apiBase ?? DEFAULT_API).replace(/\/$/, '');
213
+
214
+ if (!token) fatal('Not logged in. Run: orbseal login');
215
+
216
+ const isRaw = typeof body === 'string';
217
+ const ct = contentType ?? (isRaw ? 'text/plain' : 'application/json');
218
+
219
+ const res = await fetch(`${base}${path}`, {
220
+ method,
221
+ headers: {
222
+ 'Authorization': `Bearer ${token}`,
223
+ ...(body !== undefined ? { 'Content-Type': ct } : {}),
224
+ },
225
+ ...(body !== undefined ? { body: isRaw ? body : JSON.stringify(body) } : {}),
226
+ });
227
+
228
+ if (res.status === 204) return null;
229
+
230
+ const text = await res.text();
231
+ let data;
232
+ try { data = JSON.parse(text); } catch { fatal(`Unexpected response (${res.status}): ${text}`); }
233
+
234
+ if (!res.ok) fatal(`${res.status} ${data?.code ?? ''}: ${data?.error ?? text}`);
235
+ return data;
236
+ }
237
+
238
+ // ─── UI ──────────────────────────────────────────────────────────────────────
239
+
240
+ const isTTY = process.stdout.isTTY;
241
+
242
+ const c = {
243
+ reset: isTTY ? '\x1b[0m' : '',
244
+ bold: isTTY ? '\x1b[1m' : '',
245
+ dim: isTTY ? '\x1b[2m' : '',
246
+ green: isTTY ? '\x1b[32m' : '',
247
+ yellow: isTTY ? '\x1b[33m' : '',
248
+ cyan: isTTY ? '\x1b[36m' : '',
249
+ red: isTTY ? '\x1b[31m' : '',
250
+ };
251
+
252
+ function table(rows, cols) {
253
+ // cols: [{ key, label, fmt? }]
254
+ if (!rows.length) { console.log(`${c.dim} (none)${c.reset}`); return; }
255
+
256
+ const widths = cols.map(col =>
257
+ Math.max(col.label.length, ...rows.map(r => String(col.fmt ? col.fmt(r[col.key], r) : (r[col.key] ?? '—')).replace(/\x1b\[[0-9;]*m/g, '').length))
258
+ );
259
+
260
+ const header = cols.map((col, i) => pad(c.bold + c.dim + col.label + c.reset, widths[i])).join(' ');
261
+ console.log(' ' + header);
262
+ console.log(' ' + widths.map(w => '─'.repeat(w)).join(' '));
263
+
264
+ for (const row of rows) {
265
+ const line = cols.map((col, i) => {
266
+ const raw = col.fmt ? col.fmt(row[col.key], row) : (row[col.key] ?? '—');
267
+ const visible = String(raw).replace(/\x1b\[[0-9;]*m/g, '');
268
+ return String(raw) + ' '.repeat(Math.max(0, widths[i] - visible.length));
269
+ }).join(' ');
270
+ console.log(' ' + line);
271
+ }
272
+ }
273
+
274
+ function kv(pairs) {
275
+ const w = Math.max(...pairs.map(([k]) => k.length));
276
+ for (const [k, v] of pairs) {
277
+ console.log(` ${c.dim}${k.padEnd(w)}${c.reset} ${v}`);
278
+ }
279
+ }
280
+
281
+ function success(msg) {
282
+ console.log(`${c.green}✓${c.reset} ${msg}`);
283
+ }
284
+
285
+ function pad(str, width) {
286
+ const visible = str.replace(/\x1b\[[0-9;]*m/g, '');
287
+ return str + ' '.repeat(Math.max(0, width - visible.length));
288
+ }
289
+
290
+ function fmtDate(val) {
291
+ if (!val) return c.dim + '—' + c.reset;
292
+ // Accept both ISO string and unix ms timestamp
293
+ const d = typeof val === 'number' ? new Date(val) : new Date(val);
294
+ if (isNaN(d.getTime())) return c.dim + '—' + c.reset;
295
+ return c.dim + d.toISOString().slice(0, 10) + c.reset;
296
+ }
297
+
298
+ function fmtRole(role) {
299
+ if (role === 'admin') return c.yellow + 'admin' + c.reset;
300
+ if (role === 'member') return 'member';
301
+ return c.dim + role + c.reset;
302
+ }
303
+
304
+ function fmtStatus(revoked_at) {
305
+ return revoked_at ? c.dim + 'revoked' + c.reset : c.green + 'active' + c.reset;
306
+ }
307
+
308
+ function fatal(msg) {
309
+ console.error(`${c.red}error:${c.reset} ${msg}`);
310
+ process.exit(1);
311
+ }
312
+
313
+ function prompt(question) {
314
+ return new Promise(resolve => {
315
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
316
+ rl.question(question, ans => { rl.close(); resolve(ans.trim()); });
317
+ });
318
+ }
319
+
320
+ async function promptSecret(question) {
321
+ const { execSync } = await import('node:child_process');
322
+ return new Promise(resolve => {
323
+ process.stderr.write(question);
324
+ if (process.stdin.isTTY) {
325
+ try { execSync('stty -echo', { stdio: ['inherit', 'inherit', 'ignore'] }); } catch {}
326
+ }
327
+ process.stdin.setEncoding('utf8');
328
+ process.stdin.resume();
329
+ process.stdin.once('data', chunk => {
330
+ const value = chunk.toString().replace(/[\r\n]+$/, '');
331
+ if (process.stdin.isTTY) {
332
+ try { execSync('stty echo', { stdio: ['inherit', 'inherit', 'ignore'] }); } catch {}
333
+ process.stderr.write('\n');
334
+ }
335
+ process.stdin.pause();
336
+ resolve(value);
337
+ });
338
+ });
339
+ }
340
+
341
+ // ─── Help ────────────────────────────────────────────────────────────────────
342
+
343
+ const HELP = `
344
+ orbseal <command> [args]
345
+
346
+ ls Show org/workspace/project tree (respects context)
347
+ status Full status: org tree + definitions + release info
348
+
349
+ Context
350
+ use <org>[/<ws>[/<proj>]] [--public-key <key>] Set active context
351
+ use --public-key <key> Save public key to context only
352
+ use --clear Clear context (including public key)
353
+
354
+ Auth
355
+ login <token> Save token — admin (orb_admin_) and app (orb_live_) slots kept separately
356
+ switch admin | app Switch between saved admin and app-key tokens
357
+ logout Remove active token
358
+ whoami (alias: wh) Show active token, mode, and saved slots
359
+
360
+ Aliases: ws = workspaces proj = projects env = envs
361
+
362
+ Tokens
363
+ tokens list List your admin tokens
364
+ tokens create [label] Create a new token (shown once)
365
+ tokens revoke <id> Revoke a token
366
+
367
+ Releases
368
+ release <workspace> <project> <environment> Cut a new immutable release
369
+ releases list <workspace> <project> <environment> List releases
370
+ releases get <workspace> <project> <environment> [version|latest] Show a release
371
+
372
+ Secrets
373
+ seal <workspace> <project> <plugin:key> --public-key <key> --scope <s> --ref <r> Seal & store a secret
374
+
375
+ Config (runtime — requires app key token)
376
+ resolve [--user=<id>] Resolve config for the current app key
377
+ settings list <user_id> List user's overridable settings + current values
378
+ settings set <user_id> <plugin:key> <value> Save a user preference
379
+ settings reset <user_id> <plugin:key> Reset to default
380
+
381
+ Keypair
382
+ keygen Generate keypair + 12-word recovery phrase (saved to ~/.orbseal/key)
383
+ keys show Display your recovery phrase
384
+ keys restore Restore keypair from a 12-word recovery phrase
385
+
386
+ App Keys
387
+ keys list <workspace> <project> List app keys
388
+ keys create <workspace> <project> <name> --public-key <key> [--env <slug>] Create an app key (token shown once)
389
+ keys revoke <workspace> <project> <id> Revoke an app key
390
+
391
+ Audit Log
392
+ audit list [workspace] [project] List audit events (org-level or project-level)
393
+ [--type <action>] Filter by action type
394
+ [--actor <user_id>] Filter by actor
395
+ [--key <plugin:key>] Filter by config key
396
+ [--from <date>] [--to <date>] Date range (ISO 8601)
397
+ [--limit <n>] [--cursor <token>] Pagination
398
+ [--json] Raw JSON output
399
+
400
+ Values
401
+ values list <workspace> <project> List all set values
402
+ values set <ws> <proj> <plugin:key> <value> [--scope <s>] [--ref <r>] Set a value
403
+ values delete <ws> <proj> <plugin:key> --scope <s> --ref <r> Delete a value
404
+
405
+ Schema
406
+ sync <workspace> <project> [orb.yaml] Sync orb.yaml to project (default: ./orb.yaml)
407
+ schema list <workspace> <project> List all definitions
408
+ schema get <workspace> <project> <plugin:key> Show a single definition
409
+
410
+ Environments
411
+ envs list <workspace> <project> List environments
412
+ envs create <workspace> <project> <name> Create an environment
413
+ envs get <workspace> <project> <slug> Show environment details
414
+ envs rename <workspace> <project> <slug> <name> Rename an environment
415
+ envs delete <workspace> <project> <slug> Delete an empty environment
416
+
417
+ Projects
418
+ projects list <workspace> List projects in a workspace
419
+ projects create <workspace> <name> Create a project
420
+ projects get <workspace> <slug> Show project details
421
+ projects rename <workspace> <slug> <name> Rename a project
422
+ projects delete <workspace> <slug> Delete an empty project
423
+
424
+ Workspaces
425
+ workspaces list List workspaces in your org
426
+ workspaces create <name> Create a workspace
427
+ workspaces get <slug> Show workspace details
428
+ workspaces rename <slug> <name> Rename a workspace
429
+ workspaces delete <slug> Delete an empty workspace
430
+
431
+ Orgs
432
+ orgs list List orgs you belong to
433
+ orgs create <slug> <name> Create a new org
434
+ orgs get <slug> Show org details
435
+ orgs rename <slug> <name> Rename an org (admin)
436
+
437
+ orgs members list <slug> List org members
438
+ orgs members add <slug> <email> [role] Add member (role: admin|member|viewer)
439
+ orgs members set-role <slug> <userId> <role> Change member role
440
+ orgs members remove <slug> <userId> Remove member
441
+ `.trim();
442
+
443
+ // ─── Commands ────────────────────────────────────────────────────────────────
444
+
445
+ const commands = {
446
+
447
+ // ── Auth ──────────────────────────────────────────────────────────────────
448
+
449
+ async login(args) {
450
+ const cfg = loadConfig();
451
+ const token = args[0];
452
+ const apiBase = cfg.apiBase ?? DEFAULT_API;
453
+
454
+ // ── Direct token save (backward compat) ───────────────────────────────────
455
+ if (token) {
456
+ if (!token.startsWith('orb_admin_') && !token.startsWith('orb_live_')) {
457
+ fatal(
458
+ `"${token}" is not a valid token.\n` +
459
+ ` Admin tokens start with ${c.green}orb_admin_${c.reset}\n` +
460
+ ` App keys start with ${c.yellow}orb_live_${c.reset}\n\n` +
461
+ ` For browser-based login run: ${c.bold}orbseal login${c.reset} (no args)`
462
+ );
463
+ }
464
+ const next = { ...cfg, token };
465
+ if (token.startsWith('orb_admin_')) next.admin_token = token;
466
+ if (token.startsWith('orb_live_')) next.app_token = token;
467
+ saveConfig(next);
468
+ const mode = token.startsWith('orb_live_') ? c.yellow + 'app key' + c.reset : c.green + 'admin' + c.reset;
469
+ success(`Logged in ${c.dim}${apiBase}${c.reset} (${mode})`);
470
+ return;
471
+ }
472
+
473
+ // ── Browser-based PKCE login flow ─────────────────────────────────────────
474
+ console.log(`\n ${c.bold}Starting login…${c.reset}\n`);
475
+
476
+ // 1. Get state + URL from API
477
+ let startData;
478
+ try {
479
+ const res = await fetch(`${apiBase}/v1/auth/start`);
480
+ if (!res.ok) fatal(`Auth start failed: ${res.status} ${res.statusText}`);
481
+ startData = await res.json();
482
+ } catch (e) {
483
+ fatal(`Connection error: ${e.message}\n API: ${apiBase}`);
484
+ }
485
+
486
+ const { state, url, expires_in } = startData;
487
+ const expiresMin = Math.round(expires_in / 60);
488
+
489
+ console.log(` Open this URL in your browser:\n`);
490
+ console.log(` ${c.bold}${c.green}${url}${c.reset}\n`);
491
+ console.log(` ${c.dim}This link expires in ${expiresMin} minutes.${c.reset}\n`);
492
+
493
+ // Try to open browser automatically
494
+ try {
495
+ const { execSync } = await import('node:child_process');
496
+ const cmd = process.platform === 'darwin' ? 'open'
497
+ : process.platform === 'win32' ? 'start'
498
+ : 'xdg-open';
499
+ execSync(`${cmd} "${url}"`, { stdio: 'ignore' });
500
+ console.log(` ${c.dim}Browser opened automatically. If it didn't open, paste the URL manually.${c.reset}\n`);
501
+ } catch {
502
+ // Ignore — user will open manually
503
+ }
504
+
505
+ // 2. Poll until done
506
+ process.stdout.write(` Waiting`);
507
+ const deadline = Date.now() + (expires_in * 1000);
508
+ const intervalMs = 2000;
509
+ let authToken = null;
510
+ let isNewAccount = false;
511
+
512
+ while (Date.now() < deadline) {
513
+ await new Promise(r => setTimeout(r, intervalMs));
514
+ process.stdout.write('.');
515
+
516
+ try {
517
+ const res = await fetch(`${apiBase}/v1/auth/poll/${state}`);
518
+ const data = await res.json();
519
+
520
+ if (data.status === 'done' && data.token) {
521
+ authToken = data.token;
522
+ isNewAccount = data.new_account === true;
523
+ break;
524
+ }
525
+ if (data.status === 'expired') {
526
+ process.stdout.write('\n');
527
+ fatal('Session expired. Try again: orbseal login');
528
+ }
529
+ } catch {
530
+ // Network hiccup — keep polling
531
+ }
532
+ }
533
+
534
+ process.stdout.write('\n\n');
535
+
536
+ if (!authToken) {
537
+ fatal('Timed out — browser flow not completed.\n Try again: orbseal login');
538
+ }
539
+
540
+ // 3. Save token
541
+ const next = { ...cfg, token: authToken, admin_token: authToken };
542
+ saveConfig(next);
543
+ success(`Logged in ${c.dim}${apiBase}${c.reset} (${c.green}admin${c.reset})`);
544
+ console.log(` ${c.dim}Token saved to ~/.orbseal/config.json${c.reset}\n`);
545
+
546
+ // 4. Keypair — three scenarios
547
+ const existing = loadMnemonic();
548
+
549
+ if (existing) {
550
+ // Scenario A: key exists on this device — all good
551
+ const kp = await keypairFromMnemonic(existing);
552
+ console.log(` ${c.dim}Encryption keypair loaded (${c.cyan}${kp.publicKey.slice(0, 18)}…${c.reset}${c.dim})${c.reset}`);
553
+ console.log(` ${c.dim}View recovery phrase: ${c.reset}orbseal keys show\n`);
554
+
555
+ } else if (isNewAccount) {
556
+ // Scenario B: brand new account, no secrets yet — safe to auto-generate
557
+ console.log(` ${c.bold}Generating encryption keypair…${c.reset}\n`);
558
+ const { mnemonic, kp } = await generateAndSaveKeypair();
559
+ console.log(` ${c.yellow}⚠ Save your recovery phrase — this is the only backup of your encryption key.${c.reset}\n`);
560
+ await showRecoveryPhrase(mnemonic, kp);
561
+
562
+ } else {
563
+ // Scenario C: returning user on a new device — DO NOT auto-generate
564
+ console.log();
565
+ console.log(` ${c.yellow}${c.bold}⚠ No encryption keypair found on this device.${c.reset}`);
566
+ console.log();
567
+ console.log(` ${c.bold}If you have your recovery phrase from a previous device:${c.reset}`);
568
+ console.log(` orbseal keys restore`);
569
+ console.log();
570
+ console.log(` ${c.bold}If you have never set up a keypair before:${c.reset}`);
571
+ console.log(` orbseal keygen`);
572
+ console.log();
573
+ console.log(` ${c.red}${c.bold}Do NOT run keygen if you have existing sealed secrets —`);
574
+ console.log(` they will become permanently inaccessible.${c.reset}`);
575
+ console.log();
576
+ }
577
+ },
578
+
579
+ logout() {
580
+ const cfg = loadConfig();
581
+ delete cfg.token;
582
+ saveConfig(cfg);
583
+ success('Logged out.');
584
+ },
585
+
586
+ // ── Switch between saved admin / app-key tokens ───────────────────────────
587
+ switchToken(args) {
588
+ const target = (args[0] ?? '').toLowerCase();
589
+ const cfg = loadConfig();
590
+
591
+ if (target === 'admin') {
592
+ if (!cfg.admin_token) fatal('No admin token saved. Run: orbseal login orb_admin_...');
593
+ cfg.token = cfg.admin_token;
594
+ saveConfig(cfg);
595
+ success(`Switched to ${c.green}admin${c.reset} ${c.dim}${cfg.admin_token.slice(0, 18)}…${c.reset}`);
596
+ return;
597
+ }
598
+ if (target === 'app' || target === 'live') {
599
+ if (!cfg.app_token) fatal('No app token saved. Run: orbseal login orb_live_...');
600
+ cfg.token = cfg.app_token;
601
+ saveConfig(cfg);
602
+ success(`Switched to ${c.yellow}app key${c.reset} ${c.dim}${cfg.app_token.slice(0, 18)}…${c.reset}`);
603
+ return;
604
+ }
605
+
606
+ // No arg — show available slots
607
+ console.log();
608
+ kv([
609
+ ['active', c.cyan + (cfg.token?.slice(0, 18) ?? '—') + '…' + c.reset],
610
+ ['admin_token', cfg.admin_token ? c.green + cfg.admin_token.slice(0, 18) + '…' + c.reset : c.dim + '(none)' + c.reset],
611
+ ['app_token', cfg.app_token ? c.yellow + cfg.app_token.slice(0, 18) + '…' + c.reset : c.dim + '(none)' + c.reset],
612
+ ]);
613
+ console.log(`\n ${c.dim}Usage: orbseal switch admin | app${c.reset}`);
614
+ },
615
+
616
+ whoami() {
617
+ const cfg = loadConfig();
618
+ if (!cfg.token) fatal('Not logged in.');
619
+ const ctx = cfg.context ?? {};
620
+ const mode = cfg.token.startsWith('orb_live_')
621
+ ? c.yellow + 'app key' + c.reset
622
+ : c.green + 'admin' + c.reset;
623
+ const rows = [
624
+ ['token', c.cyan + cfg.token.slice(0, 18) + '…' + c.reset + ' ' + mode],
625
+ ['api', cfg.apiBase ?? DEFAULT_API],
626
+ ];
627
+ if (cfg.admin_token && cfg.token !== cfg.admin_token)
628
+ rows.push(['admin', c.dim + cfg.admin_token.slice(0, 18) + '… (saved)' + c.reset]);
629
+ if (cfg.app_token && cfg.token !== cfg.app_token)
630
+ rows.push(['app key', c.dim + cfg.app_token.slice(0, 18) + '… (saved)' + c.reset]);
631
+ const path = [ctx.org, ctx.workspace, ctx.project, ctx.env].filter(Boolean).join('/');
632
+ if (path) rows.push(['context', c.bold + path + c.reset]);
633
+ if (ctx.public_key) rows.push(['public key', c.dim + ctx.public_key.slice(0, 24) + '…' + c.reset]);
634
+ kv(rows);
635
+ },
636
+
637
+ // ── Status ────────────────────────────────────────────────────────────────
638
+
639
+ async status(_args) {
640
+ const cfg = loadConfig();
641
+ if (!cfg.token) fatal('Not logged in. Run: orbseal login');
642
+
643
+ const isApp = cfg.token.startsWith('orb_live_');
644
+
645
+ console.log();
646
+ kv([
647
+ ['token', c.cyan + cfg.token.slice(0, 18) + '…' + c.reset],
648
+ ['api', cfg.apiBase ?? DEFAULT_API],
649
+ ['mode', isApp ? c.yellow + 'runtime (app key)' + c.reset : c.green + 'admin' + c.reset],
650
+ ]);
651
+
652
+ if (isApp) {
653
+ console.log();
654
+ console.log(` ${c.dim}Runtime mode — use ${c.reset}${c.bold}orbseal resolve${c.reset}${c.dim} to fetch config.${c.reset}`);
655
+ console.log();
656
+ return;
657
+ }
658
+
659
+ // Admin mode — fetch org tree
660
+ console.log();
661
+ let orgs;
662
+ try { orgs = (await api('GET', '/v1/orgs')).orgs; }
663
+ catch { console.log(` ${c.red}Could not fetch orgs — check token.${c.reset}\n`); return; }
664
+
665
+ for (const org of orgs) {
666
+ console.log(` ${c.bold}${c.green}●${c.reset} ${c.bold}${org.slug}${c.reset} ${c.dim}${org.name}${c.reset} ${fmtRole(org.role)}`);
667
+
668
+ let workspaces;
669
+ try { workspaces = (await api('GET', '/v1/workspaces')).workspaces; }
670
+ catch { continue; }
671
+
672
+ for (let wi = 0; wi < workspaces.length; wi++) {
673
+ const ws = workspaces[wi];
674
+ const isLastWs = wi === workspaces.length - 1;
675
+ const wsPfx = isLastWs ? ' └──' : ' ├──';
676
+ const wsPad = isLastWs ? ' ' : ' │ ';
677
+
678
+ console.log(`${wsPfx} ${c.bold}${ws.slug}${c.reset} ${c.dim}${ws.name} workspace${c.reset}`);
679
+
680
+ let projects;
681
+ try { projects = (await api('GET', `/v1/workspaces/${ws.slug}/projects`)).projects; }
682
+ catch { continue; }
683
+
684
+ for (let pi = 0; pi < projects.length; pi++) {
685
+ const proj = projects[pi];
686
+ const isLastP = pi === projects.length - 1;
687
+ const pPfx = isLastP ? `${wsPad}└──` : `${wsPad}├──`;
688
+ const pPad = isLastP ? `${wsPad} ` : `${wsPad}│ `;
689
+
690
+ let envs, defs, keys, latestRelease;
691
+ try {
692
+ [envs, defs, keys] = await Promise.all([
693
+ api('GET', `/v1/workspaces/${ws.slug}/projects/${proj.slug}/environments`).then(r => r.environments),
694
+ api('GET', `/v1/workspaces/${ws.slug}/projects/${proj.slug}/schema`).then(r => r.definitions),
695
+ api('GET', `/v1/workspaces/${ws.slug}/projects/${proj.slug}/keys`).then(r => r.keys),
696
+ ]);
697
+ if (envs.length) {
698
+ const env = envs[0];
699
+ const rels = await api('GET', `/v1/workspaces/${ws.slug}/projects/${proj.slug}/environments/${env.slug}/releases`).then(r => r.releases).catch(() => []);
700
+ latestRelease = rels[0];
701
+ }
702
+ } catch { envs = []; defs = []; keys = []; }
703
+
704
+ const activeKeys = (keys ?? []).filter(k => !k.revoked_at).length;
705
+ const envNames = (envs ?? []).map(e => e.slug).join(', ') || c.dim + 'none' + c.reset;
706
+ const defCount = (defs ?? []).length;
707
+ const relInfo = latestRelease
708
+ ? `v${latestRelease.version} ${c.dim}${String(latestRelease.created_at ?? '').slice(0,10)}${c.reset}`
709
+ : c.dim + 'no releases' + c.reset;
710
+
711
+ console.log(`${pPfx} ${c.bold}${proj.slug}${c.reset} ${c.dim}${proj.name}${c.reset}`);
712
+ console.log(`${pPad} envs ${envNames}`);
713
+ console.log(`${pPad} definitions ${defCount}`);
714
+ console.log(`${pPad} app keys ${activeKeys} active`);
715
+ console.log(`${pPad} latest ${relInfo}`);
716
+ }
717
+ }
718
+ }
719
+ console.log();
720
+ },
721
+
722
+ // ── Release ───────────────────────────────────────────────────────────────
723
+
724
+ async release(args) {
725
+ const [ws, proj, env] = resolveCtx(args, 'ws', 'proj', 'env');
726
+
727
+ const data = await api('POST', `/v1/workspaces/${ws}/projects/${proj}/environments/${env}/release`);
728
+
729
+ success(`Release ${c.bold}v${data.version}${c.reset} ${c.dim}etag: ${data.etag}${c.reset}`);
730
+ console.log();
731
+ kv([
732
+ ['project', proj],
733
+ ['environment', env],
734
+ ['version', c.bold + String(data.version) + c.reset],
735
+ ['etag', data.etag],
736
+ ['released_at', data.released_at],
737
+ ['config keys', String(Object.keys(data.config ?? {}).length)],
738
+ ['secret keys', String((data.secret_keys ?? []).length)],
739
+ ]);
740
+
741
+ if (Object.keys(data.config ?? {}).length) {
742
+ console.log(`\n ${c.dim}Config snapshot:${c.reset}`);
743
+ for (const [k, v] of Object.entries(data.config)) {
744
+ console.log(` ${c.dim}${k.padEnd(36)}${c.reset} ${c.cyan}${JSON.stringify(v)}${c.reset}`);
745
+ }
746
+ }
747
+ },
748
+
749
+ async releases(args) {
750
+ const [sub, ...rest] = args;
751
+
752
+ if (!sub || sub === 'list') {
753
+ const [ws, proj, env] = resolveCtx(rest, 'ws', 'proj', 'env');
754
+ const data = await api('GET', `/v1/workspaces/${ws}/projects/${proj}/environments/${env}/releases`);
755
+ table(data.releases, [
756
+ { key: 'version', label: 'VER', fmt: v => c.bold + String(v) + c.reset },
757
+ { key: 'etag', label: 'ETAG', fmt: v => c.dim + String(v) + c.reset },
758
+ { key: 'created_by', label: 'BY', fmt: v => c.dim + String(v).slice(0, 8) + '…' + c.reset },
759
+ { key: 'created_at', label: 'RELEASED', fmt: fmtDate },
760
+ ]);
761
+ return;
762
+ }
763
+
764
+ if (sub === 'get') {
765
+ const [ws, proj, env] = resolveCtx(rest, 'ws', 'proj', 'env');
766
+ const ver = rest[3] ?? 'latest';
767
+ const data = await api('GET', `/v1/workspaces/${ws}/projects/${proj}/environments/${env}/releases/${ver}`);
768
+
769
+ kv([
770
+ ['version', c.bold + String(data.version) + c.reset],
771
+ ['etag', c.dim + data.etag + c.reset],
772
+ ['environment', data.environment],
773
+ ['released_at', data.released_at],
774
+ ]);
775
+
776
+ if (data.config) {
777
+ console.log(`\n ${c.bold}Config${c.reset}`);
778
+ for (const [k, v] of Object.entries(data.config)) {
779
+ console.log(` ${c.dim}${k.padEnd(36)}${c.reset} ${c.cyan}${JSON.stringify(v)}${c.reset}`);
780
+ }
781
+ }
782
+ if (data.secret_keys?.length) {
783
+ console.log(`\n ${c.bold}Secrets${c.reset} ${c.dim}(resolved at runtime)${c.reset}`);
784
+ for (const k of data.secret_keys) {
785
+ console.log(` ${c.dim}${k}${c.reset}`);
786
+ }
787
+ }
788
+ return;
789
+ }
790
+
791
+ fatal(`unknown subcommand: releases ${sub}`);
792
+ },
793
+
794
+ // ── Audit log ─────────────────────────────────────────────────────────────
795
+
796
+ async audit(args) {
797
+ // orbseal audit list [ws] [proj] [--type <action>] [--actor <id>]
798
+ // [--key <plugin:key>] [--from <date>] [--to <date>]
799
+ // [--limit <n>] [--cursor <token>] [--json]
800
+ const sub = args[0];
801
+ if (sub !== 'list' && sub !== undefined) {
802
+ fatal(`unknown subcommand: audit ${sub}\n Usage: orbseal audit list [workspace] [project] [--type value_set] [--key plugin:key]`);
803
+ }
804
+ const rest = sub === 'list' ? args.slice(1) : args;
805
+
806
+ // parse flags first so resolveCtx gets the positional-only slice
807
+ const flagArgs = rest.filter(a => a.startsWith('--'));
808
+ const posArgs = rest.filter(a => !a.startsWith('--'));
809
+
810
+ const getFlag = (name) => {
811
+ const f = flagArgs.find(a => a.startsWith(`--${name}=`));
812
+ if (f) return f.slice(name.length + 3);
813
+ const i = flagArgs.indexOf(`--${name}`);
814
+ return i !== -1 ? flagArgs[i + 1] : null;
815
+ };
816
+
817
+ const resolved = resolveCtx(posArgs, 'ws?', 'proj?');
818
+ const [ws, proj] = resolved;
819
+
820
+ const typeFilter = getFlag('type');
821
+ const actorFilter = getFlag('actor');
822
+ const keyFilter = getFlag('key');
823
+ const fromFilter = getFlag('from');
824
+ const toFilter = getFlag('to');
825
+ const limitFilter = getFlag('limit') ?? '50';
826
+ const cursorArg = getFlag('cursor');
827
+ const jsonOut = flagArgs.includes('--json');
828
+
829
+ let url, data;
830
+
831
+ if (ws && proj) {
832
+ // Project-level audit
833
+ const q = new URLSearchParams({ limit: limitFilter });
834
+ if (typeFilter) q.set('type', typeFilter);
835
+ if (actorFilter) q.set('actor', actorFilter);
836
+ if (keyFilter) q.set('key', keyFilter);
837
+ if (fromFilter) q.set('from', fromFilter);
838
+ if (toFilter) q.set('to', toFilter);
839
+ if (cursorArg) q.set('cursor', cursorArg);
840
+
841
+ url = `/v1/workspaces/${ws}/projects/${proj}/audit?${q}`;
842
+ data = await api('GET', url);
843
+ } else {
844
+ // Org-level audit
845
+ const q = new URLSearchParams({ limit: limitFilter });
846
+ if (typeFilter) q.set('type', typeFilter);
847
+ if (actorFilter) q.set('actor', actorFilter);
848
+ if (fromFilter) q.set('from', fromFilter);
849
+ if (toFilter) q.set('to', toFilter);
850
+ if (cursorArg) q.set('cursor', cursorArg);
851
+
852
+ url = `/v1/audit?${q}`;
853
+ data = await api('GET', url);
854
+ }
855
+
856
+ if (jsonOut) {
857
+ console.log(JSON.stringify(data, null, 2));
858
+ return;
859
+ }
860
+
861
+ const events = data.events ?? [];
862
+ if (!events.length) {
863
+ console.log(`\n ${c.dim}no audit events found${c.reset}\n`);
864
+ return;
865
+ }
866
+
867
+ // Action → color
868
+ const actionColor = (action) => {
869
+ if (action.endsWith('_deleted') || action.endsWith('_revoked')) return c.red + action + c.reset;
870
+ if (action === 'release_created') return c.green + action + c.reset;
871
+ if (action.startsWith('secret')) return c.yellow + action + c.reset;
872
+ return c.dim + action + c.reset;
873
+ };
874
+
875
+ console.log();
876
+ const rows = events.map(e => {
877
+ const key = e.plugin && e.key ? ` ${c.dim}${e.plugin}:${e.key}${c.reset}` : '';
878
+ const scope = e.scope ? ` ${c.dim}${e.scope}/${e.scope_ref ?? ''}${c.reset}` : '';
879
+ const actor = e.actor ? e.actor.slice(0, 8) + '…' : '—';
880
+ const date = fmtDate(e.created_at);
881
+ return [date, actionColor(e.action), actor, key + scope].join(' ');
882
+ });
883
+ rows.forEach(r => console.log(` ${r}`));
884
+
885
+ if (data.has_more && data.next_cursor) {
886
+ console.log(`\n ${c.dim}More results — fetch next page:${c.reset}`);
887
+ console.log(` ${c.bold}orbseal audit list --cursor ${data.next_cursor}${c.reset}`);
888
+ }
889
+
890
+ console.log();
891
+ },
892
+
893
+ // ── Seal (set a secret) ───────────────────────────────────────────────────
894
+
895
+ async seal(args) {
896
+ // orbseal seal [ws] [proj] <plugin:key> --public-key <key> --scope <s> --ref <r>
897
+ const resolved = resolveCtx(args, 'ws', 'proj');
898
+ const [ws, proj] = resolved;
899
+ const remaining = args.slice(resolved.consumed);
900
+
901
+ const [pluginKey, ...flags] = remaining;
902
+ if (!pluginKey) {
903
+ fatal('usage: seal [workspace] [project] <plugin:key> --scope <scope> --ref <scope_ref> [--public-key <key>]');
904
+ }
905
+ const [plugin, key] = pluginKey.split(':');
906
+ if (!plugin || !key) fatal('plugin:key format required, e.g. email:smtp_password');
907
+
908
+ const cfg = loadConfig();
909
+ let publicKey = cfg.context?.public_key ?? '';
910
+ let scope = 'environment', scopeRef = '';
911
+ for (let i = 0; i < flags.length; i++) {
912
+ if (flags[i] === '--public-key' && flags[i+1]) { publicKey = flags[++i]; }
913
+ if (flags[i] === '--scope' && flags[i+1]) { scope = flags[++i]; }
914
+ if (flags[i] === '--ref' && flags[i+1]) { scopeRef = flags[++i]; }
915
+ }
916
+ if (!publicKey) fatal('--public-key required (or run: orbseal use --public-key <key>)');
917
+ if (!scopeRef) fatal('--ref is required (e.g. --ref production)');
918
+
919
+ // Prompt for secret value — hidden input
920
+ const value = await promptSecret('Secret value: ');
921
+ if (!value) fatal('value cannot be empty');
922
+
923
+ // Decode key (orbpk-... or legacy base64) → raw bytes → base64 for libsodium
924
+ const pubKeyRaw = parsePublicKey(publicKey);
925
+ const pubKeyB64 = pubKeyRaw.toString('base64');
926
+
927
+ // Seal using libsodium CJS (avoids ESM WASM resolution issue in Node.js)
928
+ let ciphertext;
929
+ try {
930
+ const { createRequire } = await import('node:module');
931
+ const req = createRequire(import.meta.url);
932
+ const sodium = req('libsodium-wrappers');
933
+ await sodium.ready;
934
+ const pk = sodium.from_base64(pubKeyB64, sodium.base64_variants.ORIGINAL);
935
+ const ct = sodium.crypto_box_seal(sodium.from_string(value), pk);
936
+ ciphertext = sodium.to_base64(ct, sodium.base64_variants.ORIGINAL);
937
+ } catch (e) {
938
+ fatal(`seal failed: ${e?.message ?? e}`);
939
+ }
940
+
941
+ await api('PUT', `/v1/workspaces/${ws}/projects/${proj}/secrets/${plugin}/${key}`, {
942
+ ciphertext,
943
+ public_key: pubKeyB64,
944
+ scope,
945
+ scope_ref: scopeRef,
946
+ });
947
+
948
+ success(`Sealed ${c.bold}${pluginKey}${c.reset} ${c.dim}scope=${scope} ref=${scopeRef}${c.reset}`);
949
+ },
950
+
951
+ // ── User settings (app key) ───────────────────────────────────────────────
952
+
953
+ async settings(args) {
954
+ const [sub, ...rest] = args;
955
+
956
+ if (!sub || sub === 'list') {
957
+ const [userId] = rest;
958
+ if (!userId) fatal('usage: settings list <user_id>');
959
+ const data = await api('GET', `/v1/config/user-values?user_id=${encodeURIComponent(userId)}`);
960
+ console.log(`\n ${c.dim}user: ${userId}${c.reset}\n`);
961
+ table(data.settings, [
962
+ { key: 'plugin', label: 'PLUGIN' },
963
+ { key: 'key', label: 'KEY' },
964
+ { key: 'label', label: 'LABEL', fmt: v => v ?? c.dim + '—' + c.reset },
965
+ { key: 'value', label: 'VALUE', fmt: v => c.cyan + JSON.stringify(v) + c.reset },
966
+ { key: 'is_override', label: 'OVERRIDE', fmt: v => v ? c.yellow + 'yes' + c.reset : c.dim + 'no' + c.reset },
967
+ { key: 'type', label: 'TYPE' },
968
+ { key: 'component', label: 'COMPONENT', fmt: v => v ? String(v) : c.dim + '—' + c.reset },
969
+ ]);
970
+ return;
971
+ }
972
+
973
+ if (sub === 'set') {
974
+ const [userId, pluginKey, val] = rest;
975
+ if (!userId || !pluginKey || val === undefined) {
976
+ fatal('usage: settings set <user_id> <plugin:key> <value>');
977
+ }
978
+ const [plugin, key] = pluginKey.split(':');
979
+ if (!plugin || !key) fatal('plugin:key format required, e.g. taskflow:theme');
980
+ let parsed;
981
+ try { parsed = JSON.parse(val); } catch { parsed = val; }
982
+ await api('PUT', `/v1/config/user-values/${plugin}/${key}`, { value: parsed, user_id: userId });
983
+ success(`Set ${c.bold}${pluginKey}${c.reset} = ${c.cyan}${JSON.stringify(parsed)}${c.reset} ${c.dim}for user ${userId}${c.reset}`);
984
+ return;
985
+ }
986
+
987
+ if (sub === 'reset') {
988
+ const [userId, pluginKey] = rest;
989
+ if (!userId || !pluginKey) fatal('usage: settings reset <user_id> <plugin:key>');
990
+ const [plugin, key] = pluginKey.split(':');
991
+ await api('DELETE', `/v1/config/user-values/${plugin}/${key}?user_id=${encodeURIComponent(userId)}`);
992
+ success(`Reset ${c.bold}${pluginKey}${c.reset} to default for user ${userId}`);
993
+ return;
994
+ }
995
+
996
+ fatal(`unknown subcommand: settings ${sub}`);
997
+ },
998
+
999
+ // ── Resolve ───────────────────────────────────────────────────────────────
1000
+
1001
+ async resolve(args) {
1002
+ const [userFlag] = args;
1003
+ const userId = userFlag?.startsWith('--user=') ? userFlag.slice(7) : null;
1004
+ const qs = userId ? `?user=${encodeURIComponent(userId)}` : '';
1005
+
1006
+ const cfg = loadConfig();
1007
+ if (!cfg.token?.startsWith('orb_live_')) {
1008
+ fatal('resolve requires an app key (orb_live_xxx). Login with an app key: orbseal login <token>');
1009
+ }
1010
+
1011
+ const data = await api('GET', `/v1/config/resolve${qs}`);
1012
+
1013
+ console.log(`\n ${c.bold}${c.dim}etag: ${data.etag} resolved: ${data.resolved_at}${c.reset}\n`);
1014
+
1015
+ console.log(` ${c.bold}Config${c.reset}`);
1016
+ for (const [k, v] of Object.entries(data.config)) {
1017
+ const display = v === null ? c.red + 'MISSING' + c.reset : c.cyan + JSON.stringify(v) + c.reset;
1018
+ console.log(` ${c.dim}${k.padEnd(36)}${c.reset} ${display}`);
1019
+ }
1020
+
1021
+ if (Object.keys(data.secrets).length) {
1022
+ console.log(`\n ${c.bold}Secrets${c.reset} ${c.dim}(ciphertext)${c.reset}`);
1023
+ for (const [k, v] of Object.entries(data.secrets)) {
1024
+ const display = v ? c.yellow + v.slice(0, 20) + '…' + c.reset : c.red + 'MISSING' + c.reset;
1025
+ console.log(` ${c.dim}${k.padEnd(36)}${c.reset} ${display}`);
1026
+ }
1027
+ }
1028
+ console.log();
1029
+ },
1030
+
1031
+ // ── Keygen ────────────────────────────────────────────────────────────────
1032
+
1033
+ async keygen(_args) {
1034
+ // Generate 128-bit entropy → 12-word BIP39 mnemonic → X25519 keypair
1035
+ const existing = loadMnemonic();
1036
+ if (existing) {
1037
+ const existingKp = await keypairFromMnemonic(existing);
1038
+ console.log();
1039
+ console.log(` ${c.yellow}${c.bold}⚠ A keypair already exists on this device.${c.reset}`);
1040
+ console.log();
1041
+ console.log(` Current public key: ${c.cyan}${existingKp.publicKey}${c.reset}`);
1042
+ console.log();
1043
+ console.log(` Why do you want to generate a new keypair?`);
1044
+ console.log();
1045
+ console.log(` ${c.bold}[1]${c.reset} I lost my key / starting fresh`);
1046
+ console.log(` ${c.dim}Old secrets sealed with the current key become inaccessible.${c.reset}`);
1047
+ console.log();
1048
+ console.log(` ${c.bold}[2]${c.reset} Planned rotation — I want to export my current phrase first`);
1049
+ console.log(` ${c.dim}Export current recovery phrase → generate new key.${c.reset}`);
1050
+ console.log(` ${c.dim}Old key remains restorable via: orbseal keys restore${c.reset}`);
1051
+ console.log();
1052
+ const choice = (await prompt(` Choose [1/2] or Enter to cancel: `)).trim();
1053
+
1054
+ if (choice === '2') {
1055
+ // ── Rotation path: show current phrase before overwriting ──────────
1056
+ console.log();
1057
+ console.log(` ${c.yellow}${c.bold}Export your current recovery phrase now.${c.reset}`);
1058
+ console.log(` ${c.dim}After you generate a new key, you can restore the old one anytime with:${c.reset}`);
1059
+ console.log(` ${c.dim} orbseal keys restore${c.reset}`);
1060
+ await showRecoveryPhrase(existing, existingKp);
1061
+ console.log();
1062
+ const confirm = (await prompt(` Type ${c.bold}ROTATE${c.reset} to generate new keypair: `)).trim();
1063
+ if (confirm !== 'ROTATE') {
1064
+ console.log(`\n ${c.dim}Cancelled. Existing keypair kept.${c.reset}\n`);
1065
+ return;
1066
+ }
1067
+ } else if (choice === '1') {
1068
+ // ── Lost key path: warn about permanent data loss ─────────────────
1069
+ console.log();
1070
+ console.log(` ${c.red}${c.bold}All secrets sealed with the current public key will be`);
1071
+ console.log(` permanently inaccessible — there is no way to recover them.${c.reset}`);
1072
+ console.log();
1073
+ const confirm = (await prompt(` Type ${c.bold}LOST${c.reset} to confirm and generate new keypair: `)).trim();
1074
+ if (confirm !== 'LOST') {
1075
+ console.log(`\n ${c.dim}Cancelled. Existing keypair kept.${c.reset}\n`);
1076
+ return;
1077
+ }
1078
+ } else {
1079
+ console.log(`\n ${c.dim}Cancelled. Existing keypair kept.${c.reset}\n`);
1080
+ return;
1081
+ }
1082
+ console.log();
1083
+ }
1084
+ const { mnemonic, kp } = await generateAndSaveKeypair();
1085
+ console.log(`\n ${c.yellow}⚠ Save your recovery phrase — this is the only backup of your encryption key.${c.reset}\n`);
1086
+ await showRecoveryPhrase(mnemonic, kp);
1087
+ console.log(` ${c.dim}Use public key when creating an app key:${c.reset}`);
1088
+ console.log(` orbseal keys create ... --public-key "${kp.publicKey}"\n`);
1089
+ },
1090
+
1091
+ async recover(args) {
1092
+ if (!args.length) fatal('usage: recover "word1 word2 ... word12"');
1093
+ const phrase = args.join(' ');
1094
+ const err = await validateMnemonic(phrase);
1095
+ if (err) fatal(err);
1096
+
1097
+ const kp = await keypairFromMnemonic(phrase);
1098
+ console.log();
1099
+ console.log(` ${c.bold}Keypair recovered:${c.reset}`);
1100
+ console.log();
1101
+ kv([
1102
+ ['public key ', c.cyan + kp.publicKey + c.reset],
1103
+ ['private key', c.dim + kp.privateKey + c.reset],
1104
+ ]);
1105
+ console.log();
1106
+ },
1107
+
1108
+ // ── App Keys + Keypair management ─────────────────────────────────────────
1109
+
1110
+ async keys(args) {
1111
+ const [sub, ...rest] = args;
1112
+
1113
+ // ── keys show ────────────────────────────────────────────────────────────
1114
+ if (sub === 'show') {
1115
+ const mnemonic = loadMnemonic();
1116
+ if (!mnemonic) {
1117
+ fatal(
1118
+ 'No keypair found at ~/.orbseal/key\n' +
1119
+ ` Generate one: ${c.bold}orbseal keygen${c.reset}\n` +
1120
+ ` Restore one: ${c.bold}orbseal keys restore${c.reset}`
1121
+ );
1122
+ }
1123
+ const kp = await keypairFromMnemonic(mnemonic);
1124
+ console.log();
1125
+ console.log(` ${c.yellow}⚠ Make sure no one can see your screen.${c.reset}\n`);
1126
+ const answer = await prompt(' Show recovery phrase? [y/N] ');
1127
+ if (answer.trim().toLowerCase() !== 'y') {
1128
+ console.log(`\n ${c.dim}Cancelled.${c.reset}\n`);
1129
+ return;
1130
+ }
1131
+ await showRecoveryPhrase(mnemonic, kp);
1132
+ return;
1133
+ }
1134
+
1135
+ // ── keys restore ─────────────────────────────────────────────────────────
1136
+ if (sub === 'restore') {
1137
+ console.log(`\n Enter your 12-word recovery phrase:\n`);
1138
+ const phrase = (await prompt(' > ')).trim();
1139
+ const validErr = await validateMnemonic(phrase);
1140
+ if (validErr) fatal(validErr);
1141
+
1142
+ const kp = await keypairFromMnemonic(phrase);
1143
+ const existing = loadMnemonic();
1144
+ if (existing) {
1145
+ const answer = await prompt(`\n A keypair already exists. Overwrite? [y/N] `);
1146
+ if (answer.trim().toLowerCase() !== 'y') {
1147
+ console.log(`\n ${c.dim}Cancelled.${c.reset}\n`);
1148
+ return;
1149
+ }
1150
+ }
1151
+ saveMnemonic(phrase);
1152
+ console.log();
1153
+ success('Keypair restored and saved to ~/.orbseal/key');
1154
+ kv([['public key', c.cyan + kp.publicKey + c.reset]]);
1155
+ console.log();
1156
+ return;
1157
+ }
1158
+
1159
+ if (!sub || sub === 'list') {
1160
+ const [ws, proj] = resolveCtx(rest, 'ws', 'proj');
1161
+ const data = await api('GET', `/v1/workspaces/${ws}/projects/${proj}/keys`);
1162
+ table(data.keys, [
1163
+ { key: 'name', label: 'NAME' },
1164
+ { key: 'key_prefix', label: 'PREFIX' },
1165
+ { key: 'public_key', label: 'PUBLIC KEY', fmt: v => c.dim + String(v).slice(0, 12) + '…' + c.reset },
1166
+ { key: 'created_at', label: 'CREATED', fmt: fmtDate },
1167
+ { key: 'last_used_at',label: 'LAST USED', fmt: fmtDate },
1168
+ { key: 'revoked_at', label: 'STATUS', fmt: (v) => fmtStatus(v) },
1169
+ ]);
1170
+ return;
1171
+ }
1172
+
1173
+ if (sub === 'create') {
1174
+ const resolved = resolveCtx(rest, 'ws', 'proj');
1175
+ const [ws, proj] = resolved;
1176
+ const [name, ...flags] = rest.slice(resolved.consumed);
1177
+ if (!name) fatal('usage: keys create [workspace] [project] <name> --public-key <key> [--env <slug>]');
1178
+
1179
+ let publicKey = '', envSlug = '';
1180
+ for (let i = 0; i < flags.length; i++) {
1181
+ if (flags[i] === '--public-key' && flags[i+1]) { publicKey = flags[++i]; }
1182
+ if (flags[i] === '--env' && flags[i+1]) { envSlug = flags[++i]; }
1183
+ }
1184
+ if (!publicKey) fatal('--public-key is required (run: orbseal keygen)');
1185
+
1186
+ // Accept orbpk-... or legacy base64
1187
+ const pubKeyB64 = parsePublicKey(publicKey).toString('base64');
1188
+ const body = { name, public_key: pubKeyB64, ...(envSlug ? { environment: envSlug } : {}) };
1189
+ const data = await api('POST', `/v1/workspaces/${ws}/projects/${proj}/keys`, body);
1190
+
1191
+ console.log(`\n ${c.bold}App key created — save the token, it won't be shown again:${c.reset}\n`);
1192
+ console.log(` ${c.cyan}${c.bold}${data.token}${c.reset}\n`);
1193
+ kv([
1194
+ ['id', data.id],
1195
+ ['prefix', data.key_prefix],
1196
+ ['name', data.name],
1197
+ ['public_key', c.dim + data.public_key.slice(0, 16) + '…' + c.reset],
1198
+ ['env', data.environment_id ?? c.dim + 'all environments' + c.reset],
1199
+ ]);
1200
+ return;
1201
+ }
1202
+
1203
+ if (sub === 'revoke') {
1204
+ const resolved = resolveCtx(rest, 'ws', 'proj');
1205
+ const [ws, proj] = resolved;
1206
+ const [id] = rest.slice(resolved.consumed);
1207
+ if (!id) fatal('usage: keys revoke [workspace] [project] <id>');
1208
+ await api('DELETE', `/v1/workspaces/${ws}/projects/${proj}/keys/${id}`);
1209
+ success('App key revoked.');
1210
+ return;
1211
+ }
1212
+
1213
+ fatal(`unknown subcommand: keys ${sub}`);
1214
+ },
1215
+
1216
+ // ── Tokens ────────────────────────────────────────────────────────────────
1217
+
1218
+ async tokens(args) {
1219
+ const [sub, ...rest] = args;
1220
+
1221
+ if (!sub || sub === 'list') {
1222
+ const data = await api('GET', '/v1/tokens');
1223
+ table(data.tokens, [
1224
+ { key: 'key_prefix', label: 'PREFIX' },
1225
+ { key: 'name', label: 'NAME', fmt: v => v || c.dim + '—' + c.reset },
1226
+ { key: 'last_used_at', label: 'LAST USED', fmt: fmtDate },
1227
+ { key: 'created_at', label: 'CREATED', fmt: fmtDate },
1228
+ { key: 'revoked_at', label: 'STATUS', fmt: (v) => fmtStatus(v) },
1229
+ ]);
1230
+ return;
1231
+ }
1232
+
1233
+ if (sub === 'create') {
1234
+ const name = rest.join(' ') || '';
1235
+ const data = await api('POST', '/v1/tokens', { name });
1236
+ console.log();
1237
+ console.log(` ${c.bold}Token created — save it now, it won't be shown again:${c.reset}`);
1238
+ console.log();
1239
+ console.log(` ${c.cyan}${c.bold}${data.token}${c.reset}`);
1240
+ console.log();
1241
+ kv([
1242
+ ['id', data.id],
1243
+ ['prefix', data.key_prefix],
1244
+ ['name', data.name || c.dim + '(none)' + c.reset],
1245
+ ['org', data.org_id],
1246
+ ]);
1247
+ return;
1248
+ }
1249
+
1250
+ if (sub === 'revoke') {
1251
+ const [id] = rest;
1252
+ if (!id) fatal('usage: tokens revoke <id>');
1253
+ await api('DELETE', `/v1/tokens/${id}`);
1254
+ success('Token revoked.');
1255
+ return;
1256
+ }
1257
+
1258
+ fatal(`unknown subcommand: tokens ${sub}`);
1259
+ },
1260
+
1261
+ // ── Workspaces ────────────────────────────────────────────────────────────
1262
+
1263
+ async workspaces(args) {
1264
+ const [sub, ...rest] = args;
1265
+
1266
+ if (!sub || sub === 'list') {
1267
+ const data = await api('GET', '/v1/workspaces');
1268
+ table(data.workspaces, [
1269
+ { key: 'short_id', label: 'ID' },
1270
+ { key: 'slug', label: 'SLUG' },
1271
+ { key: 'name', label: 'NAME' },
1272
+ { key: 'project_count', label: 'PROJECTS', fmt: v => String(v) },
1273
+ { key: 'created_at', label: 'CREATED', fmt: fmtDate },
1274
+ ]);
1275
+ return;
1276
+ }
1277
+
1278
+ if (sub === 'create') {
1279
+ const name = rest.join(' ');
1280
+ if (!name) fatal('usage: workspaces create <name>');
1281
+ const data = await api('POST', '/v1/workspaces', { name });
1282
+ success(`Workspace created: ${c.bold}${data.slug}${c.reset} ${c.dim}(id: ${data.short_id ?? '—'})${c.reset}`);
1283
+ return;
1284
+ }
1285
+
1286
+ if (sub === 'get') {
1287
+ const [slug] = rest;
1288
+ if (!slug) fatal('usage: workspaces get <slug|short_id>');
1289
+ const data = await api('GET', `/v1/workspaces/${slug}`);
1290
+ kv([
1291
+ ['id', c.dim + data.id + c.reset],
1292
+ ['short_id', c.bold + data.short_id + c.reset],
1293
+ ['slug', c.bold + data.slug + c.reset],
1294
+ ['name', data.name],
1295
+ ['projects', String(data.project_count)],
1296
+ ['created', fmtDate(data.created_at)],
1297
+ ]);
1298
+ return;
1299
+ }
1300
+
1301
+ if (sub === 'rename') {
1302
+ const [slug, ...nameParts] = rest;
1303
+ const name = nameParts.join(' ');
1304
+ if (!slug || !name) fatal('usage: workspaces rename <slug> <name>');
1305
+ await api('PATCH', `/v1/workspaces/${slug}`, { name });
1306
+ success(`Workspace renamed to ${c.bold}${name}${c.reset}`);
1307
+ return;
1308
+ }
1309
+
1310
+ if (sub === 'delete') {
1311
+ const [slug] = rest;
1312
+ if (!slug) fatal('usage: workspaces delete <slug>');
1313
+ await api('DELETE', `/v1/workspaces/${slug}`);
1314
+ success(`Workspace ${c.bold}${slug}${c.reset} deleted.`);
1315
+ return;
1316
+ }
1317
+
1318
+ fatal(`unknown subcommand: workspaces ${sub}`);
1319
+ },
1320
+
1321
+ // ── Values ────────────────────────────────────────────────────────────────
1322
+
1323
+ async values(args) {
1324
+ const [sub, ...rest] = args;
1325
+
1326
+ if (!sub || sub === 'list') {
1327
+ const [ws, proj] = resolveCtx(rest, 'ws', 'proj');
1328
+ const data = await api('GET', `/v1/workspaces/${ws}/projects/${proj}/values`);
1329
+ table(data.values, [
1330
+ { key: 'plugin', label: 'PLUGIN' },
1331
+ { key: 'key', label: 'KEY' },
1332
+ { key: 'scope', label: 'SCOPE' },
1333
+ { key: 'scope_ref', label: 'SCOPE REF', fmt: v => c.dim + String(v).slice(0, 8) + '…' + c.reset },
1334
+ { key: 'value', label: 'VALUE', fmt: v => String(v).slice(0, 40) },
1335
+ { key: 'version', label: 'VER', fmt: v => c.dim + String(v) + c.reset },
1336
+ ]);
1337
+ return;
1338
+ }
1339
+
1340
+ if (sub === 'set') {
1341
+ // values set <ws> <proj> <plugin:key> <value> --scope <scope> --ref <scope_ref>
1342
+ const resolved = resolveCtx(rest, 'ws', 'proj');
1343
+ const [ws, proj] = resolved;
1344
+ const remaining = rest.slice(resolved.consumed);
1345
+ const [pluginKey, val, ...flags] = remaining;
1346
+ if (!pluginKey || val === undefined) {
1347
+ fatal('usage: values set [workspace] [project] <plugin:key> <value> [--scope <scope>] [--ref <scope_ref>]');
1348
+ }
1349
+ const [plugin, key] = pluginKey.split(':');
1350
+ if (!plugin || !key) fatal('plugin:key format required, e.g. email:smtp_host');
1351
+
1352
+ // Parse --scope and --ref flags
1353
+ let scope = 'project';
1354
+ let scopeRef = proj;
1355
+ for (let i = 0; i < flags.length; i++) {
1356
+ if (flags[i] === '--scope' && flags[i+1]) { scope = flags[++i]; }
1357
+ if (flags[i] === '--ref' && flags[i+1]) { scopeRef = flags[++i]; }
1358
+ }
1359
+
1360
+ // Try to parse value as JSON, fall back to string
1361
+ let parsed;
1362
+ try { parsed = JSON.parse(val); } catch { parsed = val; }
1363
+
1364
+ await api('PUT', `/v1/workspaces/${ws}/projects/${proj}/values/${plugin}/${key}`, {
1365
+ value: parsed, scope, scope_ref: scopeRef,
1366
+ });
1367
+ success(`Set ${c.bold}${pluginKey}${c.reset} ${c.dim}scope=${scope} ref=${scopeRef}${c.reset}`);
1368
+ return;
1369
+ }
1370
+
1371
+ if (sub === 'delete') {
1372
+ const resolved = resolveCtx(rest, 'ws', 'proj');
1373
+ const [ws, proj] = resolved;
1374
+ const [pluginKey, ...flags] = rest.slice(resolved.consumed);
1375
+ if (!pluginKey) fatal('usage: values delete [workspace] [project] <plugin:key> --scope <scope> --ref <scope_ref>');
1376
+ const [plugin, key] = pluginKey.split(':');
1377
+ let scope = 'project', scopeRef = proj;
1378
+ for (let i = 0; i < flags.length; i++) {
1379
+ if (flags[i] === '--scope' && flags[i+1]) { scope = flags[++i]; }
1380
+ if (flags[i] === '--ref' && flags[i+1]) { scopeRef = flags[++i]; }
1381
+ }
1382
+ await api('DELETE', `/v1/workspaces/${ws}/projects/${proj}/values/${plugin}/${key}?scope=${scope}&scope_ref=${scopeRef}`);
1383
+ success('Value deleted.');
1384
+ return;
1385
+ }
1386
+
1387
+ fatal(`unknown subcommand: values ${sub}`);
1388
+ },
1389
+
1390
+ // ── Schema ────────────────────────────────────────────────────────────────
1391
+
1392
+ async sync(args) {
1393
+ const resolved = resolveCtx(args, 'ws', 'proj');
1394
+ const [ws, proj] = resolved;
1395
+ const filePath = args[resolved.consumed] ?? 'orb.yaml';
1396
+
1397
+ const { readFileSync } = await import('fs');
1398
+ let yaml;
1399
+ try {
1400
+ yaml = readFileSync(filePath, 'utf8');
1401
+ } catch {
1402
+ fatal(`cannot read ${filePath}`);
1403
+ }
1404
+
1405
+ const data = await api('POST', `/v1/workspaces/${ws}/projects/${proj}/schema/sync`, yaml, 'text/plain');
1406
+
1407
+ if (data.errors?.length) {
1408
+ console.log(`\n ${c.red}Breaking changes blocked:${c.reset}`);
1409
+ for (const e of data.errors) console.log(` ${c.dim}✗${c.reset} ${e}`);
1410
+ }
1411
+
1412
+ console.log();
1413
+ kv([
1414
+ ['plugin', c.bold + data.plugin + c.reset],
1415
+ ['created', c.green + String(data.created) + c.reset],
1416
+ ['updated', c.cyan + String(data.updated) + c.reset],
1417
+ ['unchanged', c.dim + String(data.unchanged) + c.reset],
1418
+ ['errors', data.errors?.length ? c.red + String(data.errors.length) + c.reset : c.dim + '0' + c.reset],
1419
+ ]);
1420
+ return;
1421
+ },
1422
+
1423
+ async schema(args) {
1424
+ const [sub, ...rest] = args;
1425
+
1426
+ if (!sub || sub === 'list') {
1427
+ const [ws, proj] = resolveCtx(rest, 'ws', 'proj');
1428
+ const data = await api('GET', `/v1/workspaces/${ws}/projects/${proj}/schema`);
1429
+ table(data.definitions, [
1430
+ { key: 'plugin', label: 'PLUGIN' },
1431
+ { key: 'key', label: 'KEY' },
1432
+ { key: 'type', label: 'TYPE' },
1433
+ { key: 'scope', label: 'SCOPE' },
1434
+ { key: 'required', label: 'REQ', fmt: v => v ? c.yellow + 'yes' + c.reset : c.dim + 'no' + c.reset },
1435
+ { key: 'is_secret', label: 'SEC', fmt: v => v ? c.red + 'yes' + c.reset : c.dim + 'no' + c.reset },
1436
+ { key: 'version', label: 'VER', fmt: v => c.dim + String(v) + c.reset },
1437
+ ]);
1438
+ return;
1439
+ }
1440
+
1441
+ if (sub === 'get') {
1442
+ const resolved = resolveCtx(rest, 'ws', 'proj');
1443
+ const [ws, proj] = resolved;
1444
+ const [key] = rest.slice(resolved.consumed);
1445
+ if (!key) fatal('usage: schema get [workspace] [project] <plugin:key>');
1446
+ const data = await api('GET', `/v1/workspaces/${ws}/projects/${proj}/schema/${key}`);
1447
+ kv(Object.entries(data).map(([k, v]) => [k, c.dim + JSON.stringify(v) + c.reset]));
1448
+ return;
1449
+ }
1450
+
1451
+ fatal(`unknown subcommand: schema ${sub}`);
1452
+ },
1453
+
1454
+ // ── Environments ──────────────────────────────────────────────────────────
1455
+
1456
+ async envs(args) {
1457
+ const [sub, ...rest] = args;
1458
+
1459
+ if (!sub || sub === 'list') {
1460
+ const [ws, proj] = resolveCtx(rest, 'ws', 'proj');
1461
+ const data = await api('GET', `/v1/workspaces/${ws}/projects/${proj}/environments`);
1462
+ table(data.environments, [
1463
+ { key: 'short_id', label: 'ID' },
1464
+ { key: 'slug', label: 'SLUG' },
1465
+ { key: 'name', label: 'NAME' },
1466
+ { key: 'created_at', label: 'CREATED', fmt: fmtDate },
1467
+ ]);
1468
+ return;
1469
+ }
1470
+
1471
+ if (sub === 'create') {
1472
+ // With context: envs create <name>
1473
+ // Without context: envs create <workspace> <project> <name>
1474
+ const resolved = resolveCtx(rest, 'ws', 'proj');
1475
+ const [ws, proj] = resolved;
1476
+ const name = rest.slice(resolved.consumed).join(' ');
1477
+ if (!name) fatal('usage: envs create [workspace] [project] <name>');
1478
+ const data = await api('POST', `/v1/workspaces/${ws}/projects/${proj}/environments`, { name });
1479
+ success(`Environment created: ${c.bold}${data.slug}${c.reset} ${c.dim}in ${ws}/${proj}${c.reset}`);
1480
+ return;
1481
+ }
1482
+
1483
+ if (sub === 'get') {
1484
+ const [ws, proj, slug] = resolveCtx(rest, 'ws', 'proj', 'env');
1485
+ const data = await api('GET', `/v1/workspaces/${ws}/projects/${proj}/environments/${slug}`);
1486
+ kv([
1487
+ ['id', c.dim + data.id + c.reset],
1488
+ ['slug', c.bold + data.slug + c.reset],
1489
+ ['name', data.name],
1490
+ ['created', fmtDate(data.created_at)],
1491
+ ]);
1492
+ return;
1493
+ }
1494
+
1495
+ if (sub === 'rename') {
1496
+ const [ws, proj, slug, ...nameParts] = resolveCtx(rest, 'ws', 'proj', 'env');
1497
+ const name = nameParts.join(' ');
1498
+ if (!slug || !name) fatal('usage: envs rename [workspace] [project] <slug> <name>');
1499
+ await api('PATCH', `/v1/workspaces/${ws}/projects/${proj}/environments/${slug}`, { name });
1500
+ success(`Environment renamed to ${c.bold}${name}${c.reset}`);
1501
+ return;
1502
+ }
1503
+
1504
+ if (sub === 'delete') {
1505
+ const [ws, proj, slug] = resolveCtx(rest, 'ws', 'proj', 'env');
1506
+ if (!slug) fatal('usage: envs delete [workspace] [project] <slug>');
1507
+ await api('DELETE', `/v1/workspaces/${ws}/projects/${proj}/environments/${slug}`);
1508
+ success(`Environment ${c.bold}${slug}${c.reset} deleted.`);
1509
+ return;
1510
+ }
1511
+
1512
+ fatal(`unknown subcommand: envs ${sub}`);
1513
+ },
1514
+
1515
+ // ── Projects ──────────────────────────────────────────────────────────────
1516
+
1517
+ async projects(args) {
1518
+ const [sub, ...rest] = args;
1519
+
1520
+ if (!sub || sub === 'list') {
1521
+ const [ws] = resolveCtx(rest, 'ws');
1522
+ const data = await api('GET', `/v1/workspaces/${ws}/projects`);
1523
+ table(data.projects, [
1524
+ { key: 'short_id', label: 'ID' },
1525
+ { key: 'slug', label: 'SLUG' },
1526
+ { key: 'name', label: 'NAME' },
1527
+ { key: 'env_count', label: 'ENVS', fmt: v => String(v) },
1528
+ { key: 'created_at', label: 'CREATED', fmt: fmtDate },
1529
+ ]);
1530
+ return;
1531
+ }
1532
+
1533
+ if (sub === 'create') {
1534
+ const [ws] = resolveCtx(rest, 'ws');
1535
+ const name = rest.slice(1).join(' ') || rest[0];
1536
+ // If ws came from context, all of rest is the name
1537
+ const ctxWs = (loadConfig().context ?? {}).workspace;
1538
+ const projName = ctxWs ? rest.join(' ') : rest.slice(1).join(' ');
1539
+ if (!projName) fatal('usage: projects create [workspace] <name>');
1540
+ const data = await api('POST', `/v1/workspaces/${ws}/projects`, { name: projName });
1541
+ success(`Project created: ${c.bold}${data.slug}${c.reset} ${c.dim}(id: ${data.short_id ?? '—'}) in ${ws}${c.reset}`);
1542
+ return;
1543
+ }
1544
+
1545
+ if (sub === 'get') {
1546
+ const [ws, slug] = resolveCtx(rest, 'ws', 'proj');
1547
+ const data = await api('GET', `/v1/workspaces/${ws}/projects/${slug}`);
1548
+ kv([
1549
+ ['id', c.dim + data.id + c.reset],
1550
+ ['short_id', c.bold + data.short_id + c.reset],
1551
+ ['slug', c.bold + data.slug + c.reset],
1552
+ ['name', data.name],
1553
+ ['envs', String(data.env_count)],
1554
+ ['created', fmtDate(data.created_at)],
1555
+ ]);
1556
+ return;
1557
+ }
1558
+
1559
+ if (sub === 'rename') {
1560
+ const [ws, slug, ...nameParts] = rest;
1561
+ const [wsR] = resolveCtx([ws], 'ws');
1562
+ const name = nameParts.join(' ');
1563
+ if (!slug || !name) fatal('usage: projects rename [workspace] <slug> <name>');
1564
+ await api('PATCH', `/v1/workspaces/${wsR}/projects/${slug}`, { name });
1565
+ success(`Project renamed to ${c.bold}${name}${c.reset}`);
1566
+ return;
1567
+ }
1568
+
1569
+ if (sub === 'delete') {
1570
+ const [ws, slug] = resolveCtx(rest, 'ws', 'proj');
1571
+ await api('DELETE', `/v1/workspaces/${ws}/projects/${slug}`);
1572
+ success(`Project ${c.bold}${slug}${c.reset} deleted.`);
1573
+ return;
1574
+ }
1575
+
1576
+ fatal(`unknown subcommand: projects ${sub}`);
1577
+ },
1578
+
1579
+ // ── Orgs ──────────────────────────────────────────────────────────────────
1580
+
1581
+ async orgs(args) {
1582
+ const [sub, ...rest] = args;
1583
+
1584
+ if (!sub || sub === 'list') {
1585
+ const data = await api('GET', '/v1/orgs');
1586
+ table(data.orgs, [
1587
+ { key: 'slug', label: 'SLUG' },
1588
+ { key: 'name', label: 'NAME' },
1589
+ { key: 'role', label: 'ROLE', fmt: fmtRole },
1590
+ { key: 'created_at', label: 'CREATED', fmt: fmtDate },
1591
+ ]);
1592
+ return;
1593
+ }
1594
+
1595
+ if (sub === 'create') {
1596
+ const [slug, ...nameParts] = rest;
1597
+ const name = nameParts.join(' ');
1598
+ if (!slug || !name) fatal('usage: orgs create <slug> <name>');
1599
+ const data = await api('POST', '/v1/orgs', { slug, name });
1600
+ success(`Org created: ${c.bold}${data.slug}${c.reset}`);
1601
+ return;
1602
+ }
1603
+
1604
+ if (sub === 'get') {
1605
+ const [slug] = rest;
1606
+ if (!slug) fatal('usage: orgs get <slug>');
1607
+ const data = await api('GET', `/v1/orgs/${slug}`);
1608
+ kv([
1609
+ ['id', c.dim + data.id + c.reset],
1610
+ ['slug', c.bold + data.slug + c.reset],
1611
+ ['name', data.name],
1612
+ ['role', fmtRole(data.role)],
1613
+ ['created', fmtDate(data.created_at)],
1614
+ ]);
1615
+ return;
1616
+ }
1617
+
1618
+ if (sub === 'rename') {
1619
+ const [slug, ...nameParts] = rest;
1620
+ const name = nameParts.join(' ');
1621
+ if (!slug || !name) fatal('usage: orgs rename <slug> <name>');
1622
+ await api('PATCH', `/v1/orgs/${slug}`, { name });
1623
+ success(`Org renamed to ${c.bold}${name}${c.reset}`);
1624
+ return;
1625
+ }
1626
+
1627
+ if (sub === 'members') {
1628
+ const [action, ...mrest] = rest;
1629
+
1630
+ if (!action || action === 'list') {
1631
+ const [slug] = mrest;
1632
+ if (!slug) fatal('usage: orgs members list <slug>');
1633
+ const data = await api('GET', `/v1/orgs/${slug}/members`);
1634
+ table(data.members, [
1635
+ { key: 'email', label: 'EMAIL' },
1636
+ { key: 'name', label: 'NAME', fmt: v => v || c.dim + '—' + c.reset },
1637
+ { key: 'role', label: 'ROLE', fmt: fmtRole },
1638
+ { key: 'created_at', label: 'SINCE', fmt: fmtDate },
1639
+ ]);
1640
+ return;
1641
+ }
1642
+
1643
+ if (action === 'add') {
1644
+ const [slug, email, role = 'member'] = mrest;
1645
+ if (!slug || !email) fatal('usage: orgs members add <slug> <email> [role]');
1646
+ await api('POST', `/v1/orgs/${slug}/members`, { email, role });
1647
+ success(`Added ${c.bold}${email}${c.reset} as ${fmtRole(role)}`);
1648
+ return;
1649
+ }
1650
+
1651
+ if (action === 'set-role') {
1652
+ const [slug, userId, role] = mrest;
1653
+ if (!slug || !userId || !role) fatal('usage: orgs members set-role <slug> <userId> <role>');
1654
+ await api('PATCH', `/v1/orgs/${slug}/members/${userId}`, { role });
1655
+ success(`Role updated to ${fmtRole(role)}`);
1656
+ return;
1657
+ }
1658
+
1659
+ if (action === 'remove') {
1660
+ const [slug, userId] = mrest;
1661
+ if (!slug || !userId) fatal('usage: orgs members remove <slug> <userId>');
1662
+ await api('DELETE', `/v1/orgs/${slug}/members/${userId}`);
1663
+ success('Member removed.');
1664
+ return;
1665
+ }
1666
+
1667
+ fatal(`unknown subcommand: orgs members ${action}`);
1668
+ }
1669
+
1670
+ fatal(`unknown subcommand: orgs ${sub}`);
1671
+ },
1672
+
1673
+ // ── Context ───────────────────────────────────────────────────────────────
1674
+
1675
+ // orbseal use <org>/<workspace>/<project>
1676
+ // orbseal use <org>/<workspace>
1677
+ // orbseal use <org>
1678
+ // orbseal use --public-key <key>
1679
+ // orbseal use --clear
1680
+ use(args) {
1681
+ if (args[0] === '--clear' || args[0] === 'clear') {
1682
+ const cfg = loadConfig();
1683
+ delete cfg.context;
1684
+ saveConfig(cfg);
1685
+ success('Context cleared (including public key).');
1686
+ return;
1687
+ }
1688
+
1689
+ // Extract --public-key flag (can be combined with path or standalone)
1690
+ let publicKey = null;
1691
+ const filtered = [];
1692
+ for (let i = 0; i < args.length; i++) {
1693
+ if ((args[i] === '--public-key' || args[i] === '--pk') && args[i + 1]) {
1694
+ publicKey = args[++i];
1695
+ } else {
1696
+ filtered.push(args[i]);
1697
+ }
1698
+ }
1699
+
1700
+ const cfg = loadConfig();
1701
+ cfg.context = cfg.context ?? {};
1702
+
1703
+ // Set public key in context if provided
1704
+ if (publicKey) {
1705
+ cfg.context.public_key = publicKey;
1706
+ saveConfig(cfg);
1707
+ if (!filtered[0]) {
1708
+ success(`Public key saved to context.`);
1709
+ console.log(` ${c.dim}${publicKey.slice(0, 24)}…${c.reset}`);
1710
+ return;
1711
+ }
1712
+ }
1713
+
1714
+ const input = filtered[0] ?? '';
1715
+ if (!input && !publicKey) fatal('usage: orbseal use <org>[/<ws>[/<proj>[/<env>]]] [--public-key <key>] or orbseal use --clear');
1716
+
1717
+ if (input) {
1718
+ const parts = input.split('/').filter(Boolean);
1719
+ if (parts[0]) cfg.context.org = parts[0];
1720
+ if (parts[1]) cfg.context.workspace = parts[1];
1721
+ if (parts[2]) cfg.context.project = parts[2];
1722
+ if (parts[3]) cfg.context.env = parts[3];
1723
+ }
1724
+
1725
+ saveConfig(cfg);
1726
+
1727
+ const ctx = cfg.context;
1728
+ const pathDisplay = [ctx.org, ctx.workspace, ctx.project, ctx.env].filter(Boolean).join(c.dim + ' / ' + c.reset + c.bold);
1729
+ success(`Context set to ${c.bold}${pathDisplay}${c.reset}`);
1730
+ if (ctx.public_key) {
1731
+ console.log(` ${c.dim}public key ${ctx.public_key.slice(0, 24)}…${c.reset}`);
1732
+ }
1733
+ console.log(` ${c.dim}Tip: run ${c.reset}orbseal ls${c.dim} to see the full tree${c.reset}`);
1734
+ },
1735
+
1736
+ // ── ls (tree view of current context) ────────────────────────────────────
1737
+
1738
+ async ls(_args) {
1739
+ const cfg = loadConfig();
1740
+ if (!cfg.token) fatal('Not logged in. Run: orbseal login');
1741
+
1742
+ const ctx = cfg.context ?? {};
1743
+
1744
+ let orgs;
1745
+ try { orgs = (await api('GET', '/v1/orgs')).orgs; }
1746
+ catch { fatal('Could not fetch orgs — check token.'); }
1747
+
1748
+ // Filter to context org if set; fall back to all orgs if not found
1749
+ let filtered = orgs;
1750
+ if (ctx.org) {
1751
+ const match = orgs.filter(o => o.slug === ctx.org || o.short_id === ctx.org);
1752
+ if (match.length) {
1753
+ filtered = match;
1754
+ } else {
1755
+ console.log(` ${c.yellow}⚠${c.reset} Context org ${c.bold}${ctx.org}${c.reset} not found — showing all orgs\n`);
1756
+ }
1757
+ }
1758
+
1759
+ for (const org of filtered) {
1760
+ console.log(`\n ${c.green}${c.bold}●${c.reset} ${c.bold}${org.slug}${c.reset} ${c.dim}${org.name} ${fmtRole(org.role)}${c.reset}`);
1761
+
1762
+ let workspaces;
1763
+ try { workspaces = (await api('GET', '/v1/workspaces')).workspaces; }
1764
+ catch { continue; }
1765
+
1766
+ // Filter to context workspace if set
1767
+ const wsList = ctx.workspace
1768
+ ? workspaces.filter(w => w.slug === ctx.workspace || w.short_id === ctx.workspace)
1769
+ : workspaces;
1770
+
1771
+ for (let wi = 0; wi < wsList.length; wi++) {
1772
+ const ws = wsList[wi];
1773
+ const lastWs = wi === wsList.length - 1;
1774
+ const wsPfx = lastWs ? ' └──' : ' ├──';
1775
+ const wsPad = lastWs ? ' ' : ' │ ';
1776
+ const wsMarker = ctx.workspace && (ws.slug === ctx.workspace || ws.short_id === ctx.workspace)
1777
+ ? c.cyan + ' ◀' + c.reset : '';
1778
+
1779
+ console.log(`${wsPfx} ${c.bold}${ws.slug}${c.reset} ${c.dim}${ws.name}${c.reset}${wsMarker}`);
1780
+
1781
+ let projects;
1782
+ try { projects = (await api('GET', `/v1/workspaces/${ws.short_id}/projects`)).projects; }
1783
+ catch { continue; }
1784
+
1785
+ const projList = ctx.project
1786
+ ? projects.filter(p => p.slug === ctx.project || p.short_id === ctx.project)
1787
+ : projects;
1788
+
1789
+ for (let pi = 0; pi < projList.length; pi++) {
1790
+ const proj = projList[pi];
1791
+ const lastP = pi === projList.length - 1;
1792
+ const pPfx = lastP ? `${wsPad}└──` : `${wsPad}├──`;
1793
+ const pPad = lastP ? `${wsPad} ` : `${wsPad}│ `;
1794
+ const pMarker = ctx.project && (proj.slug === ctx.project || proj.short_id === ctx.project)
1795
+ ? c.cyan + ' ◀' + c.reset : '';
1796
+
1797
+ let envs = [];
1798
+ try { envs = (await api('GET', `/v1/workspaces/${ws.short_id}/projects/${proj.short_id}/environments`)).environments; }
1799
+ catch { /* ignore */ }
1800
+
1801
+ const envNames = envs.length
1802
+ ? envs.map(e => {
1803
+ const active = ctx.env && (e.slug === ctx.env || e.short_id === ctx.env);
1804
+ return active
1805
+ ? c.bold + e.slug + c.reset + c.cyan + ' ◀' + c.reset
1806
+ : c.dim + e.slug + c.reset;
1807
+ }).join(' ')
1808
+ : c.dim + 'no environments' + c.reset;
1809
+
1810
+ console.log(`${pPfx} ${c.bold}${proj.slug}${c.reset} ${c.dim}${proj.name}${c.reset}${pMarker}`);
1811
+ console.log(`${pPad} ${c.dim}envs ${c.reset}${envNames}`);
1812
+ }
1813
+ }
1814
+ }
1815
+ console.log();
1816
+ },
1817
+
1818
+ };
1819
+
1820
+ // ─── Router ──────────────────────────────────────────────────────────────────
1821
+
1822
+ const ALIASES = {
1823
+ 'ws': 'workspaces',
1824
+ 'proj': 'projects',
1825
+ 'env': 'envs',
1826
+ 'wh': 'whoami',
1827
+ 'switch': 'switchToken',
1828
+ };
1829
+
1830
+ const [,, rawCmd, ...args] = process.argv;
1831
+ const cmd = ALIASES[rawCmd] ?? rawCmd;
1832
+
1833
+ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
1834
+ console.log(HELP);
1835
+ process.exit(0);
1836
+ }
1837
+
1838
+ if (!commands[cmd]) {
1839
+ console.error(`${c.red}error:${c.reset} unknown command "${cmd}"\n`);
1840
+ console.log(HELP);
1841
+ process.exit(1);
1842
+ }
1843
+
1844
+ Promise.resolve(commands[cmd](args)).catch(e => fatal(e?.message ?? String(e)));