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.
- package/README.md +360 -0
- package/dist/cli.d.ts +17 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1207 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +15 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +15 -0
- package/dist/client.js.map +1 -0
- package/dist/db.d.ts +52 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +97 -0
- package/dist/db.js.map +1 -0
- package/dist/dotenv.d.ts +69 -0
- package/dist/dotenv.d.ts.map +1 -0
- package/dist/dotenv.js +115 -0
- package/dist/dotenv.js.map +1 -0
- package/dist/env-mapper.d.ts +55 -0
- package/dist/env-mapper.d.ts.map +1 -0
- package/dist/env-mapper.js +97 -0
- package/dist/env-mapper.js.map +1 -0
- package/dist/exec.d.ts +80 -0
- package/dist/exec.d.ts.map +1 -0
- package/dist/exec.js +214 -0
- package/dist/exec.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -0
- package/dist/oauth/commands.d.ts +151 -0
- package/dist/oauth/commands.d.ts.map +1 -0
- package/dist/oauth/commands.js +322 -0
- package/dist/oauth/commands.js.map +1 -0
- package/dist/oauth/config.d.ts +84 -0
- package/dist/oauth/config.d.ts.map +1 -0
- package/dist/oauth/config.js +156 -0
- package/dist/oauth/config.js.map +1 -0
- package/dist/oauth/crypto-helpers.d.ts +44 -0
- package/dist/oauth/crypto-helpers.d.ts.map +1 -0
- package/dist/oauth/crypto-helpers.js +94 -0
- package/dist/oauth/crypto-helpers.js.map +1 -0
- package/dist/oauth/device-secret.d.ts +57 -0
- package/dist/oauth/device-secret.d.ts.map +1 -0
- package/dist/oauth/device-secret.js +106 -0
- package/dist/oauth/device-secret.js.map +1 -0
- package/dist/oauth/flow.d.ts +112 -0
- package/dist/oauth/flow.d.ts.map +1 -0
- package/dist/oauth/flow.js +255 -0
- package/dist/oauth/flow.js.map +1 -0
- package/dist/oauth/index.d.ts +18 -0
- package/dist/oauth/index.d.ts.map +1 -0
- package/dist/oauth/index.js +24 -0
- package/dist/oauth/index.js.map +1 -0
- package/dist/oauth/key-wrap.d.ts +146 -0
- package/dist/oauth/key-wrap.d.ts.map +1 -0
- package/dist/oauth/key-wrap.js +275 -0
- package/dist/oauth/key-wrap.js.map +1 -0
- package/dist/oauth/pkce.d.ts +29 -0
- package/dist/oauth/pkce.d.ts.map +1 -0
- package/dist/oauth/pkce.js +34 -0
- package/dist/oauth/pkce.js.map +1 -0
- package/dist/oauth/provider.d.ts +79 -0
- package/dist/oauth/provider.d.ts.map +1 -0
- package/dist/oauth/provider.js +10 -0
- package/dist/oauth/provider.js.map +1 -0
- package/dist/oauth/providers/github.d.ts +75 -0
- package/dist/oauth/providers/github.d.ts.map +1 -0
- package/dist/oauth/providers/github.js +119 -0
- package/dist/oauth/providers/github.js.map +1 -0
- package/dist/oauth/providers/google.d.ts +115 -0
- package/dist/oauth/providers/google.d.ts.map +1 -0
- package/dist/oauth/providers/google.js +285 -0
- package/dist/oauth/providers/google.js.map +1 -0
- package/dist/sdk.d.ts +8 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +8 -0
- package/dist/sdk.js.map +1 -0
- package/dist/server.d.ts +33 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +287 -0
- package/dist/server.js.map +1 -0
- package/dist/signatures/approval-codes.d.ts +192 -0
- package/dist/signatures/approval-codes.d.ts.map +1 -0
- package/dist/signatures/approval-codes.js +407 -0
- package/dist/signatures/approval-codes.js.map +1 -0
- package/dist/signatures/commands.d.ts +108 -0
- package/dist/signatures/commands.d.ts.map +1 -0
- package/dist/signatures/commands.js +270 -0
- package/dist/signatures/commands.js.map +1 -0
- package/dist/signatures/devices.d.ts +165 -0
- package/dist/signatures/devices.d.ts.map +1 -0
- package/dist/signatures/devices.js +344 -0
- package/dist/signatures/devices.js.map +1 -0
- package/dist/signatures/email-config.d.ts +102 -0
- package/dist/signatures/email-config.d.ts.map +1 -0
- package/dist/signatures/email-config.js +188 -0
- package/dist/signatures/email-config.js.map +1 -0
- package/dist/signatures/email.d.ts +106 -0
- package/dist/signatures/email.d.ts.map +1 -0
- package/dist/signatures/email.js +180 -0
- package/dist/signatures/email.js.map +1 -0
- package/dist/signatures/fingerprint.d.ts +70 -0
- package/dist/signatures/fingerprint.d.ts.map +1 -0
- package/dist/signatures/fingerprint.js +123 -0
- package/dist/signatures/fingerprint.js.map +1 -0
- package/dist/signatures/guard.d.ts +118 -0
- package/dist/signatures/guard.d.ts.map +1 -0
- package/dist/signatures/guard.js +310 -0
- package/dist/signatures/guard.js.map +1 -0
- package/dist/signatures/resend.d.ts +84 -0
- package/dist/signatures/resend.d.ts.map +1 -0
- package/dist/signatures/resend.js +248 -0
- package/dist/signatures/resend.js.map +1 -0
- package/dist/token-requests.d.ts +80 -0
- package/dist/token-requests.d.ts.map +1 -0
- package/dist/token-requests.js +201 -0
- package/dist/token-requests.js.map +1 -0
- package/dist/tokens.d.ts +80 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +150 -0
- package/dist/tokens.js.map +1 -0
- 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
|