unotoken 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.
Files changed (122) hide show
  1. package/README.md +360 -0
  2. package/dist/cli.d.ts +17 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +1207 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/client.d.ts +15 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +15 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/db.d.ts +52 -0
  11. package/dist/db.d.ts.map +1 -0
  12. package/dist/db.js +97 -0
  13. package/dist/db.js.map +1 -0
  14. package/dist/dotenv.d.ts +69 -0
  15. package/dist/dotenv.d.ts.map +1 -0
  16. package/dist/dotenv.js +115 -0
  17. package/dist/dotenv.js.map +1 -0
  18. package/dist/env-mapper.d.ts +55 -0
  19. package/dist/env-mapper.d.ts.map +1 -0
  20. package/dist/env-mapper.js +97 -0
  21. package/dist/env-mapper.js.map +1 -0
  22. package/dist/exec.d.ts +80 -0
  23. package/dist/exec.d.ts.map +1 -0
  24. package/dist/exec.js +214 -0
  25. package/dist/exec.js.map +1 -0
  26. package/dist/index.d.ts +12 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +43 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/oauth/commands.d.ts +151 -0
  31. package/dist/oauth/commands.d.ts.map +1 -0
  32. package/dist/oauth/commands.js +322 -0
  33. package/dist/oauth/commands.js.map +1 -0
  34. package/dist/oauth/config.d.ts +84 -0
  35. package/dist/oauth/config.d.ts.map +1 -0
  36. package/dist/oauth/config.js +156 -0
  37. package/dist/oauth/config.js.map +1 -0
  38. package/dist/oauth/crypto-helpers.d.ts +44 -0
  39. package/dist/oauth/crypto-helpers.d.ts.map +1 -0
  40. package/dist/oauth/crypto-helpers.js +94 -0
  41. package/dist/oauth/crypto-helpers.js.map +1 -0
  42. package/dist/oauth/device-secret.d.ts +57 -0
  43. package/dist/oauth/device-secret.d.ts.map +1 -0
  44. package/dist/oauth/device-secret.js +106 -0
  45. package/dist/oauth/device-secret.js.map +1 -0
  46. package/dist/oauth/flow.d.ts +112 -0
  47. package/dist/oauth/flow.d.ts.map +1 -0
  48. package/dist/oauth/flow.js +255 -0
  49. package/dist/oauth/flow.js.map +1 -0
  50. package/dist/oauth/index.d.ts +18 -0
  51. package/dist/oauth/index.d.ts.map +1 -0
  52. package/dist/oauth/index.js +24 -0
  53. package/dist/oauth/index.js.map +1 -0
  54. package/dist/oauth/key-wrap.d.ts +146 -0
  55. package/dist/oauth/key-wrap.d.ts.map +1 -0
  56. package/dist/oauth/key-wrap.js +275 -0
  57. package/dist/oauth/key-wrap.js.map +1 -0
  58. package/dist/oauth/pkce.d.ts +29 -0
  59. package/dist/oauth/pkce.d.ts.map +1 -0
  60. package/dist/oauth/pkce.js +34 -0
  61. package/dist/oauth/pkce.js.map +1 -0
  62. package/dist/oauth/provider.d.ts +79 -0
  63. package/dist/oauth/provider.d.ts.map +1 -0
  64. package/dist/oauth/provider.js +10 -0
  65. package/dist/oauth/provider.js.map +1 -0
  66. package/dist/oauth/providers/github.d.ts +75 -0
  67. package/dist/oauth/providers/github.d.ts.map +1 -0
  68. package/dist/oauth/providers/github.js +119 -0
  69. package/dist/oauth/providers/github.js.map +1 -0
  70. package/dist/oauth/providers/google.d.ts +115 -0
  71. package/dist/oauth/providers/google.d.ts.map +1 -0
  72. package/dist/oauth/providers/google.js +285 -0
  73. package/dist/oauth/providers/google.js.map +1 -0
  74. package/dist/sdk.d.ts +8 -0
  75. package/dist/sdk.d.ts.map +1 -0
  76. package/dist/sdk.js +8 -0
  77. package/dist/sdk.js.map +1 -0
  78. package/dist/server.d.ts +33 -0
  79. package/dist/server.d.ts.map +1 -0
  80. package/dist/server.js +287 -0
  81. package/dist/server.js.map +1 -0
  82. package/dist/signatures/approval-codes.d.ts +192 -0
  83. package/dist/signatures/approval-codes.d.ts.map +1 -0
  84. package/dist/signatures/approval-codes.js +407 -0
  85. package/dist/signatures/approval-codes.js.map +1 -0
  86. package/dist/signatures/commands.d.ts +108 -0
  87. package/dist/signatures/commands.d.ts.map +1 -0
  88. package/dist/signatures/commands.js +270 -0
  89. package/dist/signatures/commands.js.map +1 -0
  90. package/dist/signatures/devices.d.ts +165 -0
  91. package/dist/signatures/devices.d.ts.map +1 -0
  92. package/dist/signatures/devices.js +344 -0
  93. package/dist/signatures/devices.js.map +1 -0
  94. package/dist/signatures/email-config.d.ts +102 -0
  95. package/dist/signatures/email-config.d.ts.map +1 -0
  96. package/dist/signatures/email-config.js +188 -0
  97. package/dist/signatures/email-config.js.map +1 -0
  98. package/dist/signatures/email.d.ts +106 -0
  99. package/dist/signatures/email.d.ts.map +1 -0
  100. package/dist/signatures/email.js +180 -0
  101. package/dist/signatures/email.js.map +1 -0
  102. package/dist/signatures/fingerprint.d.ts +70 -0
  103. package/dist/signatures/fingerprint.d.ts.map +1 -0
  104. package/dist/signatures/fingerprint.js +123 -0
  105. package/dist/signatures/fingerprint.js.map +1 -0
  106. package/dist/signatures/guard.d.ts +118 -0
  107. package/dist/signatures/guard.d.ts.map +1 -0
  108. package/dist/signatures/guard.js +310 -0
  109. package/dist/signatures/guard.js.map +1 -0
  110. package/dist/signatures/resend.d.ts +84 -0
  111. package/dist/signatures/resend.d.ts.map +1 -0
  112. package/dist/signatures/resend.js +248 -0
  113. package/dist/signatures/resend.js.map +1 -0
  114. package/dist/token-requests.d.ts +80 -0
  115. package/dist/token-requests.d.ts.map +1 -0
  116. package/dist/token-requests.js +201 -0
  117. package/dist/token-requests.js.map +1 -0
  118. package/dist/tokens.d.ts +80 -0
  119. package/dist/tokens.d.ts.map +1 -0
  120. package/dist/tokens.js +150 -0
  121. package/dist/tokens.js.map +1 -0
  122. package/package.json +62 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1207 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * unotoken CLI — yokotoken + cloud features.
4
+ *
5
+ * Wraps the yokotoken CLI and extends it with OAuth-related commands:
6
+ * - auth link <provider> Link an OAuth identity for passwordless unlock
7
+ * - auth unlink <provider> Remove an OAuth identity link
8
+ * - auth list List linked OAuth providers
9
+ * - auth config Configure custom OAuth client credentials
10
+ * - unlock --oauth Unlock the vault using linked OAuth identity
11
+ * - config email <address> Register and verify email for device signatures
12
+ * - device guard check on all vault operations (unlock, get, set, list)
13
+ *
14
+ * All other commands are delegated to the underlying yokotoken CLI.
15
+ */
16
+ import { Command } from 'commander';
17
+ import { spawn } from 'node:child_process';
18
+ import { fileURLToPath } from 'node:url';
19
+ import path from 'node:path';
20
+ import { createInterface } from 'node:readline';
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = path.dirname(__filename);
23
+ const VERSION = '0.1.0';
24
+ const program = new Command();
25
+ program
26
+ .name('unotoken')
27
+ .description('yokotoken + cloud features (OAuth unlock, team vaults, key escrow)')
28
+ .version(VERSION);
29
+ // ─── auth command group ────────────────────────────────────────────
30
+ const auth = program
31
+ .command('auth')
32
+ .description('OAuth identity management for passwordless vault unlock');
33
+ auth
34
+ .command('link <provider>')
35
+ .description('Link an OAuth identity (google, github) for passwordless unlock')
36
+ .option('--vault-path <path>', 'Path to the vault database')
37
+ .option('--device-dir <path>', 'Directory for device.key storage')
38
+ .action(async (provider, opts) => {
39
+ try {
40
+ const { authLink, isValidProvider } = await import('./oauth/commands.js');
41
+ if (!isValidProvider(provider)) {
42
+ process.stderr.write(`Error: Unsupported provider '${provider}'. Supported: google, github\n`);
43
+ process.exit(1);
44
+ }
45
+ // Read passphrase from stdin
46
+ const passphrase = await readPassphraseFromTerminal('Enter vault passphrase to verify ownership: ');
47
+ process.stderr.write(`\nLinking ${provider} account...\n`);
48
+ process.stderr.write('A browser window will open for authentication.\n\n');
49
+ const result = await authLink(provider, {
50
+ passphrase,
51
+ vaultPath: opts.vaultPath,
52
+ deviceDir: opts.deviceDir,
53
+ });
54
+ const display = result.email ?? result.name ?? result.subjectId;
55
+ process.stdout.write(`${provider} account linked: ${display}\n`);
56
+ process.stdout.write(`You can now unlock with: unotoken unlock --oauth\n`);
57
+ }
58
+ catch (err) {
59
+ const msg = err instanceof Error ? err.message : String(err);
60
+ process.stderr.write(`Error: ${msg}\n`);
61
+ process.exit(1);
62
+ }
63
+ });
64
+ auth
65
+ .command('unlink <provider>')
66
+ .description('Remove an OAuth identity link')
67
+ .option('--vault-path <path>', 'Path to the vault database')
68
+ .option('--yes', 'Skip confirmation prompt')
69
+ .action(async (provider, opts) => {
70
+ try {
71
+ const { isValidProvider, listLinkedProviders, unlinkProvider } = await import('./oauth/commands.js');
72
+ if (!isValidProvider(provider)) {
73
+ process.stderr.write(`Error: Unsupported provider '${provider}'. Supported: google, github\n`);
74
+ process.exit(1);
75
+ }
76
+ // Check how many providers are linked
77
+ const linked = await listLinkedProviders(opts.vaultPath);
78
+ const isLinked = linked.some((l) => l.provider === provider);
79
+ if (!isLinked) {
80
+ process.stderr.write(`Error: No OAuth link found for '${provider}'.\n` +
81
+ (linked.length > 0
82
+ ? `Linked providers: ${linked.map((l) => l.provider).join(', ')}\n`
83
+ : 'No OAuth providers are currently linked.\n'));
84
+ process.exit(1);
85
+ }
86
+ // Warn if this is the last provider
87
+ if (linked.length === 1) {
88
+ process.stderr.write(`Warning: '${provider}' is the only linked OAuth provider.\n` +
89
+ 'Removing it means passphrase will be the only unlock method.\n\n');
90
+ }
91
+ // Confirm before deletion (unless --yes)
92
+ if (!opts.yes) {
93
+ const answer = await readLineFromTerminal(`Remove ${provider} OAuth link? [y/N] `);
94
+ if (answer.trim().toLowerCase() !== 'y') {
95
+ process.stderr.write('Cancelled.\n');
96
+ process.exit(0);
97
+ }
98
+ }
99
+ const result = await unlinkProvider(provider, opts.vaultPath);
100
+ process.stdout.write(`${capitalize(result.provider)} OAuth link removed.\n`);
101
+ if (result.wasLastProvider) {
102
+ process.stdout.write('No OAuth providers remaining. Use your passphrase to unlock.\n');
103
+ }
104
+ }
105
+ catch (err) {
106
+ const msg = err instanceof Error ? err.message : String(err);
107
+ process.stderr.write(`Error: ${msg}\n`);
108
+ process.exit(1);
109
+ }
110
+ });
111
+ auth
112
+ .command('list')
113
+ .description('List linked OAuth providers')
114
+ .option('--vault-path <path>', 'Path to the vault database')
115
+ .option('--json', 'Output as JSON')
116
+ .action(async (opts) => {
117
+ try {
118
+ const { listLinkedProviders } = await import('./oauth/commands.js');
119
+ const linked = await listLinkedProviders(opts.vaultPath);
120
+ if (linked.length === 0) {
121
+ if (opts.json) {
122
+ process.stdout.write('[]\n');
123
+ }
124
+ else {
125
+ process.stdout.write('No OAuth providers linked.\n');
126
+ process.stdout.write('Link one with: unotoken auth link <google|github>\n');
127
+ }
128
+ return;
129
+ }
130
+ if (opts.json) {
131
+ process.stdout.write(JSON.stringify(linked, null, 2) + '\n');
132
+ return;
133
+ }
134
+ process.stdout.write('Linked OAuth providers:\n\n');
135
+ for (const link of linked) {
136
+ process.stdout.write(` ${capitalize(link.provider)}\n`);
137
+ process.stdout.write(` Subject ID: ${link.subjectId}\n`);
138
+ process.stdout.write(` Linked: ${formatDate(link.createdAt)}\n`);
139
+ process.stdout.write(` Last used: ${formatDate(link.lastUsedAt)}\n`);
140
+ process.stdout.write('\n');
141
+ }
142
+ }
143
+ catch (err) {
144
+ const msg = err instanceof Error ? err.message : String(err);
145
+ process.stderr.write(`Error: ${msg}\n`);
146
+ process.exit(1);
147
+ }
148
+ });
149
+ auth
150
+ .command('config')
151
+ .description('Configure custom OAuth client credentials')
152
+ .option('--google-client-id <id>', 'Set custom Google OAuth client ID')
153
+ .option('--google-client-secret <secret>', 'Set custom Google OAuth client secret')
154
+ .option('--github-client-id <id>', 'Set custom GitHub OAuth client ID')
155
+ .option('--github-client-secret <secret>', 'Set custom GitHub OAuth client secret')
156
+ .option('--show', 'Display current OAuth configuration')
157
+ .option('--reset', 'Reset to built-in Indigo OAuth apps')
158
+ .option('--config-dir <path>', 'Directory for oauth-config.json (default: ~/.yokotoken)')
159
+ .action(async (opts) => {
160
+ try {
161
+ const { loadOAuthConfig, updateOAuthConfig, resetOAuthConfig, formatConfigForDisplay, } = await import('./oauth/config.js');
162
+ // --show: display current config
163
+ if (opts.show) {
164
+ const config = loadOAuthConfig(opts.configDir);
165
+ process.stdout.write('OAuth Configuration:\n');
166
+ process.stdout.write(formatConfigForDisplay(config) + '\n');
167
+ return;
168
+ }
169
+ // --reset: remove custom config
170
+ if (opts.reset) {
171
+ const removed = resetOAuthConfig(opts.configDir);
172
+ if (removed) {
173
+ process.stdout.write('OAuth configuration reset to built-in Indigo OAuth apps.\n');
174
+ }
175
+ else {
176
+ process.stdout.write('No custom configuration to reset.\n');
177
+ }
178
+ return;
179
+ }
180
+ // Set custom credentials
181
+ const hasUpdate = opts.googleClientId !== undefined ||
182
+ opts.googleClientSecret !== undefined ||
183
+ opts.githubClientId !== undefined ||
184
+ opts.githubClientSecret !== undefined;
185
+ if (!hasUpdate) {
186
+ // No flags — show help
187
+ process.stderr.write('Usage: unotoken auth config [options]\n\n' +
188
+ 'Options:\n' +
189
+ ' --google-client-id <id> Set custom Google OAuth client ID\n' +
190
+ ' --google-client-secret <s> Set custom Google OAuth client secret\n' +
191
+ ' --github-client-id <id> Set custom GitHub OAuth client ID\n' +
192
+ ' --github-client-secret <s> Set custom GitHub OAuth client secret\n' +
193
+ ' --show Display current OAuth configuration\n' +
194
+ ' --reset Reset to built-in Indigo OAuth apps\n');
195
+ return;
196
+ }
197
+ const updated = updateOAuthConfig({
198
+ googleClientId: opts.googleClientId,
199
+ googleClientSecret: opts.googleClientSecret,
200
+ githubClientId: opts.githubClientId,
201
+ githubClientSecret: opts.githubClientSecret,
202
+ }, opts.configDir);
203
+ process.stdout.write('OAuth configuration updated:\n');
204
+ process.stdout.write(formatConfigForDisplay(updated) + '\n');
205
+ }
206
+ catch (err) {
207
+ const msg = err instanceof Error ? err.message : String(err);
208
+ process.stderr.write(`Error: ${msg}\n`);
209
+ process.exit(1);
210
+ }
211
+ });
212
+ // ─── unlock command (unotoken-specific OAuth unlock) ──────────────────
213
+ program
214
+ .command('unlock')
215
+ .description('Unlock the vault (passphrase or OAuth)')
216
+ .option('--oauth', 'Unlock using a linked OAuth provider')
217
+ .option('--provider <name>', 'Specify which OAuth provider to use (google or github)')
218
+ .option('--vault-path <path>', 'Path to the vault database')
219
+ .option('--device-dir <path>', 'Directory for device.key storage')
220
+ .option('--skip-device-check', 'Skip device approval check')
221
+ .action(async (opts) => {
222
+ try {
223
+ // Device guard check (runs BEFORE vault unlock)
224
+ if (!opts.skipDeviceCheck) {
225
+ const guardResult = await runDeviceGuard();
226
+ if (!guardResult.allowed) {
227
+ process.exit(1);
228
+ }
229
+ }
230
+ if (opts.oauth || opts.provider) {
231
+ // OAuth-based unlock
232
+ const { unlockWithOAuth, isValidProvider, MultipleProvidersError } = await import('./oauth/commands.js');
233
+ let provider;
234
+ if (opts.provider) {
235
+ if (!isValidProvider(opts.provider)) {
236
+ process.stderr.write(`Error: Unsupported provider '${opts.provider}'. Supported: google, github\n`);
237
+ process.exit(1);
238
+ }
239
+ provider = opts.provider;
240
+ }
241
+ process.stderr.write('Unlocking with OAuth...\n');
242
+ process.stderr.write('A browser window will open for authentication.\n\n');
243
+ try {
244
+ const result = await unlockWithOAuth({
245
+ provider,
246
+ vaultPath: opts.vaultPath,
247
+ deviceDir: opts.deviceDir,
248
+ });
249
+ const display = result.email ?? result.name ?? result.provider;
250
+ process.stdout.write(`Vault unlocked via ${result.provider} (${display})\n`);
251
+ if (result.serverNotified) {
252
+ process.stdout.write('Vault server is unlocked.\n');
253
+ }
254
+ }
255
+ catch (err) {
256
+ if (err instanceof MultipleProvidersError) {
257
+ // Present choices to user
258
+ process.stderr.write(`${err.message}\n`);
259
+ process.exit(1);
260
+ }
261
+ throw err;
262
+ }
263
+ }
264
+ else {
265
+ // Check if OAuth providers are linked — offer interactive choice
266
+ const { listLinkedProviders } = await import('./oauth/commands.js');
267
+ let hasLinked = false;
268
+ try {
269
+ const linked = await listLinkedProviders(opts.vaultPath);
270
+ hasLinked = linked.length > 0;
271
+ if (hasLinked) {
272
+ process.stderr.write('Unlock methods available:\n');
273
+ process.stderr.write(' 1. Passphrase\n');
274
+ for (let i = 0; i < linked.length; i++) {
275
+ process.stderr.write(` ${i + 2}. ${capitalize(linked[i].provider)} (OAuth)\n`);
276
+ }
277
+ process.stderr.write('\n');
278
+ const choice = await readLineFromTerminal('Choose unlock method [1]: ');
279
+ const choiceNum = parseInt(choice.trim(), 10);
280
+ if (!isNaN(choiceNum) && choiceNum >= 2 && choiceNum <= linked.length + 1) {
281
+ // User chose an OAuth provider
282
+ const chosenProvider = linked[choiceNum - 2].provider;
283
+ process.stderr.write(`\nUnlocking with ${chosenProvider}...\n`);
284
+ process.stderr.write('A browser window will open for authentication.\n\n');
285
+ const { unlockWithOAuth, isValidProvider } = await import('./oauth/commands.js');
286
+ if (isValidProvider(chosenProvider)) {
287
+ const result = await unlockWithOAuth({
288
+ provider: chosenProvider,
289
+ vaultPath: opts.vaultPath,
290
+ deviceDir: opts.deviceDir,
291
+ });
292
+ const display = result.email ?? result.name ?? result.provider;
293
+ process.stdout.write(`Vault unlocked via ${result.provider} (${display})\n`);
294
+ return;
295
+ }
296
+ }
297
+ // Fall through to passphrase unlock
298
+ }
299
+ }
300
+ catch {
301
+ // If listing fails (e.g., no vault), fall through to passphrase
302
+ }
303
+ // Passphrase unlock — delegate to yokotoken
304
+ delegateToYokotoken(['unlock']);
305
+ }
306
+ }
307
+ catch (err) {
308
+ const msg = err instanceof Error ? err.message : String(err);
309
+ process.stderr.write(`Error: ${msg}\n`);
310
+ process.exit(1);
311
+ }
312
+ });
313
+ // ─── config command group ─────────────────────────────────────────
314
+ const config = program
315
+ .command('config')
316
+ .description('Manage unotoken configuration (email for device signatures)');
317
+ config
318
+ .command('email [address]')
319
+ .description('Register and verify an email address for device approval codes')
320
+ .option('--show', 'Display current email configuration')
321
+ .option('--clear', 'Remove email configuration')
322
+ .option('--resend-key <key>', 'Set a custom Resend API key')
323
+ .option('--config-dir <path>', 'Directory for email-config.json (default: ~/.yokotoken)')
324
+ .action(async (address, opts) => {
325
+ try {
326
+ const { loadEmailConfig, clearEmailConfig, formatEmailConfigForDisplay, isValidEmail, setResendApiKey, } = await import('./signatures/email-config.js');
327
+ const { initiateVerification, verifyCode, completeVerification, } = await import('./signatures/email.js');
328
+ const { generateDeviceFingerprint, getDefaultDeviceName, } = await import('./signatures/fingerprint.js');
329
+ // --show: display current email config
330
+ if (opts.show) {
331
+ const emailConfig = loadEmailConfig(opts.configDir);
332
+ process.stdout.write('Email Configuration:\n');
333
+ process.stdout.write(formatEmailConfigForDisplay(emailConfig) + '\n');
334
+ return;
335
+ }
336
+ // --clear: remove email config
337
+ if (opts.clear) {
338
+ process.stderr.write('Warning: Removing your email configuration means device approval codes\n' +
339
+ 'cannot be sent. New devices will have unrestricted access unless email\n' +
340
+ 'is re-configured.\n\n');
341
+ const answer = await readLineFromTerminal('Remove email configuration? [y/N] ');
342
+ if (answer.trim().toLowerCase() !== 'y') {
343
+ process.stderr.write('Cancelled.\n');
344
+ return;
345
+ }
346
+ const removed = clearEmailConfig(opts.configDir);
347
+ if (removed) {
348
+ process.stdout.write('Email configuration removed.\n');
349
+ }
350
+ else {
351
+ process.stdout.write('No email configuration to remove.\n');
352
+ }
353
+ return;
354
+ }
355
+ // --resend-key without address: update key on existing config
356
+ if (opts.resendKey && !address) {
357
+ try {
358
+ const updated = setResendApiKey(opts.resendKey, opts.configDir);
359
+ process.stdout.write('Resend API key updated.\n');
360
+ process.stdout.write(formatEmailConfigForDisplay(updated) + '\n');
361
+ }
362
+ catch (err) {
363
+ const msg = err instanceof Error ? err.message : String(err);
364
+ process.stderr.write(`Error: ${msg}\n`);
365
+ process.exit(1);
366
+ }
367
+ return;
368
+ }
369
+ // No address and no flags — show help
370
+ if (!address) {
371
+ process.stderr.write('Usage: unotoken config email <address> [options]\n\n' +
372
+ 'Register and verify an email address for device approval codes.\n\n' +
373
+ 'Options:\n' +
374
+ ' --show Display current email configuration\n' +
375
+ ' --clear Remove email configuration\n' +
376
+ ' --resend-key <k> Set a custom Resend API key\n\n' +
377
+ 'Examples:\n' +
378
+ ' unotoken config email user@example.com\n' +
379
+ ' unotoken config email --show\n' +
380
+ ' unotoken config email --clear\n' +
381
+ ' unotoken config email --resend-key re_xxxx\n');
382
+ return;
383
+ }
384
+ // Validate email
385
+ if (!isValidEmail(address)) {
386
+ process.stderr.write(`Error: Invalid email address: ${address}\n`);
387
+ process.exit(1);
388
+ }
389
+ // Get device info for context
390
+ const fingerprint = generateDeviceFingerprint(opts.configDir);
391
+ const deviceName = getDefaultDeviceName();
392
+ // Initiate verification
393
+ process.stderr.write(`Sending verification code to ${address}...\n`);
394
+ const initResult = await initiateVerification(address, fingerprint, deviceName, opts.configDir);
395
+ if (!initResult.sent || !initResult.pending) {
396
+ process.stderr.write(`Error: ${initResult.error ?? 'Failed to send verification email'}\n`);
397
+ process.exit(1);
398
+ }
399
+ process.stderr.write('Verification code sent! Check your inbox.\n\n');
400
+ // Prompt for code (up to 3 attempts)
401
+ let verified = false;
402
+ const pending = initResult.pending;
403
+ while (!verified) {
404
+ const code = await readLineFromTerminal('Enter 6-digit code: ');
405
+ const result = verifyCode(pending, code);
406
+ if (result.success) {
407
+ verified = true;
408
+ // Save verified config
409
+ completeVerification(address, opts.resendKey, opts.configDir);
410
+ process.stdout.write('\nEmail verified successfully!\n');
411
+ process.stdout.write(`Device approval codes will be sent to ${address}\n`);
412
+ }
413
+ else if (result.reason === 'expired') {
414
+ process.stderr.write('Code expired. Run the command again to get a new code.\n');
415
+ process.exit(1);
416
+ }
417
+ else if (result.reason === 'max_attempts') {
418
+ process.stderr.write('Maximum attempts reached. Run the command again to get a new code.\n');
419
+ process.exit(1);
420
+ }
421
+ else {
422
+ process.stderr.write(`Invalid code. ${result.remainingAttempts} attempt${result.remainingAttempts === 1 ? '' : 's'} remaining.\n`);
423
+ }
424
+ }
425
+ }
426
+ catch (err) {
427
+ const msg = err instanceof Error ? err.message : String(err);
428
+ process.stderr.write(`Error: ${msg}\n`);
429
+ process.exit(1);
430
+ }
431
+ });
432
+ // ─── device command group ─────────────────────────────────────────
433
+ const device = program
434
+ .command('device')
435
+ .description('Manage approved devices for vault access');
436
+ device
437
+ .command('list')
438
+ .description('List all approved devices')
439
+ .option('--json', 'Output as JSON array for scripting')
440
+ .option('--base-dir <path>', 'Base directory for config/database files')
441
+ .action(async (opts) => {
442
+ try {
443
+ const { listDevicesCommand, formatDeviceList } = await import('./signatures/commands.js');
444
+ const result = await listDevicesCommand(opts.baseDir);
445
+ if (opts.json) {
446
+ process.stdout.write(JSON.stringify(result.devices, null, 2) + '\n');
447
+ return;
448
+ }
449
+ process.stdout.write(formatDeviceList(result));
450
+ }
451
+ catch (err) {
452
+ const msg = err instanceof Error ? err.message : String(err);
453
+ process.stderr.write(`Error: ${msg}\n`);
454
+ process.exit(1);
455
+ }
456
+ });
457
+ device
458
+ .command('rename <fingerprint-prefix> <new-name>')
459
+ .description('Rename an approved device (fingerprint prefix match, minimum 8 chars)')
460
+ .option('--base-dir <path>', 'Base directory for config/database files')
461
+ .action(async (prefix, newName, opts) => {
462
+ try {
463
+ const { renameDeviceCommand, formatAmbiguousMatches, resolvePrefix } = await import('./signatures/commands.js');
464
+ const { DevicesDatabase } = await import('./signatures/devices.js');
465
+ // Check for ambiguous matches to show helpful output
466
+ const db = await DevicesDatabase.open(opts.baseDir);
467
+ try {
468
+ const match = resolvePrefix(db, prefix);
469
+ if (!match.exact && match.matches.length > 1) {
470
+ process.stderr.write(formatAmbiguousMatches(match.matches));
471
+ process.exit(1);
472
+ }
473
+ }
474
+ finally {
475
+ db.close();
476
+ }
477
+ const result = await renameDeviceCommand(prefix, newName, opts.baseDir);
478
+ if (!result.success) {
479
+ process.stderr.write(`Error: ${result.error}\n`);
480
+ process.exit(1);
481
+ }
482
+ process.stdout.write(`Device renamed: '${result.oldName}' -> '${result.newName}'\n`);
483
+ }
484
+ catch (err) {
485
+ const msg = err instanceof Error ? err.message : String(err);
486
+ process.stderr.write(`Error: ${msg}\n`);
487
+ process.exit(1);
488
+ }
489
+ });
490
+ device
491
+ .command('remove [fingerprint-prefix]')
492
+ .description('Remove an approved device (requires confirmation)')
493
+ .option('--all', 'Remove all devices except current')
494
+ .option('--yes', 'Skip confirmation prompt')
495
+ .option('--base-dir <path>', 'Base directory for config/database files')
496
+ .action(async (prefix, opts) => {
497
+ try {
498
+ if (opts.all) {
499
+ // Remove all devices except current
500
+ const { removeAllDevicesCommand } = await import('./signatures/commands.js');
501
+ if (!opts.yes) {
502
+ const answer = await readLineFromTerminal('Remove ALL approved devices except the current one? This cannot be undone. [y/N] ');
503
+ if (answer.trim().toLowerCase() !== 'y') {
504
+ process.stderr.write('Cancelled.\n');
505
+ process.exit(0);
506
+ }
507
+ }
508
+ const result = await removeAllDevicesCommand(opts.baseDir);
509
+ if (!result.success) {
510
+ process.stderr.write(`Error: ${result.error}\n`);
511
+ process.exit(1);
512
+ }
513
+ process.stdout.write(`Removed ${result.removedCount} device(s). Only the current device remains.\n`);
514
+ return;
515
+ }
516
+ // Remove a single device
517
+ if (!prefix) {
518
+ process.stderr.write('Error: Provide a fingerprint prefix, or use --all to remove all devices except current.\n');
519
+ process.exit(1);
520
+ }
521
+ const { removeDeviceCommand, formatAmbiguousMatches, resolvePrefix, } = await import('./signatures/commands.js');
522
+ const { DevicesDatabase } = await import('./signatures/devices.js');
523
+ // Pre-check for ambiguous or missing matches
524
+ const db = await DevicesDatabase.open(opts.baseDir);
525
+ let matchedDevice = null;
526
+ try {
527
+ const { generateDeviceFingerprint } = await import('./signatures/fingerprint.js');
528
+ const currentFp = generateDeviceFingerprint(opts.baseDir);
529
+ const match = resolvePrefix(db, prefix);
530
+ if (!match.exact && match.matches.length > 1) {
531
+ process.stderr.write(formatAmbiguousMatches(match.matches));
532
+ process.exit(1);
533
+ }
534
+ if (match.exact) {
535
+ matchedDevice = {
536
+ fingerprint: match.matches[0].fingerprint,
537
+ name: match.matches[0].name,
538
+ is_current: match.matches[0].fingerprint === currentFp,
539
+ };
540
+ }
541
+ }
542
+ finally {
543
+ db.close();
544
+ }
545
+ // Confirm before removal
546
+ if (!opts.yes && matchedDevice) {
547
+ let prompt = `Remove device '${matchedDevice.name}'?`;
548
+ if (matchedDevice.is_current) {
549
+ prompt += ' (WARNING: This is the current device. You will need to re-approve on next use.)';
550
+ }
551
+ prompt += ' [y/N] ';
552
+ const answer = await readLineFromTerminal(prompt);
553
+ if (answer.trim().toLowerCase() !== 'y') {
554
+ process.stderr.write('Cancelled.\n');
555
+ process.exit(0);
556
+ }
557
+ }
558
+ const result = await removeDeviceCommand(prefix, opts.baseDir);
559
+ if (!result.success) {
560
+ process.stderr.write(`Error: ${result.error}\n`);
561
+ process.exit(1);
562
+ }
563
+ process.stdout.write(`Device removed: ${result.name}\n`);
564
+ if (result.wasCurrentDevice) {
565
+ process.stdout.write('You will need to re-approve this device on next use.\n');
566
+ }
567
+ }
568
+ catch (err) {
569
+ const msg = err instanceof Error ? err.message : String(err);
570
+ process.stderr.write(`Error: ${msg}\n`);
571
+ process.exit(1);
572
+ }
573
+ });
574
+ // ─── token command group (prefix-scoped tokens — US-001) ────────────
575
+ const tokenCmd = program
576
+ .command('token')
577
+ .description('Manage access tokens (with prefix scoping)');
578
+ tokenCmd
579
+ .command('create')
580
+ .description('Create a new access token with optional prefix scopes')
581
+ .requiredOption('--name <name>', 'Human-readable name for the token')
582
+ .option('--prefix <prefixes...>', 'Restrict token to specific vault prefixes (e.g., levelfit/ synesis/)')
583
+ .option('--ttl <duration>', 'Time-to-live (e.g. 1h, 30m, 7d). Default: no expiry')
584
+ .option('--max-uses <count>', 'Maximum number of uses. Default: unlimited')
585
+ .action(async (opts) => {
586
+ try {
587
+ const { vaultRequest, readTokenFile, getDefaultTokenFile, readServerPort, getDefaultPortFile } = await import('yokotoken');
588
+ const { storeTokenScopes } = await import('./tokens.js');
589
+ const portFile = getDefaultPortFile();
590
+ const port = readServerPort(portFile);
591
+ if (!port) {
592
+ process.stderr.write('Error: Vault server is not running. Start it with: unotoken server\n');
593
+ process.exit(1);
594
+ }
595
+ const tokenFile = getDefaultTokenFile();
596
+ const serverToken = readTokenFile(tokenFile);
597
+ if (!serverToken) {
598
+ process.stderr.write('Error: No server token found. Is the vault server running?\n');
599
+ process.exit(1);
600
+ }
601
+ const body = { name: opts.name };
602
+ if (opts.ttl)
603
+ body.ttl = opts.ttl;
604
+ if (opts.maxUses)
605
+ body.max_uses = parseInt(opts.maxUses, 10);
606
+ const res = await vaultRequest({ port, host: '127.0.0.1', token: serverToken }, 'POST', '/v1/tokens', body);
607
+ if (res.statusCode === 201) {
608
+ const rawToken = res.body.token;
609
+ const meta = res.body.metadata;
610
+ const prefixes = opts.prefix ?? null;
611
+ // Store prefix scopes
612
+ storeTokenScopes(rawToken, opts.name, prefixes);
613
+ process.stderr.write(`Token created: ${opts.name}\n`);
614
+ if (prefixes && prefixes.length > 0) {
615
+ process.stderr.write(`Scopes: ${prefixes.map((p) => `${p}*`).join(', ')}\n`);
616
+ }
617
+ else {
618
+ process.stderr.write('Scopes: * (full access)\n');
619
+ }
620
+ process.stderr.write('\n');
621
+ // Display the token ONCE
622
+ process.stdout.write(`${rawToken}\n`);
623
+ process.stderr.write('\nIMPORTANT: Save this token now. It cannot be retrieved again.\n');
624
+ if (meta.expiresAt) {
625
+ process.stderr.write(`Expires: ${meta.expiresAt}\n`);
626
+ }
627
+ if (meta.maxUses) {
628
+ process.stderr.write(`Max uses: ${meta.maxUses}\n`);
629
+ }
630
+ }
631
+ else {
632
+ process.stderr.write(`Error: ${res.body.error}\n`);
633
+ process.exit(1);
634
+ }
635
+ }
636
+ catch (err) {
637
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : err}\n`);
638
+ process.exit(1);
639
+ }
640
+ });
641
+ tokenCmd
642
+ .command('list')
643
+ .description('List all access tokens with scopes')
644
+ .action(async () => {
645
+ try {
646
+ const { vaultRequest, readTokenFile, getDefaultTokenFile, readServerPort, getDefaultPortFile } = await import('yokotoken');
647
+ const { buildScopesByName, formatScopes } = await import('./tokens.js');
648
+ const portFile = getDefaultPortFile();
649
+ const port = readServerPort(portFile);
650
+ if (!port) {
651
+ process.stderr.write('Error: Vault server is not running. Start it with: unotoken server\n');
652
+ process.exit(1);
653
+ }
654
+ const tokenFile = getDefaultTokenFile();
655
+ const serverToken = readTokenFile(tokenFile);
656
+ if (!serverToken) {
657
+ process.stderr.write('Error: No server token found. Is the vault server running?\n');
658
+ process.exit(1);
659
+ }
660
+ const res = await vaultRequest({ port, host: '127.0.0.1', token: serverToken }, 'GET', '/v1/tokens');
661
+ if (res.statusCode === 200) {
662
+ const tokens = res.body.tokens;
663
+ if (tokens.length === 0) {
664
+ process.stderr.write('No access tokens found.\n');
665
+ return;
666
+ }
667
+ const scopesByName = buildScopesByName();
668
+ // Table output with SCOPES column
669
+ process.stdout.write(`${'NAME'.padEnd(25)} ${'SCOPES'.padEnd(30)} ${'CREATED'.padEnd(20)} ${'EXPIRES'.padEnd(20)} ${'USES'.padEnd(10)}\n`);
670
+ process.stdout.write(`${'─'.repeat(25)} ${'─'.repeat(30)} ${'─'.repeat(20)} ${'─'.repeat(20)} ${'─'.repeat(10)}\n`);
671
+ for (const t of tokens) {
672
+ const name = t.name;
673
+ const nameCol = name.padEnd(25);
674
+ const scopes = scopesByName.get(name) ?? null;
675
+ const scopeCol = formatScopes(scopes).padEnd(30);
676
+ const createdCol = (t.createdAt || '-').substring(0, 19).padEnd(20);
677
+ const expiresCol = (t.expiresAt ? t.expiresAt.substring(0, 19) : 'never').padEnd(20);
678
+ const usesCol = (t.maxUses !== null ? `${t.useCount}/${t.maxUses}` : `${t.useCount}`).padEnd(10);
679
+ process.stdout.write(`${nameCol} ${scopeCol} ${createdCol} ${expiresCol} ${usesCol}\n`);
680
+ }
681
+ process.stderr.write(`\n${tokens.length} token(s) found.\n`);
682
+ }
683
+ else {
684
+ process.stderr.write(`Error: ${res.body.error}\n`);
685
+ process.exit(1);
686
+ }
687
+ }
688
+ catch (err) {
689
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : err}\n`);
690
+ process.exit(1);
691
+ }
692
+ });
693
+ tokenCmd
694
+ .command('revoke <name>')
695
+ .description('Revoke (delete) an access token and its scopes')
696
+ .action(async (tokenName) => {
697
+ try {
698
+ const { vaultRequest, readTokenFile, getDefaultTokenFile, readServerPort, getDefaultPortFile } = await import('yokotoken');
699
+ const { revokeTokenScopes } = await import('./tokens.js');
700
+ const portFile = getDefaultPortFile();
701
+ const port = readServerPort(portFile);
702
+ if (!port) {
703
+ process.stderr.write('Error: Vault server is not running. Start it with: unotoken server\n');
704
+ process.exit(1);
705
+ }
706
+ const tokenFile = getDefaultTokenFile();
707
+ const serverToken = readTokenFile(tokenFile);
708
+ if (!serverToken) {
709
+ process.stderr.write('Error: No server token found. Is the vault server running?\n');
710
+ process.exit(1);
711
+ }
712
+ const res = await vaultRequest({ port, host: '127.0.0.1', token: serverToken }, 'DELETE', `/v1/tokens/${encodeURIComponent(tokenName)}`);
713
+ if (res.statusCode === 200) {
714
+ // Also remove scope entry
715
+ revokeTokenScopes(tokenName);
716
+ process.stderr.write(`Token revoked: ${tokenName}\n`);
717
+ }
718
+ else {
719
+ process.stderr.write(`Error: ${res.body.error}\n`);
720
+ process.exit(1);
721
+ }
722
+ }
723
+ catch (err) {
724
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : err}\n`);
725
+ process.exit(1);
726
+ }
727
+ });
728
+ // ─── token request workflow (approval — US-004) ─────────────────────
729
+ tokenCmd
730
+ .command('request')
731
+ .description('Request access to a vault prefix (creates a pending request for owner approval)')
732
+ .requiredOption('--name <name>', 'Name for the requested token')
733
+ .requiredOption('--prefix <prefixes...>', 'Vault prefix(es) to request access to')
734
+ .option('--reason <reason>', 'Reason for the request', '')
735
+ .option('--status', 'Check the status of your pending request (by name)')
736
+ .option('--requests-path <path>', 'Path to token-requests.json (auto-detected)')
737
+ .action(async (opts) => {
738
+ try {
739
+ const { createRequest, getRequestByName, formatRequest } = await import('./token-requests.js');
740
+ if (opts.status) {
741
+ // Poll mode: check status of request by name
742
+ const req = getRequestByName(opts.name, opts.requestsPath);
743
+ if (!req) {
744
+ process.stderr.write(`No request found for name '${opts.name}'.\n`);
745
+ process.exit(1);
746
+ }
747
+ process.stdout.write(formatRequest(req) + '\n');
748
+ return;
749
+ }
750
+ // Read owner email from env (optional, for notifications)
751
+ const ownerEmail = process.env.UNOTOKEN_OWNER_EMAIL;
752
+ const request = createRequest({ name: opts.name, prefixes: opts.prefix, reason: opts.reason }, ownerEmail, opts.requestsPath);
753
+ process.stderr.write(`Token request created:\n`);
754
+ process.stderr.write(` ID: ${request.id}\n`);
755
+ process.stderr.write(` Name: ${request.name}\n`);
756
+ process.stderr.write(` Prefixes: ${request.prefixes.map((p) => `${p}*`).join(', ')}\n`);
757
+ process.stderr.write(` Status: pending\n`);
758
+ process.stderr.write(`\nThe vault owner must approve this request.\n`);
759
+ process.stderr.write(`Check status: unotoken token request --name ${request.name} --prefix ${request.prefixes[0]} --status\n`);
760
+ }
761
+ catch (err) {
762
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : err}\n`);
763
+ process.exit(1);
764
+ }
765
+ });
766
+ tokenCmd
767
+ .command('requests')
768
+ .description('List pending token requests (owner/admin only)')
769
+ .option('--all', 'Show all requests (including approved/denied)')
770
+ .option('--requests-path <path>', 'Path to token-requests.json (auto-detected)')
771
+ .action(async (opts) => {
772
+ try {
773
+ const { listRequests, formatRequest } = await import('./token-requests.js');
774
+ const statusFilter = opts.all ? undefined : 'pending';
775
+ const requests = listRequests(statusFilter, opts.requestsPath);
776
+ if (requests.length === 0) {
777
+ process.stderr.write(opts.all ? 'No token requests found.\n' : 'No pending token requests.\n');
778
+ return;
779
+ }
780
+ for (const req of requests) {
781
+ process.stdout.write(formatRequest(req) + '\n\n');
782
+ }
783
+ process.stderr.write(`${requests.length} request(s) found.\n`);
784
+ }
785
+ catch (err) {
786
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : err}\n`);
787
+ process.exit(1);
788
+ }
789
+ });
790
+ tokenCmd
791
+ .command('approve <requestId>')
792
+ .description('Approve a pending token request (creates the scoped token)')
793
+ .option('--requests-path <path>', 'Path to token-requests.json (auto-detected)')
794
+ .action(async (requestId, opts) => {
795
+ try {
796
+ const { vaultRequest, readTokenFile, getDefaultTokenFile, readServerPort, getDefaultPortFile } = await import('yokotoken');
797
+ const { storeTokenScopes } = await import('./tokens.js');
798
+ const { approveRequest, getRequest } = await import('./token-requests.js');
799
+ // Verify the request exists and is pending
800
+ const request = getRequest(requestId, opts.requestsPath);
801
+ if (!request) {
802
+ process.stderr.write(`Error: Token request '${requestId}' not found.\n`);
803
+ process.exit(1);
804
+ }
805
+ if (request.status !== 'pending') {
806
+ process.stderr.write(`Error: Token request '${requestId}' is already ${request.status}.\n`);
807
+ process.exit(1);
808
+ }
809
+ // Verify vault server is running
810
+ const portFile = getDefaultPortFile();
811
+ const port = readServerPort(portFile);
812
+ if (!port) {
813
+ process.stderr.write('Error: Vault server is not running. Start it with: unotoken server\n');
814
+ process.exit(1);
815
+ }
816
+ const tokenFile = getDefaultTokenFile();
817
+ const serverToken = readTokenFile(tokenFile);
818
+ if (!serverToken) {
819
+ process.stderr.write('Error: No server token found. Is the vault server running?\n');
820
+ process.exit(1);
821
+ }
822
+ // Create the scoped token via yokotoken API
823
+ const res = await vaultRequest({ port, host: '127.0.0.1', token: serverToken }, 'POST', '/v1/tokens', { name: request.name });
824
+ if (res.statusCode !== 201) {
825
+ process.stderr.write(`Error creating token: ${res.body.error}\n`);
826
+ process.exit(1);
827
+ }
828
+ const rawToken = res.body.token;
829
+ // Store prefix scopes
830
+ storeTokenScopes(rawToken, request.name, request.prefixes);
831
+ // Mark request as approved
832
+ const reviewer = process.env.USER || process.env.USERNAME || 'vault-owner';
833
+ approveRequest(requestId, reviewer, opts.requestsPath);
834
+ process.stderr.write(`Request approved: ${request.name}\n`);
835
+ process.stderr.write(`Scopes: ${request.prefixes.map((p) => `${p}*`).join(', ')}\n\n`);
836
+ // Display the token ONCE
837
+ process.stdout.write(`${rawToken}\n`);
838
+ process.stderr.write('\nIMPORTANT: Save this token now. It cannot be retrieved again.\n');
839
+ process.stderr.write('If the requester misses this token, they must submit a new request.\n');
840
+ }
841
+ catch (err) {
842
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : err}\n`);
843
+ process.exit(1);
844
+ }
845
+ });
846
+ tokenCmd
847
+ .command('deny <requestId>')
848
+ .description('Deny a pending token request')
849
+ .option('--reason <reason>', 'Reason for denial')
850
+ .option('--requests-path <path>', 'Path to token-requests.json (auto-detected)')
851
+ .action(async (requestId, opts) => {
852
+ try {
853
+ const { denyRequest } = await import('./token-requests.js');
854
+ const reviewer = process.env.USER || process.env.USERNAME || 'vault-owner';
855
+ const request = denyRequest(requestId, reviewer, opts.reason, opts.requestsPath);
856
+ process.stderr.write(`Request denied: ${request.name}\n`);
857
+ if (opts.reason) {
858
+ process.stderr.write(`Reason: ${opts.reason}\n`);
859
+ }
860
+ }
861
+ catch (err) {
862
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : err}\n`);
863
+ process.exit(1);
864
+ }
865
+ });
866
+ // ─── exec command (build-time env injection — US-002) ────────────────
867
+ program
868
+ .command('exec')
869
+ .description('Run a command with vault secrets injected as environment variables')
870
+ .requiredOption('--prefix <prefix>', 'Vault prefix to fetch secrets from (e.g., levelfit/)')
871
+ .option('--token <token>', 'Bearer token for authentication (default: UNOTOKEN_TOKEN or ~/.yokotoken/token)')
872
+ .option('--vault-url <url>', 'Vault server URL (default: UNOTOKEN_URL or local vault)')
873
+ .option('--force', 'Vault values override existing env vars (default behavior)')
874
+ .option('--no-force', 'Existing env vars take precedence over vault values')
875
+ .option('--env-file <path>', 'Supplementary .env file for non-secret config')
876
+ .allowUnknownOption(true)
877
+ .action(async (opts, cmd) => {
878
+ try {
879
+ const { resolveToken, resolveVaultConnection, execWithVaultSecrets } = await import('./exec.js');
880
+ // Extract the child command from args after --
881
+ const rawArgs = cmd.args;
882
+ if (rawArgs.length === 0) {
883
+ process.stderr.write('Error: No command specified. Usage: unotoken exec --prefix <prefix> -- <command>\n');
884
+ process.exit(1);
885
+ }
886
+ // Resolve token
887
+ let token;
888
+ try {
889
+ token = await resolveToken(opts.token);
890
+ }
891
+ catch (err) {
892
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : err}\n`);
893
+ process.exit(1);
894
+ }
895
+ // Resolve vault connection
896
+ let connection;
897
+ try {
898
+ connection = await resolveVaultConnection(opts.vaultUrl);
899
+ }
900
+ catch (err) {
901
+ const msg = err instanceof Error ? err.message : String(err);
902
+ if (msg.includes('Cannot reach vault')) {
903
+ process.stderr.write('Error: Cannot reach vault. Unlock with: unotoken unlock\n');
904
+ }
905
+ else {
906
+ process.stderr.write(`Error: ${msg}\n`);
907
+ }
908
+ process.exit(1);
909
+ }
910
+ // Execute
911
+ const exitCode = await execWithVaultSecrets({
912
+ prefix: opts.prefix,
913
+ command: rawArgs,
914
+ token,
915
+ host: connection.host,
916
+ port: connection.port,
917
+ force: opts.force,
918
+ envFile: opts.envFile,
919
+ });
920
+ process.exit(exitCode);
921
+ }
922
+ catch (err) {
923
+ const msg = err instanceof Error ? err.message : String(err);
924
+ // Provide friendly messages for common errors
925
+ if (msg.includes('ECONNREFUSED') || msg.includes('ENOTFOUND')) {
926
+ process.stderr.write('Error: Cannot reach vault. Is the vault server running?\n');
927
+ process.stderr.write('Start it with: unotoken server\n');
928
+ }
929
+ else {
930
+ process.stderr.write(`Error: ${msg}\n`);
931
+ }
932
+ process.exit(1);
933
+ }
934
+ });
935
+ // ─── dotenv command (build-time .env generation — US-003) ────────────
936
+ program
937
+ .command('dotenv')
938
+ .description('Generate a .env file from vault secrets')
939
+ .requiredOption('--prefix <prefix>', 'Vault prefix to fetch secrets from (e.g., levelfit/)')
940
+ .option('--out <path>', 'Output file path (default: stdout)')
941
+ .option('--token <token>', 'Bearer token for authentication (default: UNOTOKEN_TOKEN or ~/.yokotoken/token)')
942
+ .option('--vault-url <url>', 'Vault server URL (default: UNOTOKEN_URL or local vault)')
943
+ .option('--force', 'Overwrite file even if it has non-unotoken content')
944
+ .option('--clean <path>', 'Delete a unotoken-generated .env file')
945
+ .action(async (opts) => {
946
+ try {
947
+ // --clean mode: delete a generated .env file and exit
948
+ if (opts.clean) {
949
+ const { cleanDotenv } = await import('./dotenv.js');
950
+ cleanDotenv(opts.clean);
951
+ process.stderr.write(`Cleaned: ${opts.clean}\n`);
952
+ return;
953
+ }
954
+ const { resolveToken, resolveVaultConnection } = await import('./exec.js');
955
+ const { writeDotenv } = await import('./dotenv.js');
956
+ // Resolve token
957
+ let token;
958
+ try {
959
+ token = await resolveToken(opts.token);
960
+ }
961
+ catch (err) {
962
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : err}\n`);
963
+ process.exit(1);
964
+ }
965
+ // Resolve vault connection
966
+ let connection;
967
+ try {
968
+ connection = await resolveVaultConnection(opts.vaultUrl);
969
+ }
970
+ catch (err) {
971
+ const msg = err instanceof Error ? err.message : String(err);
972
+ if (msg.includes('Cannot reach vault')) {
973
+ process.stderr.write('Error: Cannot reach vault. Unlock with: unotoken unlock\n');
974
+ }
975
+ else {
976
+ process.stderr.write(`Error: ${msg}\n`);
977
+ }
978
+ process.exit(1);
979
+ }
980
+ // Generate dotenv
981
+ await writeDotenv({
982
+ prefix: opts.prefix,
983
+ token,
984
+ host: connection.host,
985
+ port: connection.port,
986
+ out: opts.out,
987
+ force: opts.force,
988
+ });
989
+ if (opts.out) {
990
+ process.stderr.write(`Generated: ${opts.out}\n`);
991
+ }
992
+ }
993
+ catch (err) {
994
+ const msg = err instanceof Error ? err.message : String(err);
995
+ if (msg.includes('ECONNREFUSED') || msg.includes('ENOTFOUND')) {
996
+ process.stderr.write('Error: Cannot reach vault. Is the vault server running?\n');
997
+ process.stderr.write('Start it with: unotoken server\n');
998
+ }
999
+ else {
1000
+ process.stderr.write(`Error: ${msg}\n`);
1001
+ }
1002
+ process.exit(1);
1003
+ }
1004
+ });
1005
+ // ─── Helpers ─────────────────────────────────────────────────────────
1006
+ /**
1007
+ * Read a passphrase from the terminal (stdin) with hidden input.
1008
+ */
1009
+ function readPassphraseFromTerminal(prompt) {
1010
+ return new Promise((resolve, reject) => {
1011
+ // Check if stdin is a TTY
1012
+ if (process.stdin.isTTY) {
1013
+ const rl = createInterface({
1014
+ input: process.stdin,
1015
+ output: process.stderr,
1016
+ terminal: true,
1017
+ });
1018
+ // Temporarily mute output for passphrase entry
1019
+ process.stderr.write(prompt);
1020
+ const stdin = process.stdin;
1021
+ const oldRawMode = stdin.isRaw;
1022
+ // On Windows, readline handles the echo suppression
1023
+ rl.question('', (answer) => {
1024
+ rl.close();
1025
+ if (stdin.setRawMode && oldRawMode !== undefined) {
1026
+ stdin.setRawMode(oldRawMode);
1027
+ }
1028
+ resolve(answer);
1029
+ });
1030
+ // Suppress echo by using raw mode if available
1031
+ if (stdin.setRawMode) {
1032
+ stdin.setRawMode(true);
1033
+ let passphrase = '';
1034
+ const onData = (data) => {
1035
+ const char = data.toString('utf-8');
1036
+ if (char === '\n' || char === '\r') {
1037
+ stdin.removeListener('data', onData);
1038
+ stdin.setRawMode(false);
1039
+ rl.close();
1040
+ process.stderr.write('\n');
1041
+ resolve(passphrase);
1042
+ }
1043
+ else if (char === '\u0003') {
1044
+ // Ctrl+C
1045
+ stdin.removeListener('data', onData);
1046
+ stdin.setRawMode(false);
1047
+ rl.close();
1048
+ reject(new Error('Cancelled'));
1049
+ }
1050
+ else if (char === '\u007f' || char === '\b') {
1051
+ // Backspace
1052
+ passphrase = passphrase.slice(0, -1);
1053
+ }
1054
+ else {
1055
+ passphrase += char;
1056
+ }
1057
+ };
1058
+ stdin.on('data', onData);
1059
+ // Close the readline question to avoid double-resolve
1060
+ rl.close();
1061
+ }
1062
+ }
1063
+ else {
1064
+ // Non-TTY (piped input) — just read a line
1065
+ const rl = createInterface({
1066
+ input: process.stdin,
1067
+ output: process.stderr,
1068
+ });
1069
+ rl.question(prompt, (answer) => {
1070
+ rl.close();
1071
+ resolve(answer);
1072
+ });
1073
+ }
1074
+ });
1075
+ }
1076
+ /**
1077
+ * Read a line of input from the terminal.
1078
+ */
1079
+ function readLineFromTerminal(prompt) {
1080
+ return new Promise((resolve) => {
1081
+ const rl = createInterface({
1082
+ input: process.stdin,
1083
+ output: process.stderr,
1084
+ });
1085
+ rl.question(prompt, (answer) => {
1086
+ rl.close();
1087
+ resolve(answer);
1088
+ });
1089
+ });
1090
+ }
1091
+ function capitalize(s) {
1092
+ return s.charAt(0).toUpperCase() + s.slice(1);
1093
+ }
1094
+ function formatDate(isoDate) {
1095
+ try {
1096
+ const d = new Date(isoDate);
1097
+ if (isNaN(d.getTime()))
1098
+ return isoDate;
1099
+ return d.toLocaleString();
1100
+ }
1101
+ catch {
1102
+ return isoDate;
1103
+ }
1104
+ }
1105
+ // ─── Device guard helper ──────────────────────────────────────────
1106
+ /**
1107
+ * Run the device guard check interactively.
1108
+ *
1109
+ * Uses TTY readline for code entry when a new device is detected.
1110
+ */
1111
+ async function runDeviceGuard() {
1112
+ const { deviceGuard, DeviceApprovalRequired } = await import('./signatures/guard.js');
1113
+ try {
1114
+ const result = await deviceGuard({
1115
+ isTTY: process.stdin.isTTY === true,
1116
+ readLine: readLineFromTerminal,
1117
+ writeOutput: (msg) => process.stderr.write(msg),
1118
+ writeError: (msg) => process.stderr.write(msg),
1119
+ });
1120
+ return { allowed: result.allowed };
1121
+ }
1122
+ catch (err) {
1123
+ if (err instanceof DeviceApprovalRequired) {
1124
+ process.stderr.write(`Error: ${err.message}\n`);
1125
+ return { allowed: false };
1126
+ }
1127
+ throw err;
1128
+ }
1129
+ }
1130
+ // ─── Vault command interception (device guard for delegated commands) ──
1131
+ /** Commands that require device approval before delegation to yokotoken */
1132
+ const GUARDED_VAULT_COMMANDS = new Set(['get', 'set', 'list', 'delete', 'server']);
1133
+ /**
1134
+ * Run the device guard, then delegate to yokotoken if approved.
1135
+ */
1136
+ async function guardAndDelegate(args) {
1137
+ const guardResult = await runDeviceGuard();
1138
+ if (!guardResult.allowed) {
1139
+ process.exit(1);
1140
+ }
1141
+ delegateToYokotoken(args);
1142
+ }
1143
+ // ─── Delegate all other commands to yokotoken CLI ──────────────────
1144
+ /**
1145
+ * Resolve the path to the yokotoken CLI binary.
1146
+ */
1147
+ function resolveYokotokenBin() {
1148
+ const localBin = path.resolve(__dirname, '..', 'node_modules', '.bin', 'yokotoken');
1149
+ return localBin;
1150
+ }
1151
+ /**
1152
+ * Delegate a command invocation to the yokotoken CLI.
1153
+ * Passes through all arguments and inherits stdio.
1154
+ */
1155
+ function delegateToYokotoken(args) {
1156
+ const bin = resolveYokotokenBin();
1157
+ const child = spawn(bin, args, {
1158
+ stdio: 'inherit',
1159
+ shell: true,
1160
+ });
1161
+ child.on('error', (err) => {
1162
+ // If local bin not found, try global yokotoken
1163
+ const fallback = spawn('yokotoken', args, {
1164
+ stdio: 'inherit',
1165
+ shell: true,
1166
+ });
1167
+ fallback.on('error', () => {
1168
+ process.stderr.write(`Error: Could not find yokotoken CLI. Install it with: npm install -g yokotoken\n` +
1169
+ `Original error: ${err.message}\n`);
1170
+ process.exit(1);
1171
+ });
1172
+ fallback.on('exit', (code) => {
1173
+ process.exit(code ?? 1);
1174
+ });
1175
+ });
1176
+ child.on('exit', (code) => {
1177
+ process.exit(code ?? 0);
1178
+ });
1179
+ }
1180
+ // Check if the first argument is a known unotoken command
1181
+ const unotokenCommands = new Set([
1182
+ 'auth',
1183
+ 'config',
1184
+ 'unlock',
1185
+ 'device',
1186
+ 'token',
1187
+ 'exec',
1188
+ 'help',
1189
+ '--help',
1190
+ '-h',
1191
+ '--version',
1192
+ '-V',
1193
+ ]);
1194
+ const args = process.argv.slice(2);
1195
+ if (args.length === 0 || unotokenCommands.has(args[0])) {
1196
+ // Let commander handle it (unotoken-specific commands + help/version)
1197
+ program.parse();
1198
+ }
1199
+ else if (GUARDED_VAULT_COMMANDS.has(args[0])) {
1200
+ // Vault operations -- run device guard first, then delegate
1201
+ guardAndDelegate(args);
1202
+ }
1203
+ else {
1204
+ // Delegate everything else to yokotoken
1205
+ delegateToYokotoken(args);
1206
+ }
1207
+ //# sourceMappingURL=cli.js.map