faces-cli 1.6.16 → 1.6.18

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/dist/client.d.ts CHANGED
@@ -2,9 +2,11 @@ export declare class FacesAPIError extends Error {
2
2
  statusCode: number;
3
3
  errorCode?: string;
4
4
  fallbackAvailable?: boolean;
5
+ details?: Record<string, unknown>;
5
6
  constructor(statusCode: number, message: string, opts?: {
6
7
  errorCode?: string;
7
8
  fallbackAvailable?: boolean;
9
+ details?: Record<string, unknown>;
8
10
  });
9
11
  }
10
12
  export interface ResponseWithHeaders<T = unknown> {
package/dist/client.js CHANGED
@@ -2,12 +2,14 @@ export class FacesAPIError extends Error {
2
2
  statusCode;
3
3
  errorCode;
4
4
  fallbackAvailable;
5
+ details;
5
6
  constructor(statusCode, message, opts) {
6
7
  super(message);
7
8
  this.statusCode = statusCode;
8
9
  this.name = 'FacesAPIError';
9
10
  this.errorCode = opts?.errorCode;
10
11
  this.fallbackAvailable = opts?.fallbackAvailable;
12
+ this.details = opts?.details;
11
13
  }
12
14
  }
13
15
  export class FacesClient {
@@ -46,6 +48,7 @@ export class FacesClient {
46
48
  let msg = resp.statusText;
47
49
  let errorCode;
48
50
  let fallbackAvailable;
51
+ let details;
49
52
  try {
50
53
  const body = await resp.json();
51
54
  // Structured OAuth rejection (422)
@@ -56,11 +59,16 @@ export class FacesClient {
56
59
  }
57
60
  else {
58
61
  const raw = body.detail ?? body.error ?? body.message ?? msg;
59
- if (typeof raw === 'object' && raw !== null && 'message' in raw) {
60
- msg = String(raw.message);
62
+ if (typeof raw === 'object' && raw !== null) {
63
+ // Structured error detail, e.g. {error, message, settings_url, settings_steps}
64
+ const obj = raw;
65
+ details = obj;
66
+ if (typeof obj.error === 'string')
67
+ errorCode = obj.error;
68
+ msg = 'message' in obj ? String(obj.message) : JSON.stringify(obj);
61
69
  }
62
70
  else {
63
- msg = typeof raw === 'object' ? JSON.stringify(raw) : String(raw);
71
+ msg = String(raw);
64
72
  }
65
73
  }
66
74
  }
@@ -71,7 +79,7 @@ export class FacesClient {
71
79
  msg = `Payment required: ${msg}`;
72
80
  if (resp.status === 403)
73
81
  msg = `Forbidden: ${msg}`;
74
- return new FacesAPIError(resp.status, msg, { errorCode, fallbackAvailable });
82
+ return new FacesAPIError(resp.status, msg, { errorCode, fallbackAvailable, details });
75
83
  }
76
84
  async get(path, opts = {}) {
77
85
  let url = this.url(path);
@@ -5,11 +5,12 @@ export default class AuthConnect extends BaseCommand {
5
5
  provider: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
6
6
  };
7
7
  static flags: {
8
- manual: import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
8
  'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
9
  token: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
10
  'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
11
  };
13
12
  static examples: string[];
14
13
  run(): Promise<unknown>;
14
+ private startDevice;
15
+ private handleStartError;
15
16
  }
@@ -1,71 +1,27 @@
1
- import { Args, Flags } from '@oclif/core';
2
- import * as http from 'node:http';
3
- import * as readline from 'node:readline';
4
- import { spawn } from 'node:child_process';
1
+ import { Args } from '@oclif/core';
5
2
  import { BaseCommand } from '../../base.js';
6
3
  import { FacesAPIError } from '../../client.js';
7
- const CALLBACK_PORT = 1455;
8
- const HTML_SUCCESS = `<!DOCTYPE html><html>
9
- <head><title>Connected</title><style>body{font-family:sans-serif;padding:48px;max-width:480px;color:#333}</style></head>
10
- <body><h2 style="color:#2e7d32">✅ Connected</h2>
11
- <p>Your account is linked. You can close this tab and return to your terminal.</p>
12
- </body></html>`;
13
- const HTML_ERROR = `<!DOCTYPE html><html>
14
- <head><title>Error</title><style>body{font-family:sans-serif;padding:48px;max-width:480px;color:#333}</style></head>
15
- <body><h2 style="color:#c62828">Connection failed</h2>
16
- <p>Something went wrong. Check your terminal for details.</p>
17
- </body></html>`;
18
- function openBrowser(url) {
19
- const p = process.platform;
20
- const [cmd, args] = p === 'darwin' ? ['open', [url]] :
21
- p === 'win32' ? ['cmd', ['/c', 'start', url]] :
22
- ['xdg-open', [url]];
23
- spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
4
+ const VERIFICATION_FALLBACK = 'https://auth.openai.com/codex/device';
5
+ const SETTINGS_URL = 'https://chatgpt.com/#settings/Security';
6
+ const HARD_STOP_MS = 15 * 60 * 1000; // device codes expire after 15 minutes
7
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
8
+ const emit = (msg = '') => process.stderr.write(msg + '\n');
9
+ // Unwrap a possible StandardResponse envelope ({data: {...}}) without
10
+ // clobbering a flat payload that already has the expected key.
11
+ function unwrap(raw, key) {
12
+ const r = raw;
13
+ if (r && typeof r === 'object' && !(key in r) && r.data && typeof r.data === 'object') {
14
+ return r.data;
15
+ }
16
+ return raw;
24
17
  }
25
- function waitForCallback(timeoutMs = 300_000) {
26
- return new Promise((resolve, reject) => {
27
- const server = http.createServer((req, res) => {
28
- const url = new URL(req.url ?? '/', `http://127.0.0.1:${CALLBACK_PORT}`);
29
- if (url.pathname !== '/auth/callback') {
30
- res.writeHead(404);
31
- res.end();
32
- return;
33
- }
34
- const code = url.searchParams.get('code');
35
- const state = url.searchParams.get('state');
36
- if (!code || !state) {
37
- res.writeHead(400, { 'Content-Type': 'text/html' });
38
- res.end(HTML_ERROR);
39
- server.close();
40
- reject(new Error('OAuth callback missing code or state'));
41
- return;
42
- }
43
- res.writeHead(200, { 'Content-Type': 'text/html' });
44
- res.end(HTML_SUCCESS);
45
- server.close();
46
- resolve({ code, state });
47
- });
48
- server.on('error', (err) => {
49
- const code = err.code;
50
- if (code === 'EADDRINUSE') {
51
- reject(new Error(`Port ${CALLBACK_PORT} is already in use. ` +
52
- 'Make sure no other process is listening on that port and retry.'));
53
- }
54
- else {
55
- reject(err);
56
- }
57
- });
58
- server.listen(CALLBACK_PORT, '127.0.0.1');
59
- const timer = setTimeout(() => {
60
- server.close();
61
- reject(new Error('Timed out waiting for OAuth callback (5 minutes). Please try again.'));
62
- }, timeoutMs);
63
- // Don't let the timer keep the process alive
64
- timer.unref();
65
- });
18
+ function planLabel(plan) {
19
+ if (!plan)
20
+ return '';
21
+ return plan.charAt(0).toUpperCase() + plan.slice(1);
66
22
  }
67
23
  export default class AuthConnect extends BaseCommand {
68
- static description = 'Connect an OAuth provider account to Faces (e.g. ChatGPT Plus/Pro)';
24
+ static description = 'Connect your ChatGPT account to Faces via device-code authorization (Subscription Connect plan)';
69
25
  static args = {
70
26
  provider: Args.string({
71
27
  description: 'OAuth provider to connect',
@@ -75,165 +31,177 @@ export default class AuthConnect extends BaseCommand {
75
31
  };
76
32
  static flags = {
77
33
  ...BaseCommand.baseFlags,
78
- manual: Flags.boolean({
79
- description: 'Manual mode for headless environments: prints the authorize URL and ' +
80
- 'prompts you to paste the callback URL after approving in any browser.',
81
- default: false,
82
- }),
83
34
  };
84
35
  static examples = [
85
36
  '<%= config.bin %> auth connect openai',
86
- '<%= config.bin %> auth connect openai --manual',
37
+ '<%= config.bin %> auth connect openai --json',
87
38
  ];
88
39
  async run() {
89
- const { args, flags } = await this.parse(AuthConnect);
40
+ const { flags } = await this.parse(AuthConnect);
90
41
  const client = this.makeClient(flags, true);
91
- // 0. Short-circuit if already connected
42
+ // 0. Short-circuit if already connected.
92
43
  try {
93
44
  const existing = (await client.get('/v1/oauth', { requireJwt: true }));
94
- if (existing.some((r) => r.provider === args.provider)) {
95
- const output = { provider: args.provider, connected: true, already_connected: true };
96
- if (!this.jsonEnabled())
97
- this.log(`Already connected to ${args.provider}. Run 'faces auth:disconnect ${args.provider}' to reconnect.`);
45
+ const row = existing.find((r) => r.provider === 'openai');
46
+ if (row) {
47
+ const email = row.account_email ?? null;
48
+ const plan = row.chatgpt_plan_type ?? null;
49
+ const output = { provider: 'openai', connected: true, already_connected: true, account_email: email, chatgpt_plan_type: plan };
50
+ if (!this.jsonEnabled()) {
51
+ const who = email ? ` as ${email}` : '';
52
+ const tier = plan ? ` (${planLabel(plan)})` : '';
53
+ this.log(`Already connected to ChatGPT${who}${tier}. Run 'faces auth:disconnect openai' to reconnect.`);
54
+ }
98
55
  return output;
99
56
  }
100
57
  }
101
- catch { /* ignore — proceed with connect flow */ }
102
- // 1. Get the authorize URL (backend generates PKCE, stores state)
103
- this.log(`Connecting ${args.provider}...`);
104
- let authorizeData;
58
+ catch {
59
+ /* ignore proceed with connect flow */
60
+ }
61
+ // 1. Start the device authorization (backend brokers OpenAI).
62
+ let start;
105
63
  try {
106
- authorizeData = (await client.get(`/v1/oauth/${args.provider}/authorize`, {
107
- requireJwt: true,
108
- }));
64
+ start = await this.startDevice(client);
109
65
  }
110
66
  catch (err) {
111
- if (err instanceof FacesAPIError) {
112
- if (err.statusCode === 403)
113
- this.error('OAuth provider routing requires the connect plan.\n' +
114
- 'Upgrade with: faces billing checkout');
115
- if (err.statusCode === 501)
116
- this.error(`Provider '${args.provider}' is not yet available.`);
117
- this.error(`Failed to get authorize URL (${err.statusCode}): ${err.message}`);
118
- }
119
- throw err;
67
+ this.handleStartError(err);
120
68
  }
121
- // 2. Get code + state — either via local server or manual paste
122
- let code, state;
123
- if (flags.manual) {
124
- this.log('\nOpen this URL in any browser (phone, laptop, etc.):\n' +
125
- ` ${authorizeData.authorize_url}\n\n` +
126
- 'After approving on the OpenAI page:\n' +
127
- ' • If you have the Faces browser extension installed, it will handle the rest automatically.\n' +
128
- ' If not, your browser will try to load localhost:1455 and fail — copy the full URL\n' +
129
- ' from your address bar and paste it below.\n');
130
- // Race: poll for connection (extension path) vs paste (no-extension path)
131
- let rl = null;
132
- const pastePromise = new Promise((resolve) => {
133
- rl = readline.createInterface({ input: process.stdin, output: process.stderr });
134
- rl.question('Callback URL (skip if using extension): ', (answer) => {
135
- rl?.close();
136
- rl = null;
137
- resolve(answer);
69
+ const verificationUri = start.verification_uri || VERIFICATION_FALLBACK;
70
+ // 2. Cancel the device session if the user aborts (Ctrl-C).
71
+ let done = false;
72
+ const onSigint = () => {
73
+ if (done)
74
+ return;
75
+ done = true;
76
+ emit('\nAborted. Cancelling device authorization…');
77
+ Promise.race([
78
+ client.post('/v1/oauth/openai/device/cancel', { requireJwt: true, body: { device_auth_id: start.device_auth_id } }).catch(() => { }),
79
+ sleep(2000),
80
+ ]).finally(() => process.exit(130));
81
+ };
82
+ process.once('SIGINT', onSigint);
83
+ // 3. Show the code + URL and the one-time enable-setting guidance.
84
+ emit('');
85
+ emit('To connect ChatGPT:');
86
+ emit(` 1. Open this URL in any browser (phone, laptop — anywhere):`);
87
+ emit(` ${verificationUri}`);
88
+ emit(` 2. Enter this code: ${start.user_code}`);
89
+ emit(` 3. Sign in to ChatGPT and click Continue.`);
90
+ emit('');
91
+ emit('If this is your first time, you may need to enable device-code authorization in ChatGPT first:');
92
+ emit(` 1. Open ${SETTINGS_URL} (the settings panel can take a few seconds to appear — give it a moment)`);
93
+ emit(' 2. Turn on "Enable device code authorization for Codex"');
94
+ emit(' 3. Then enter the code above');
95
+ emit('');
96
+ emit('Waiting for approval (the code expires in 15 minutes; press Ctrl-C to cancel)…');
97
+ // 4. Poll until completed / expired / timed out.
98
+ const startedAt = Date.now();
99
+ const expiresAt = Date.parse(start.expires_at); // NaN if unparseable
100
+ let intervalMs = Math.max(1, start.interval || 5) * 1000;
101
+ for (;;) {
102
+ const now = Date.now();
103
+ if (now - startedAt > HARD_STOP_MS || (!Number.isNaN(expiresAt) && now >= expiresAt)) {
104
+ process.removeListener('SIGINT', onSigint);
105
+ this.error('The code expired before it was approved. Run `faces auth:connect openai` again to get a new code.');
106
+ }
107
+ await sleep(intervalMs);
108
+ let poll;
109
+ try {
110
+ const raw = await client.post('/v1/oauth/openai/device/poll', {
111
+ requireJwt: true,
112
+ body: { device_auth_id: start.device_auth_id },
138
113
  });
139
- });
140
- const result = await Promise.race([
141
- pollForConnection(client, args.provider, 300_000),
142
- pastePromise,
143
- ]);
144
- // Clean up readline + stdin if polling won (keeps Node from hanging)
145
- if (rl) {
146
- rl.close();
147
- rl = null;
114
+ poll = unwrap(raw, 'status');
148
115
  }
149
- process.stdin.destroy();
150
- if (result === 'connected') {
151
- const output = { provider: args.provider, connected: true };
152
- if (!this.jsonEnabled())
153
- this.log(`\n✅ ${args.provider} connected. Your connect plan requests will route through your subscription.`);
154
- return output;
116
+ catch (err) {
117
+ // Transient upstream errors: keep polling. Everything else is fatal.
118
+ if (err instanceof FacesAPIError && (err.statusCode === 502 || err.statusCode === 503)) {
119
+ process.stderr.write('.');
120
+ continue;
121
+ }
122
+ process.removeListener('SIGINT', onSigint);
123
+ if (err instanceof FacesAPIError)
124
+ this.error(`Polling failed (${err.statusCode}): ${err.message}`);
125
+ throw err;
155
126
  }
156
- // User pasted a URL — exchange it ourselves
157
- const raw = result;
158
- if (!raw.trim())
159
- this.error('No callback URL provided and connection was not detected.');
160
- let parsed;
161
- try {
162
- const normalized = raw.trim().startsWith('http') ? raw.trim() : `http://localhost:1455${raw.trim()}`;
163
- parsed = new URL(normalized);
127
+ if (poll.status === 'completed') {
128
+ done = true;
129
+ process.removeListener('SIGINT', onSigint);
130
+ const email = poll.account_email ?? null;
131
+ const plan = poll.chatgpt_plan_type ?? null;
132
+ const output = { provider: 'openai', connected: true, account_email: email, chatgpt_plan_type: plan };
133
+ if (!this.jsonEnabled()) {
134
+ const who = email ? ` as ${email}` : '';
135
+ const tier = plan ? ` (${planLabel(plan)})` : '';
136
+ this.log(`\n✅ ChatGPT connected${who}${tier}.`);
137
+ if (!plan || plan === 'free') {
138
+ this.log('⚠️ Your ChatGPT plan may not include API access. Connecting requires a paid plan (Plus/Pro/Team, etc.).');
139
+ }
140
+ else {
141
+ this.log('Requests to supported OpenAI models will now route through your ChatGPT subscription.');
142
+ }
143
+ }
144
+ return output;
164
145
  }
165
- catch {
166
- this.error('Could not parse the URL you pasted. Make sure to copy the full address bar URL.');
146
+ if (poll.status === 'expired') {
147
+ process.removeListener('SIGINT', onSigint);
148
+ this.error('The code expired or was not recognized. Run `faces auth:connect openai` again to get a new code.');
167
149
  }
168
- code = parsed.searchParams.get('code') ?? '';
169
- state = parsed.searchParams.get('state') ?? '';
170
- if (!code || !state)
171
- this.error('The pasted URL is missing code or state parameters. Did you copy the full URL?');
150
+ // pending honor server-supplied retry_after, else fall back to interval.
151
+ intervalMs = (poll.retry_after && poll.retry_after > 0 ? poll.retry_after : (start.interval || 5)) * 1000;
152
+ process.stderr.write('.');
172
153
  }
173
- else {
174
- // Automatic mode: local callback server + open browser
175
- const callbackPromise = waitForCallback();
176
- openBrowser(authorizeData.authorize_url);
177
- this.log(`\nBrowser opened. Approve access on the ${args.provider} page.\n` +
178
- `If the browser didn't open, paste this URL:\n ${authorizeData.authorize_url}\n`);
154
+ }
155
+ // Start the device flow, retrying transient upstream (502/503) errors.
156
+ async startDevice(client) {
157
+ let attempts502 = 0;
158
+ let attempts503 = 0;
159
+ for (;;) {
179
160
  try {
180
- ;
181
- ({ code, state } = await callbackPromise);
161
+ const raw = await client.post('/v1/oauth/openai/device/start', { requireJwt: true });
162
+ return unwrap(raw, 'device_auth_id');
182
163
  }
183
164
  catch (err) {
184
- this.error(err instanceof Error ? err.message : String(err));
185
- }
186
- }
187
- // 3. Exchange code for tokens (stored encrypted in backend DB)
188
- this.log('Completing authorization...');
189
- try {
190
- const raw = (await client.post('/v1/oauth/openai/exchange', {
191
- requireJwt: true,
192
- body: { code, state },
193
- }));
194
- // Unwrap StandardResponse envelope if present
195
- const inner = (raw.data ?? raw);
196
- if (!inner.connected)
197
- this.error('Exchange succeeded but the server did not confirm the connection.');
198
- }
199
- catch (err) {
200
- if (err instanceof FacesAPIError) {
201
- // If state was already consumed (browser extension beat us to it),
202
- // check whether the connection was stored anyway and succeed if so.
203
- if (err.statusCode === 400) {
204
- try {
205
- const rows = (await client.get('/v1/oauth', { requireJwt: true }));
206
- if (rows.some((r) => r.provider === args.provider)) {
207
- const output = { provider: args.provider, connected: true };
208
- if (!this.jsonEnabled())
209
- this.log(`\n✅ ${args.provider} connected. Your connect plan requests will route through your subscription.`);
210
- return output;
211
- }
212
- }
213
- catch { /* ignore */ }
165
+ if (err instanceof FacesAPIError && err.statusCode === 503 && attempts503 < 4) {
166
+ attempts503++;
167
+ emit('OpenAI is temporarily unavailable, retrying…');
168
+ await sleep(2000);
169
+ continue;
170
+ }
171
+ if (err instanceof FacesAPIError && err.statusCode === 502 && attempts502 < 2) {
172
+ attempts502++;
173
+ emit('OpenAI returned an error, retrying…');
174
+ await sleep(2000);
175
+ continue;
214
176
  }
215
- this.error(`Token exchange failed (${err.statusCode}): ${err.message}`);
177
+ throw err;
216
178
  }
217
- throw err;
218
179
  }
219
- const output = { provider: args.provider, connected: true };
220
- if (!this.jsonEnabled())
221
- this.log(`\n✅ ${args.provider} connected. Your connect plan requests will route through your subscription.`);
222
- return output;
223
180
  }
224
- }
225
- async function pollForConnection(client, provider, timeoutMs) {
226
- const deadline = Date.now() + timeoutMs;
227
- while (Date.now() < deadline) {
228
- await new Promise((r) => setTimeout(r, 2000));
229
- try {
230
- const rows = (await client.get('/v1/oauth', { requireJwt: true }));
231
- if (rows.some((r) => r.provider === provider))
232
- return 'connected';
233
- }
234
- catch {
235
- // ignore transient errors, keep polling
181
+ handleStartError(err) {
182
+ if (err instanceof FacesAPIError) {
183
+ if (err.statusCode === 401) {
184
+ this.error('Your Faces session is missing or expired. Run `faces auth:login` and try again.');
185
+ }
186
+ if (err.statusCode === 403 || err.errorCode === 'connect_plan_required') {
187
+ this.error('Connecting ChatGPT requires the Subscription Connect plan.\n' +
188
+ 'Upgrade with: faces billing checkout');
189
+ }
190
+ if (err.statusCode === 422 || err.errorCode === 'device_auth_not_enabled') {
191
+ const d = err.details ?? {};
192
+ const url = d.settings_url || SETTINGS_URL;
193
+ const steps = Array.isArray(d.settings_steps) && d.settings_steps.length > 0
194
+ ? d.settings_steps
195
+ : [
196
+ `Open ${url} (the settings panel can take a few seconds to appear — give it a moment)`,
197
+ 'Turn on "Enable device code authorization for Codex"',
198
+ 'Then run `faces auth:connect openai` again',
199
+ ];
200
+ this.error('Device-code authorization is not enabled on your ChatGPT account yet:\n' +
201
+ steps.map((s, i) => ` ${i + 1}. ${s}`).join('\n'));
202
+ }
203
+ this.error(`Failed to start device authorization (${err.statusCode}): ${err.message}`);
236
204
  }
205
+ throw err;
237
206
  }
238
- throw new Error('Timed out waiting for connection (5 minutes).');
239
207
  }
@@ -19,7 +19,19 @@ export default class AuthConnections extends BaseCommand {
19
19
  }
20
20
  else {
21
21
  for (const row of rows) {
22
- this.log(`${row.provider} connected_at=${row.connected_at}${row.scope ? ` scope=${row.scope}` : ''}`);
22
+ if (row.provider === 'openai') {
23
+ const plan = row.chatgpt_plan_type;
24
+ const label = plan ? plan.charAt(0).toUpperCase() + plan.slice(1) : null;
25
+ const who = row.account_email ? ` as ${row.account_email}` : '';
26
+ const tier = label ? ` (${label})` : '';
27
+ this.log(`openai connected${who}${tier} connected_at=${row.connected_at}`);
28
+ if (!plan || plan === 'free') {
29
+ this.log(' ⚠️ This ChatGPT plan may not include API access — connecting requires a paid plan (Plus/Pro/Team, etc.).');
30
+ }
31
+ }
32
+ else {
33
+ this.log(`${row.provider} connected_at=${row.connected_at}${row.scope ? ` scope=${row.scope}` : ''}`);
34
+ }
23
35
  }
24
36
  }
25
37
  }
@@ -7,6 +7,7 @@ export default class KeysCreate extends BaseCommand {
7
7
  budget: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
8
  face: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
9
9
  model: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
+ save: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
11
  'base-url': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
12
  token: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
13
  'api-key': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -1,8 +1,9 @@
1
1
  import { Flags } from '@oclif/core';
2
2
  import { BaseCommand } from '../../base.js';
3
3
  import { FacesAPIError } from '../../client.js';
4
+ import { saveConfig } from '../../config.js';
4
5
  export default class KeysCreate extends BaseCommand {
5
- static description = 'Create a new API key (JWT required)';
6
+ static description = 'Create a new API key (JWT required). The new key is saved to ~/.faces/config.json as api_key by default; pass --no-save to skip.';
6
7
  static flags = {
7
8
  ...BaseCommand.baseFlags,
8
9
  name: Flags.string({ description: 'Key name/label', required: true }),
@@ -10,6 +11,11 @@ export default class KeysCreate extends BaseCommand {
10
11
  budget: Flags.string({ description: 'Spend budget in USD' }),
11
12
  face: Flags.string({ description: 'Allowed face alias (repeatable)', multiple: true }),
12
13
  model: Flags.string({ description: 'Allowed model name (repeatable)', multiple: true }),
14
+ save: Flags.boolean({
15
+ description: 'Save the new key to ~/.faces/config.json as api_key (default: true; use --no-save to skip)',
16
+ default: true,
17
+ allowNo: true,
18
+ }),
13
19
  };
14
20
  async run() {
15
21
  const { flags } = await this.parse(KeysCreate);
@@ -25,15 +31,40 @@ export default class KeysCreate extends BaseCommand {
25
31
  payload.allowed_models = flags.model;
26
32
  let data;
27
33
  try {
28
- data = await client.post('/v1/auth/api-keys', { requireJwt: true, body: payload });
34
+ data = (await client.post('/v1/auth/api-keys', {
35
+ requireJwt: true,
36
+ body: payload,
37
+ }));
29
38
  }
30
39
  catch (err) {
31
40
  if (err instanceof FacesAPIError)
32
41
  this.error(`Error (${err.statusCode}): ${err.message}`);
33
42
  throw err;
34
43
  }
35
- if (!this.jsonEnabled())
36
- this.printHuman(data);
44
+ // The plaintext secret is returned exactly once — here. keys:list only ever
45
+ // returns it truncated, so it must be saved or copied now.
46
+ const apiKey = (data.key ?? data.api_key ?? data.secret);
47
+ let saved = false;
48
+ if (flags.save && apiKey) {
49
+ saveConfig({ api_key: apiKey });
50
+ saved = true;
51
+ }
52
+ if (this.jsonEnabled()) {
53
+ data._saved_to_config = saved;
54
+ return data;
55
+ }
56
+ this.printHuman(data);
57
+ this.log('');
58
+ this.log('⚠ The full key is shown only once — `faces keys:list` returns it truncated by design.');
59
+ if (saved) {
60
+ this.log('✓ Saved to ~/.faces/config.json as api_key — faces commands will use it automatically.');
61
+ }
62
+ else if (!flags.save) {
63
+ this.log(' Not saved (--no-save). Copy the key above now.');
64
+ }
65
+ else {
66
+ this.warn('Could not find the key in the response — nothing was saved. Copy it manually.');
67
+ }
37
68
  return data;
38
69
  }
39
70
  }