@newly/cli 0.1.1 → 0.1.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,145 @@
1
+ import { InvalidArgumentError, Option } from 'commander';
2
+ import { ApiClient, friendlyApiError } from '../api.js';
3
+ import { requireCredentials } from '../config.js';
4
+ import { formatAge, formatState, printJson, renderDetails, renderTable, shortDigest, StatusLine, style, } from '../terminal.js';
5
+ const POLL_INTERVAL_MS = 3_000;
6
+ const DEFAULT_WAIT_TIMEOUT_SECONDS = 1_200;
7
+ function runCloudApi() {
8
+ const credentials = requireCredentials();
9
+ return new ApiClient(credentials.apiUrl, credentials.token);
10
+ }
11
+ function print(value, opts) {
12
+ if (opts.json) {
13
+ printJson(value);
14
+ return;
15
+ }
16
+ console.log(renderImage(value));
17
+ }
18
+ function renderImage(row, title) {
19
+ const heading = title ??
20
+ (row.state === 'ready'
21
+ ? '✓ Image ready'
22
+ : row.state === 'failed'
23
+ ? '✗ Image build failed'
24
+ : '◐ Image build started');
25
+ const footer = row.state === 'building'
26
+ ? `${style.dim('Follow:')} runcloud image list`
27
+ : undefined;
28
+ return renderDetails(heading, [
29
+ { label: 'Reference', value: row.ref ?? row.name },
30
+ { label: 'State', value: formatState(row.state) },
31
+ { label: 'Digest', value: shortDigest(row.digest) },
32
+ { label: 'Created', value: formatAge(row.createdAt) },
33
+ { label: 'Error', value: row.error },
34
+ ], footer);
35
+ }
36
+ function renderImageList(rows) {
37
+ if (rows.length === 0) {
38
+ return `${style.bold('Images')}\n\nNo images registered.\n\n${style.dim('Register one:')} runcloud image create --ref python:3.12-slim`;
39
+ }
40
+ return renderTable(`Images (${rows.length})`, [
41
+ { key: 'ref', label: 'REFERENCE', maxWidth: 44 },
42
+ { key: 'state', label: 'STATE', maxWidth: 12 },
43
+ { key: 'digest', label: 'DIGEST' },
44
+ { key: 'age', label: 'AGE' },
45
+ ], rows.map((row) => ({
46
+ ref: row.ref ?? row.name,
47
+ state: formatState(row.state),
48
+ digest: shortDigest(row.digest) ?? '—',
49
+ age: formatAge(row.createdAt) ?? '—',
50
+ })));
51
+ }
52
+ async function run(fn) {
53
+ try {
54
+ await fn();
55
+ }
56
+ catch (err) {
57
+ console.error(friendlyApiError(err));
58
+ process.exitCode = 1;
59
+ }
60
+ }
61
+ function withOutput(cmd) {
62
+ return cmd.option('--json', 'output JSON', false);
63
+ }
64
+ function parseTimeoutSeconds(value) {
65
+ const n = Number(value);
66
+ if (!Number.isFinite(n) || n <= 0) {
67
+ throw new InvalidArgumentError('--timeout must be a positive number of seconds');
68
+ }
69
+ return n;
70
+ }
71
+ const TERMINAL_STATES = new Set(['ready', 'failed']);
72
+ export async function pollImageRegistration(api, ref, opts = {}) {
73
+ const intervalMs = opts.intervalMs ?? POLL_INTERVAL_MS;
74
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_SECONDS * 1000;
75
+ const deadline = Date.now() + timeoutMs;
76
+ for (;;) {
77
+ const data = await api.get('/run-cloud/images');
78
+ const items = (data?.items ?? []);
79
+ const row = items.find((item) => item.ref === ref || item.name === ref);
80
+ if (row)
81
+ opts.onState?.(row.state);
82
+ if (row && TERMINAL_STATES.has(row.state))
83
+ return row;
84
+ if (Date.now() > deadline) {
85
+ throw new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s waiting for image ${ref} to become ready`);
86
+ }
87
+ await new Promise((done) => setTimeout(done, intervalMs));
88
+ }
89
+ }
90
+ export function registerImage(program) {
91
+ const image = program
92
+ .command('image')
93
+ .description('Register and manage the OCI images run.cloud sandboxes boot from');
94
+ withOutput(image
95
+ .command('create')
96
+ .description('Register (build-once) an OCI image ref for run.cloud sandboxes')
97
+ .requiredOption('--ref <ref>', 'OCI image ref to register, e.g. python:3.12-slim')
98
+ .option('--no-wait', 'return after scheduling instead of waiting for the image build')
99
+ .option('--timeout <seconds>', 'max time to wait in seconds', parseTimeoutSeconds, DEFAULT_WAIT_TIMEOUT_SECONDS)
100
+ .addOption(new Option('--wait').hideHelp())).action((opts) => run(async () => {
101
+ const api = runCloudApi();
102
+ const created = await api.post('/run-cloud/images', { ref: opts.ref });
103
+ if (!opts.wait || created?.state === 'ready') {
104
+ print(created, opts);
105
+ return;
106
+ }
107
+ const status = opts.json ? undefined : new StatusLine();
108
+ status?.update(`Building ${opts.ref}`);
109
+ const finalRow = await pollImageRegistration(api, opts.ref, {
110
+ timeoutMs: opts.timeout * 1000,
111
+ onState: (state) => status?.update(state === 'building' ? `Building ${opts.ref}` : `${opts.ref} is ${state}`),
112
+ });
113
+ if (finalRow.state === 'failed') {
114
+ status?.fail(`Image build failed`);
115
+ throw new Error(finalRow.error
116
+ ? `image ${opts.ref} failed to build: ${finalRow.error}`
117
+ : `image ${opts.ref} failed to build`);
118
+ }
119
+ status?.clear();
120
+ print(finalRow, opts);
121
+ }));
122
+ withOutput(image.command('list').description('List images your organization has registered')).action((opts) => run(async () => {
123
+ const data = await runCloudApi().get('/run-cloud/images');
124
+ const rows = (data?.items ?? []);
125
+ if (opts.json)
126
+ printJson(rows);
127
+ else
128
+ console.log(renderImageList(rows));
129
+ }));
130
+ withOutput(image
131
+ .command('refresh')
132
+ .description("Move a tag's pin to its current upstream digest")
133
+ .argument('<ref>', 'OCI image ref to refresh')).action((ref, opts) => run(async () => {
134
+ const refreshed = await runCloudApi().post('/run-cloud/images/refresh', { ref });
135
+ if (opts.json)
136
+ printJson(refreshed);
137
+ else {
138
+ console.log(renderDetails('✓ Image refreshed', [
139
+ { label: 'Reference', value: refreshed?.ref ?? ref },
140
+ { label: 'Previous', value: shortDigest(refreshed?.old_digest) },
141
+ { label: 'Digest', value: shortDigest(refreshed?.new_digest ?? refreshed?.digest) },
142
+ ]));
143
+ }
144
+ }));
145
+ }
@@ -1,9 +1,39 @@
1
1
  import http from 'node:http';
2
2
  import crypto from 'node:crypto';
3
+ import { hostname } from 'node:os';
3
4
  import { spawn } from 'node:child_process';
4
- import { clearCredentials, saveCredentials } from '../config.js';
5
- const DEFAULT_APP_URL = process.env.NEWLY_APP_URL ?? 'https://newly.app';
5
+ import { ApiClient, ApiError } from '../api.js';
6
+ import { clearCredentials, credentialsDisplayPath, loadCredentials, normalizeApiUrl, saveCredentials, } from '../config.js';
7
+ import { acquirePromptIO, ask, PromptAbortedError, select, } from '../prompt.js';
8
+ import { AuthError, createApiKey, fetchProviders, isValidEmail, isValidOtp, normalizeOtp, sendEmailCode, verifyEmailCode, } from '../runCloudAuth.js';
6
9
  const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
10
+ export const DEFAULT_RUN_CLOUD_API_URL = 'https://api.run.cloud';
11
+ export function loginChoices(providers) {
12
+ const choices = [];
13
+ if (providers.emailOtp) {
14
+ choices.push({ value: 'email', label: 'Email', hint: 'a code is sent to your inbox' });
15
+ }
16
+ if (providers.google) {
17
+ choices.push({ value: 'google', label: 'Google', hint: 'opens your browser' });
18
+ }
19
+ if (providers.github) {
20
+ choices.push({ value: 'github', label: 'GitHub', hint: 'opens your browser' });
21
+ }
22
+ return choices;
23
+ }
24
+ export function methodFromFlags(flags) {
25
+ const chosen = [];
26
+ if (flags.email)
27
+ chosen.push('email');
28
+ if (flags.google)
29
+ chosen.push('google');
30
+ if (flags.github)
31
+ chosen.push('github');
32
+ if (chosen.length > 1) {
33
+ throw new Error(`Pick one sign-in method: ${chosen.map((m) => `--${m}`).join(' and ')} conflict.`);
34
+ }
35
+ return chosen[0] ?? null;
36
+ }
7
37
  export async function startCallbackServer(state, appOrigin, timeoutMs) {
8
38
  let resolvePayload;
9
39
  let rejectPayload;
@@ -11,11 +41,15 @@ export async function startCallbackServer(state, appOrigin, timeoutMs) {
11
41
  resolvePayload = resolve;
12
42
  rejectPayload = reject;
13
43
  });
14
- const timer = setTimeout(() => rejectPayload(new Error(`Login timed out after ${timeoutMs / 1000}s`)), timeoutMs);
44
+ const timer = setTimeout(() => rejectPayload(new Error(`Login timed out after ${timeoutMs / 1000}s.\n` +
45
+ "If the browser said the local handoff was blocked, Chrome gates HTTPS→localhost behind a permission: " +
46
+ 'click the icon at the left of the address bar, allow "access to apps on this device", ' +
47
+ 'then run login again (or use --manual).')), timeoutMs);
15
48
  const corsHeaders = {
16
49
  'Access-Control-Allow-Origin': appOrigin,
17
50
  'Access-Control-Allow-Methods': 'POST, OPTIONS',
18
51
  'Access-Control-Allow-Headers': 'content-type',
52
+ 'Access-Control-Allow-Private-Network': 'true',
19
53
  };
20
54
  const server = http.createServer((req, res) => {
21
55
  if (req.method === 'OPTIONS') {
@@ -38,6 +72,22 @@ export async function startCallbackServer(state, appOrigin, timeoutMs) {
38
72
  res.end('state mismatch');
39
73
  return;
40
74
  }
75
+ if (typeof parsed.token !== 'string' ||
76
+ parsed.token.length === 0 ||
77
+ typeof parsed.expiresIn !== 'number' ||
78
+ !Number.isFinite(parsed.expiresIn) ||
79
+ parsed.expiresIn <= 0 ||
80
+ typeof parsed.apiUrl !== 'string') {
81
+ res.writeHead(400, corsHeaders);
82
+ res.end('invalid credential payload');
83
+ return;
84
+ }
85
+ const apiUrl = new URL(parsed.apiUrl);
86
+ if (!['http:', 'https:'].includes(apiUrl.protocol)) {
87
+ res.writeHead(400, corsHeaders);
88
+ res.end('invalid API URL');
89
+ return;
90
+ }
41
91
  res.writeHead(200, { ...corsHeaders, 'Content-Type': 'application/json' });
42
92
  res.end(JSON.stringify({ ok: true }));
43
93
  clearTimeout(timer);
@@ -64,7 +114,7 @@ export async function startCallbackServer(state, appOrigin, timeoutMs) {
64
114
  },
65
115
  };
66
116
  }
67
- function openBrowser(url) {
117
+ export function openBrowser(url) {
68
118
  const [cmd, args] = process.platform === 'darwin'
69
119
  ? ['open', [url]]
70
120
  : process.platform === 'win32'
@@ -75,6 +125,54 @@ function openBrowser(url) {
75
125
  });
76
126
  child.unref();
77
127
  }
128
+ export function cliAuthUrl(appUrl, port, state, provider) {
129
+ const url = new URL('/cli-auth', appUrl);
130
+ url.searchParams.set('port', String(port));
131
+ url.searchParams.set('state', state);
132
+ if (provider)
133
+ url.searchParams.set('provider', provider);
134
+ return url.toString();
135
+ }
136
+ export async function emailLogin(apiUrl, presetEmail, io) {
137
+ const email = presetEmail?.trim()
138
+ ? presetEmail.trim()
139
+ : await ask(' Email: ', {
140
+ io,
141
+ validate: (value) => (isValidEmail(value) ? null : 'That does not look like an email address.'),
142
+ });
143
+ if (!isValidEmail(email))
144
+ throw new Error(`Not a valid email address: ${email}`);
145
+ await sendEmailCode(apiUrl, email);
146
+ console.log(` Code sent to ${email}\n`);
147
+ let session = null;
148
+ for (let attempt = 1; attempt <= 3 && !session; attempt += 1) {
149
+ const code = await ask(' Code: ', {
150
+ io,
151
+ validate: (value) => (isValidOtp(value) ? null : 'Codes are six digits.'),
152
+ });
153
+ try {
154
+ session = await verifyEmailCode(apiUrl, email, normalizeOtp(code));
155
+ }
156
+ catch (e) {
157
+ const wrongCode = e instanceof AuthError && (e.status === 400 || e.status === 401);
158
+ if (!wrongCode || attempt === 3)
159
+ throw e;
160
+ console.log(` ${e.message} — try again.\n`);
161
+ }
162
+ }
163
+ if (!session)
164
+ throw new Error('Could not verify the code.');
165
+ const token = await createApiKey(apiUrl, session.sessionToken, `runcloud CLI (${hostLabel()})`);
166
+ return { email, token };
167
+ }
168
+ function hostLabel() {
169
+ try {
170
+ return hostname() || 'cli';
171
+ }
172
+ catch {
173
+ return 'cli';
174
+ }
175
+ }
78
176
  export function parseManualCredential(blob) {
79
177
  let parsed;
80
178
  try {
@@ -90,39 +188,113 @@ export function parseManualCredential(blob) {
90
188
  }
91
189
  return { token: c.token, expiresIn: c.expiresIn, apiUrl: c.apiUrl };
92
190
  }
191
+ export async function verifyRunCloudCredential(apiUrl, token) {
192
+ try {
193
+ await new ApiClient(normalizeApiUrl(apiUrl), token).get('/run-cloud/account');
194
+ }
195
+ catch (e) {
196
+ if (e instanceof ApiError && (e.status === 401 || e.status === 403)) {
197
+ throw new Error(`${apiUrl} rejected this credential (${e.status}). Make sure you signed in to the account ` +
198
+ 'you want the CLI to use, then run login again.');
199
+ }
200
+ console.warn(`Warning: could not verify the credential against ${apiUrl} ` +
201
+ `(${e instanceof Error ? e.message : String(e)}). Saving it anyway.`);
202
+ }
203
+ }
204
+ export async function revokeRunCloudCredential(apiUrl, token) {
205
+ try {
206
+ await new ApiClient(normalizeApiUrl(apiUrl), token).delete('/run-cloud/api-keys/self');
207
+ return 'Credential revoked.';
208
+ }
209
+ catch (e) {
210
+ if (e instanceof ApiError && (e.status === 401 || e.status === 403)) {
211
+ return 'Credential was already invalid.';
212
+ }
213
+ return (`Warning: could not reach ${apiUrl} to revoke this credential ` +
214
+ `(${e instanceof Error ? e.message : String(e)}). It may still be valid — ` +
215
+ 'revoke it from the run.cloud dashboard.');
216
+ }
217
+ }
218
+ export function existingLoginSummary(creds) {
219
+ const who = creds.account ? ` as ${creds.account}` : '';
220
+ const expiry = creds.expiresAt < Number.MAX_SAFE_INTEGER
221
+ ? `, valid until ${new Date(creds.expiresAt * 1000).toLocaleString()}`
222
+ : '';
223
+ return `You are already logged in${who} (${creds.apiUrl}${expiry}).`;
224
+ }
225
+ export async function confirmReplaceExistingLogin(creds) {
226
+ if (!creds)
227
+ return true;
228
+ const terminal = acquirePromptIO();
229
+ if (!terminal)
230
+ return true;
231
+ try {
232
+ console.log(`\n ${existingLoginSummary(creds)}`);
233
+ const choice = await select(' Do you want to continue?', [
234
+ { value: 'keep', label: 'Keep this session', hint: 'nothing to do' },
235
+ { value: 'again', label: 'Log in again', hint: 'replaces the saved credential' },
236
+ ], terminal.io);
237
+ return choice === 'again';
238
+ }
239
+ finally {
240
+ terminal.close();
241
+ }
242
+ }
93
243
  export function registerLogin(program) {
244
+ const isRunCloud = program.name() === 'runcloud';
245
+ const defaultAppUrl = isRunCloud
246
+ ? process.env.RUN_CLOUD_APP_URL ?? 'https://run.cloud'
247
+ : process.env.NEWLY_APP_URL ?? 'https://newly.app';
94
248
  program
95
249
  .command('login')
96
- .description('Log in with your Newly account via the browser')
97
- .option('--app-url <url>', 'Newly web app URL', DEFAULT_APP_URL)
98
- .option('--manual', 'paste the credential from the browser instead of using the local callback', false)
250
+ .description(isRunCloud
251
+ ? 'Log in with your run.cloud account: email, Google, or GitHub'
252
+ : 'Log in with your Newly account via the browser')
253
+ .option('--app-url <url>', 'product web app URL', defaultAppUrl)
254
+ .option('--manual', 'newly skin only: paste the credential from the browser instead of using the local callback', false)
99
255
  .option('--api-url <url>', 'explicit API URL (skips browser; use with --token)')
100
256
  .option('--token <token>', 'explicit credential token (skips browser; use with --api-url)')
101
257
  .option('--expires-in <seconds>', 'session lifetime when using --token (default 7 days)', (v) => Number(v), 604800)
258
+ .option('--email [address]', 'sign in with an emailed code, without a browser')
259
+ .option('--google', 'sign in with Google', false)
260
+ .option('--github', 'sign in with GitHub', false)
261
+ .option('--browser', 'skip the picker and use the browser handoff', false)
102
262
  .action(async (opts) => {
103
263
  try {
104
264
  const hasApiUrl = typeof opts.apiUrl === 'string' && opts.apiUrl.length > 0;
105
265
  const hasToken = typeof opts.token === 'string' && opts.token.length > 0;
106
- if (hasApiUrl !== hasToken) {
266
+ if (!isRunCloud && hasApiUrl !== hasToken) {
107
267
  throw new Error('--api-url and --token must be provided together for headless login.');
108
268
  }
109
- if (hasApiUrl && hasToken) {
269
+ if (isRunCloud && hasApiUrl && !hasToken) {
270
+ throw new Error('--api-url requires --token for headless login.');
271
+ }
272
+ if (hasToken) {
273
+ const apiUrl = isRunCloud
274
+ ? opts.apiUrl ?? process.env.RUN_CLOUD_API_URL ?? 'https://api.run.cloud'
275
+ : opts.apiUrl;
110
276
  try {
111
- new URL(opts.apiUrl);
277
+ new URL(apiUrl);
112
278
  }
113
279
  catch {
114
- throw new Error(`--api-url is not a valid URL: ${opts.apiUrl}`);
280
+ throw new Error(`--api-url is not a valid URL: ${apiUrl}`);
115
281
  }
116
282
  saveCredentials({
117
283
  token: opts.token,
118
- apiUrl: opts.apiUrl,
284
+ apiUrl,
119
285
  appUrl: opts.appUrl,
120
- expiresAt: Math.floor(Date.now() / 1000) + opts.expiresIn,
286
+ expiresAt: isRunCloud
287
+ ? Number.MAX_SAFE_INTEGER
288
+ : Math.floor(Date.now() / 1000) + opts.expiresIn,
121
289
  });
122
- console.log(`Logged in headlessly. Credentials written for API ${opts.apiUrl}.`);
290
+ console.log(`Logged in headlessly. Credentials written for API ${apiUrl}.`);
291
+ return;
292
+ }
293
+ if (!(await confirmReplaceExistingLogin(loadCredentials()))) {
294
+ console.log(` Keeping the credential at ${credentialsDisplayPath()}\n`);
123
295
  return;
124
296
  }
125
- if (opts.manual) {
297
+ if (opts.manual && !isRunCloud) {
126
298
  const { createInterface } = await import('node:readline/promises');
127
299
  const rl = createInterface({ input: process.stdin, output: process.stdout });
128
300
  const blob = await rl.question('Paste the credential from the browser: ');
@@ -137,36 +309,111 @@ export function registerLogin(program) {
137
309
  console.log('Logged in.');
138
310
  return;
139
311
  }
312
+ let socialProvider;
313
+ if (isRunCloud && !opts.browser) {
314
+ const authApiUrl = opts.apiUrl ?? process.env.RUN_CLOUD_API_URL ?? DEFAULT_RUN_CLOUD_API_URL;
315
+ let method = methodFromFlags(opts);
316
+ const terminal = method === 'email' || !method ? acquirePromptIO() : null;
317
+ try {
318
+ if (!method && terminal) {
319
+ const providers = await fetchProviders(authApiUrl);
320
+ const choices = loginChoices(providers);
321
+ if (choices.length === 0) {
322
+ throw new Error(`${authApiUrl} has no sign-in methods enabled.`);
323
+ }
324
+ method = await select(' How do you want to sign in?', choices, terminal.io);
325
+ }
326
+ if (method === 'email') {
327
+ const preset = typeof opts.email === 'string' ? opts.email : undefined;
328
+ const { email, token } = await emailLogin(authApiUrl, preset, terminal?.io);
329
+ saveCredentials({
330
+ token,
331
+ apiUrl: authApiUrl,
332
+ appUrl: opts.appUrl,
333
+ expiresAt: Number.MAX_SAFE_INTEGER,
334
+ revocable: true,
335
+ account: email,
336
+ });
337
+ console.log(`\n Signed in as ${email}`);
338
+ console.log(` Credential saved to ${credentialsDisplayPath()}\n`);
339
+ console.log(' Try:');
340
+ console.log(' runcloud ios create --open');
341
+ console.log(' runcloud sandbox run "echo hello"');
342
+ return;
343
+ }
344
+ }
345
+ finally {
346
+ terminal?.close();
347
+ }
348
+ if (method === 'google' || method === 'github')
349
+ socialProvider = method;
350
+ }
351
+ if (isRunCloud) {
352
+ if (opts.manual) {
353
+ console.log('Note: --manual is no longer needed for run.cloud — `runcloud login` works over SSH and headless via device flow.');
354
+ }
355
+ const apiUrl = opts.apiUrl ?? process.env.RUN_CLOUD_API_URL ?? DEFAULT_RUN_CLOUD_API_URL;
356
+ const { runDeviceLogin } = await import('../deviceLogin.js');
357
+ const payload = await runDeviceLogin({
358
+ apiUrl,
359
+ appUrl: opts.appUrl,
360
+ provider: socialProvider,
361
+ openBrowser,
362
+ });
363
+ await verifyRunCloudCredential(payload.apiUrl, payload.token);
364
+ saveCredentials({
365
+ token: payload.token,
366
+ apiUrl: payload.apiUrl,
367
+ appUrl: opts.appUrl,
368
+ expiresAt: Math.floor(Date.now() / 1000) + payload.expiresIn,
369
+ revocable: true,
370
+ });
371
+ console.log(`Logged in. Credentials saved to ${credentialsDisplayPath()}`);
372
+ return;
373
+ }
140
374
  const state = crypto.randomBytes(16).toString('hex');
141
375
  const appOrigin = new URL(opts.appUrl).origin;
142
376
  const { port, waitForToken, close } = await startCallbackServer(state, appOrigin, LOGIN_TIMEOUT_MS);
143
- const loginUrl = `${opts.appUrl}/cli-auth?port=${port}&state=${state}`;
377
+ const loginUrl = cliAuthUrl(opts.appUrl, port, state, socialProvider);
144
378
  console.log(`Opening your browser to authorize the CLI…\n ${loginUrl}\n`);
145
379
  console.log("If the browser doesn't open, paste the URL above into it manually.");
146
380
  openBrowser(loginUrl);
147
381
  try {
148
382
  const payload = await waitForToken();
383
+ if (isRunCloud)
384
+ await verifyRunCloudCredential(payload.apiUrl, payload.token);
149
385
  saveCredentials({
150
386
  token: payload.token,
151
387
  apiUrl: payload.apiUrl,
152
388
  appUrl: opts.appUrl,
153
389
  expiresAt: Math.floor(Date.now() / 1000) + payload.expiresIn,
154
390
  });
155
- console.log('Logged in. Credentials saved to ~/.newly/credentials');
391
+ console.log(`Logged in. Credentials saved to ${credentialsDisplayPath()}`);
156
392
  }
157
393
  finally {
158
394
  close();
159
395
  }
160
396
  }
161
397
  catch (e) {
398
+ if (e instanceof PromptAbortedError) {
399
+ console.log('Cancelled.');
400
+ process.exitCode = 130;
401
+ return;
402
+ }
162
403
  console.error(e instanceof Error ? e.message : String(e));
163
404
  process.exitCode = 1;
164
405
  }
165
406
  });
166
407
  program
167
408
  .command('logout')
168
- .description('Delete the stored CLI credential')
169
- .action(() => {
409
+ .description(isRunCloud
410
+ ? 'Revoke this machine’s credential and delete it locally'
411
+ : 'Delete the stored CLI credential')
412
+ .action(async () => {
413
+ const creds = loadCredentials();
414
+ if (isRunCloud && creds?.revocable) {
415
+ console.log(await revokeRunCloudCredential(creds.apiUrl, creds.token));
416
+ }
170
417
  clearCredentials();
171
418
  console.log('Logged out.');
172
419
  });