ninepm 0.34.0 → 0.36.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/dist/index.d.ts +23 -3
- package/dist/index.js +235 -23
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -113,7 +113,7 @@ export interface CreatedServiceToken {
|
|
|
113
113
|
clientSecret: string;
|
|
114
114
|
}
|
|
115
115
|
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
116
|
-
export declare const CLI_VERSION = "0.
|
|
116
|
+
export declare const CLI_VERSION = "0.36.0";
|
|
117
117
|
type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
|
|
118
118
|
export interface CredentialDiagnostics {
|
|
119
119
|
platform: NodeJS.Platform;
|
|
@@ -244,12 +244,19 @@ export interface ListSitesResult {
|
|
|
244
244
|
}
|
|
245
245
|
export declare function listSites(options: ApiOptions): Promise<ListSitesResult>;
|
|
246
246
|
export declare function deleteSite(options: DeleteSiteOptions): Promise<DeletedSiteResult>;
|
|
247
|
+
export interface DeviceAuthFeedbackOptions {
|
|
248
|
+
progressStream?: NodeJS.WriteStream;
|
|
249
|
+
openBrowserFn?: (url: string) => BrowserOpenResult;
|
|
250
|
+
storeCredentialFn?: (token: string, options: {
|
|
251
|
+
allowFileFallback?: boolean | undefined;
|
|
252
|
+
}) => string;
|
|
253
|
+
}
|
|
247
254
|
export declare function loginDevice(options: {
|
|
248
255
|
apiUrl: string;
|
|
249
256
|
fetchFn?: FetchLike;
|
|
250
257
|
machineName?: string;
|
|
251
258
|
allowFileCredentialStore?: boolean;
|
|
252
|
-
}): Promise<{
|
|
259
|
+
} & DeviceAuthFeedbackOptions): Promise<{
|
|
253
260
|
email?: string;
|
|
254
261
|
storage: string;
|
|
255
262
|
}>;
|
|
@@ -258,10 +265,11 @@ export declare function signupDevice(options: {
|
|
|
258
265
|
fetchFn?: FetchLike;
|
|
259
266
|
machineName?: string;
|
|
260
267
|
allowFileCredentialStore?: boolean;
|
|
261
|
-
}): Promise<{
|
|
268
|
+
} & DeviceAuthFeedbackOptions): Promise<{
|
|
262
269
|
email?: string;
|
|
263
270
|
storage: string;
|
|
264
271
|
}>;
|
|
272
|
+
export declare function expiredDeviceAuthMessage(purpose: string, userCode: string, lifetimeMs: number, verificationUri: string): string;
|
|
265
273
|
export declare function listManagedFiles(options: FileCommandOptions): Promise<ManagedFileListResult>;
|
|
266
274
|
export declare function putManagedFile(options: FilePutOptions): Promise<ManagedFilePutResult>;
|
|
267
275
|
export declare function getManagedFile(options: FileGetOptions): Promise<{
|
|
@@ -294,6 +302,11 @@ export interface UploadProgressReporter {
|
|
|
294
302
|
stop(): void;
|
|
295
303
|
}
|
|
296
304
|
export declare function createUploadProgressReporter(stream?: NodeJS.WriteStream): UploadProgressReporter;
|
|
305
|
+
export interface DeviceAuthProgressReporter {
|
|
306
|
+
tick(remainingMs: number): void;
|
|
307
|
+
stop(): void;
|
|
308
|
+
}
|
|
309
|
+
export declare function createDeviceAuthProgressReporter(stream?: NodeJS.WriteStream): DeviceAuthProgressReporter;
|
|
297
310
|
type CliCommand = {
|
|
298
311
|
kind: "deploy";
|
|
299
312
|
options: DeployOptions;
|
|
@@ -466,6 +479,13 @@ export declare function parseEnvFileEntries(content: string, kind: "secret" | "v
|
|
|
466
479
|
kind: "secret" | "var";
|
|
467
480
|
}>;
|
|
468
481
|
export declare function credentialDiagnostics(): CredentialDiagnostics;
|
|
482
|
+
export type BrowserOpenResult = "opened" | "failed" | "unknown";
|
|
483
|
+
export declare function classifyBrowserOpen(input: {
|
|
484
|
+
platform: string;
|
|
485
|
+
status: number | null;
|
|
486
|
+
error?: unknown;
|
|
487
|
+
}): BrowserOpenResult;
|
|
488
|
+
export declare function hasNoGraphicalDisplay(platform: string, env: Record<string, string | undefined>): boolean;
|
|
469
489
|
export declare class UsageError extends Error {
|
|
470
490
|
}
|
|
471
491
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import { MAX_DEPLOY_DESCRIPTION_LENGTH, normalizeDeployDescription } from "./dep
|
|
|
12
12
|
import { assetFileTooLargeMessage, oversizedAssets } from "./deploy-limits.js";
|
|
13
13
|
import { buildFileManifest, readManifestFile } from "./manifest.js";
|
|
14
14
|
import { API_KEY_HELP, API_URL_HELP, CLI_CLIENT_ID, DEFAULT_API_URL, reportCliError } from "./cli-config.js";
|
|
15
|
-
export const CLI_VERSION = "0.
|
|
15
|
+
export const CLI_VERSION = "0.36.0";
|
|
16
16
|
const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
|
|
17
17
|
const TOKEN_SERVICE = "9pm";
|
|
18
18
|
const TOKEN_ACCOUNT = "default";
|
|
@@ -755,6 +755,12 @@ async function deviceAuthFlow(options) {
|
|
|
755
755
|
headers: { "content-type": "application/json" },
|
|
756
756
|
body: JSON.stringify({ machineName: options.machineName ?? os.hostname(), purpose: options.purpose }),
|
|
757
757
|
});
|
|
758
|
+
// The control plane refuses a signup it knows can never be approved (#507) with
|
|
759
|
+
// a message written for the tester. Surface it alone and stop here, rather than
|
|
760
|
+
// opening a browser and polling for something that will never arrive.
|
|
761
|
+
if (!startResponse.ok) {
|
|
762
|
+
throw new Error(await apiErrorMessage(startResponse, `start device ${options.purpose}`));
|
|
763
|
+
}
|
|
758
764
|
const start = await expectJson(startResponse, `start device ${options.purpose}`);
|
|
759
765
|
const userCode = start.user_code ?? start.code;
|
|
760
766
|
const deviceCode = start.device_code;
|
|
@@ -763,29 +769,104 @@ async function deviceAuthFlow(options) {
|
|
|
763
769
|
}
|
|
764
770
|
console.log(`Open ${start.verification_uri}`);
|
|
765
771
|
console.log(`Device code: ${userCode}`);
|
|
766
|
-
|
|
772
|
+
// Everything new below goes to stderr (#509): stdout keeps exactly the two
|
|
773
|
+
// lines above plus the caller's unchanged result line, so anything already
|
|
774
|
+
// reading it is unaffected.
|
|
775
|
+
const progressStream = options.progressStream ?? process.stderr;
|
|
776
|
+
const browser = (options.openBrowserFn ?? openBrowser)(start.verification_uri);
|
|
777
|
+
if (browser === "failed") {
|
|
778
|
+
// Say it the moment it happens. A swallowed browser failure used to look
|
|
779
|
+
// exactly like a flow nobody had approved yet.
|
|
780
|
+
progressStream.write("Could not open a browser automatically.\n");
|
|
781
|
+
}
|
|
782
|
+
// Stated on every device auth, not only after a failure: the printed URL
|
|
783
|
+
// carries the code, so opening it anywhere finishes the flow. That recovery
|
|
784
|
+
// path has to be known before it is needed, not discovered by guesswork.
|
|
785
|
+
progressStream.write("Open the URL above in any browser — on this machine or another — to approve this device.\n");
|
|
767
786
|
const deadline = Date.now() + start.expires_in * 1000;
|
|
768
787
|
const intervalMs = Math.max(1, start.interval ?? 2) * 1000;
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
788
|
+
// stop() in a finally so no outcome — approval, denial, network throw,
|
|
789
|
+
// expiry — can leave the spinner timer alive or the line half-drawn.
|
|
790
|
+
const progress = createDeviceAuthProgressReporter(progressStream);
|
|
791
|
+
try {
|
|
792
|
+
while (Date.now() < deadline) {
|
|
793
|
+
// Tick before the sleep so the first feedback is immediate rather than
|
|
794
|
+
// one poll interval late. Remaining time is derived from the deadline the
|
|
795
|
+
// server's expires_in produced, never from a CLI-side constant.
|
|
796
|
+
progress.tick(deadline - Date.now());
|
|
797
|
+
await sleep(intervalMs);
|
|
798
|
+
const pollResponse = await fetchFn(`${apiUrl}/v1/oauth/device/poll`, {
|
|
799
|
+
method: "POST",
|
|
800
|
+
headers: { "content-type": "application/json" },
|
|
801
|
+
body: JSON.stringify({ code: deviceCode }),
|
|
802
|
+
});
|
|
803
|
+
if (pollResponse.status === 400) {
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
if (pollResponse.status === 410) {
|
|
807
|
+
// The server distinguishes three terminal states behind this one status
|
|
808
|
+
// (expired / denied / consumed); the old branch collapsed them into one
|
|
809
|
+
// message and appended the raw JSON envelope. It also swallowed the
|
|
810
|
+
// actionable expiry guidance below: the server starts the clock before the
|
|
811
|
+
// client computes `deadline`, so a login left unapproved almost always ends
|
|
812
|
+
// HERE rather than by the loop running out — which made that guidance
|
|
813
|
+
// unreachable in the normal case (#514 review).
|
|
814
|
+
const state = await deviceTerminalState(pollResponse);
|
|
815
|
+
if (state === "denied") {
|
|
816
|
+
throw new Error(`Device ${options.purpose} was declined in the browser. ` +
|
|
817
|
+
`Run "9pm ${options.purpose}" again if that was not intended.`);
|
|
818
|
+
}
|
|
819
|
+
if (state === "consumed") {
|
|
820
|
+
throw new Error(`Device ${options.purpose} code ${userCode} was already used. ` +
|
|
821
|
+
`Run "9pm ${options.purpose}" again to get a fresh code.`);
|
|
822
|
+
}
|
|
823
|
+
// "expired", and anything unrecognised: expiry is what a 410 means here by
|
|
824
|
+
// default, and the guidance is useful either way.
|
|
825
|
+
throw new Error(expiredDeviceAuthMessage(options.purpose, userCode, start.expires_in * 1000, start.verification_uri));
|
|
826
|
+
}
|
|
827
|
+
const poll = await expectJson(pollResponse, `poll device ${options.purpose}`);
|
|
828
|
+
// Retire the spinner the moment approval lands, BEFORE credential storage
|
|
829
|
+
// and the account lookup. storeCredential can fall back to plaintext and
|
|
830
|
+
// warn on stderr, and whoami is an awaited round trip — either would have
|
|
831
|
+
// its output appended to an unterminated spinner line, and the redraw timer
|
|
832
|
+
// could fire again underneath it, corrupting the tail of a *successful*
|
|
833
|
+
// login (#514 review). stop() is idempotent, so the finally below stays as
|
|
834
|
+
// the fallback for every other exit path.
|
|
835
|
+
progress.stop();
|
|
836
|
+
const storage = (options.storeCredentialFn ?? storeCredential)(poll.token, {
|
|
837
|
+
allowFileFallback: options.allowFileCredentialStore,
|
|
838
|
+
});
|
|
839
|
+
const me = await whoami({ apiUrl, token: poll.token, fetchFn });
|
|
840
|
+
return { email: me.user.email, storage };
|
|
782
841
|
}
|
|
783
|
-
const poll = await expectJson(pollResponse, `poll device ${options.purpose}`);
|
|
784
|
-
const storage = storeCredential(poll.token, { allowFileFallback: options.allowFileCredentialStore });
|
|
785
|
-
const me = await whoami({ apiUrl, token: poll.token, fetchFn });
|
|
786
|
-
return { email: me.user.email, storage };
|
|
787
842
|
}
|
|
788
|
-
|
|
843
|
+
finally {
|
|
844
|
+
progress.stop();
|
|
845
|
+
}
|
|
846
|
+
// Reached only when the client's own deadline elapses without the server having
|
|
847
|
+
// said anything terminal — rarer than the 410 route above, but it must say the
|
|
848
|
+
// same thing, so both share one message.
|
|
849
|
+
throw new Error(expiredDeviceAuthMessage(options.purpose, userCode, start.expires_in * 1000, start.verification_uri));
|
|
850
|
+
}
|
|
851
|
+
// The actionable timeout guidance, shared by both routes out of an unapproved
|
|
852
|
+
// device flow (the server's 410 and the client's own deadline) so they can never
|
|
853
|
+
// drift into saying different things.
|
|
854
|
+
export function expiredDeviceAuthMessage(purpose, userCode, lifetimeMs, verificationUri) {
|
|
855
|
+
return (`Device ${purpose} expired: code ${userCode} was not approved within ${formatRemainingForCli(lifetimeMs)}. ` +
|
|
856
|
+
`Run "9pm ${purpose}" again, then open the printed URL in any browser — on this machine or another — to approve it. ` +
|
|
857
|
+
`If no page ever loaded, check that ${verificationUri} is reachable from this network.`);
|
|
858
|
+
}
|
|
859
|
+
// Which terminal state the control plane meant by its 410. Returns "" when the
|
|
860
|
+
// body is not the expected envelope, so callers fall back rather than guess.
|
|
861
|
+
async function deviceTerminalState(response) {
|
|
862
|
+
const text = await response.text();
|
|
863
|
+
try {
|
|
864
|
+
const parsed = JSON.parse(text);
|
|
865
|
+
return typeof parsed.state === "string" ? parsed.state : "";
|
|
866
|
+
}
|
|
867
|
+
catch {
|
|
868
|
+
return "";
|
|
869
|
+
}
|
|
789
870
|
}
|
|
790
871
|
async function createProject(fetchFn, apiUrl, token, body) {
|
|
791
872
|
const response = await fetchFn(`${apiUrl}/v1/sites`, {
|
|
@@ -1149,7 +1230,8 @@ export async function uploadContainerImage(fetchFn, imagePath, upload, retry = C
|
|
|
1149
1230
|
await dispatcher.close().catch(() => { });
|
|
1150
1231
|
}
|
|
1151
1232
|
}
|
|
1152
|
-
|
|
1233
|
+
// Shared by every in-place stderr reporter in this file; do not duplicate.
|
|
1234
|
+
const CLI_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
1153
1235
|
export function createUploadProgressReporter(stream = process.stderr) {
|
|
1154
1236
|
const isTty = stream.isTTY === true;
|
|
1155
1237
|
let timer;
|
|
@@ -1167,7 +1249,7 @@ export function createUploadProgressReporter(stream = process.stderr) {
|
|
|
1167
1249
|
const redraw = () => {
|
|
1168
1250
|
if (!last)
|
|
1169
1251
|
return;
|
|
1170
|
-
stream.write(`\r\x1b[K${
|
|
1252
|
+
stream.write(`\r\x1b[K${CLI_SPINNER_FRAMES[frame % CLI_SPINNER_FRAMES.length]} ${label(last)}`);
|
|
1171
1253
|
};
|
|
1172
1254
|
const stopTimer = () => {
|
|
1173
1255
|
if (timer) {
|
|
@@ -1238,6 +1320,72 @@ export function createUploadProgressReporter(stream = process.stderr) {
|
|
|
1238
1320
|
},
|
|
1239
1321
|
};
|
|
1240
1322
|
}
|
|
1323
|
+
// Off-TTY ceiling: at most one line per 30s of the window (~20 lines over 600s).
|
|
1324
|
+
const DEVICE_AUTH_PROGRESS_LINE_INTERVAL_MS = 30_000;
|
|
1325
|
+
export function createDeviceAuthProgressReporter(stream = process.stderr) {
|
|
1326
|
+
const isTty = stream.isTTY === true;
|
|
1327
|
+
let timer;
|
|
1328
|
+
let frame = 0;
|
|
1329
|
+
let finished = false;
|
|
1330
|
+
let remainingMs;
|
|
1331
|
+
let lastLineRemainingMs;
|
|
1332
|
+
const label = (ms) => `Waiting for approval… ${formatRemainingForCli(ms)} left`;
|
|
1333
|
+
const redraw = () => {
|
|
1334
|
+
if (remainingMs === undefined)
|
|
1335
|
+
return;
|
|
1336
|
+
stream.write(`\r\x1b[K${CLI_SPINNER_FRAMES[frame % CLI_SPINNER_FRAMES.length]} ${label(remainingMs)}`);
|
|
1337
|
+
};
|
|
1338
|
+
const stopTimer = () => {
|
|
1339
|
+
if (timer) {
|
|
1340
|
+
clearInterval(timer);
|
|
1341
|
+
timer = undefined;
|
|
1342
|
+
}
|
|
1343
|
+
};
|
|
1344
|
+
return {
|
|
1345
|
+
tick(nextRemainingMs) {
|
|
1346
|
+
if (finished)
|
|
1347
|
+
return;
|
|
1348
|
+
const first = remainingMs === undefined;
|
|
1349
|
+
remainingMs = nextRemainingMs;
|
|
1350
|
+
if (isTty) {
|
|
1351
|
+
redraw();
|
|
1352
|
+
if (first) {
|
|
1353
|
+
// Sub-second liveness: the countdown itself only advances on a poll
|
|
1354
|
+
// boundary, which would look frozen for a whole interval at a time.
|
|
1355
|
+
timer = setInterval(() => {
|
|
1356
|
+
frame += 1;
|
|
1357
|
+
redraw();
|
|
1358
|
+
}, 120);
|
|
1359
|
+
// Never hold the process open on the reporter's account.
|
|
1360
|
+
if (typeof timer.unref === "function")
|
|
1361
|
+
timer.unref();
|
|
1362
|
+
}
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
if (lastLineRemainingMs !== undefined && lastLineRemainingMs - nextRemainingMs < DEVICE_AUTH_PROGRESS_LINE_INTERVAL_MS) {
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
lastLineRemainingMs = nextRemainingMs;
|
|
1369
|
+
stream.write(`${label(nextRemainingMs)}\n`);
|
|
1370
|
+
},
|
|
1371
|
+
// Idempotent; called from a finally around the poll loop. On a TTY it erases
|
|
1372
|
+
// the in-place line and writes nothing in its place: unlike the upload
|
|
1373
|
+
// reporter this deliberately leaves no ✓/✗ summary, because the flow's own
|
|
1374
|
+
// success line or its thrown error is the outcome, and a second line here
|
|
1375
|
+
// would be exactly the leftover progress noise #509 forbids. Off-TTY the
|
|
1376
|
+
// lines are already terminated, and before the first tick there is nothing
|
|
1377
|
+
// to clean up.
|
|
1378
|
+
stop() {
|
|
1379
|
+
if (finished)
|
|
1380
|
+
return;
|
|
1381
|
+
finished = true;
|
|
1382
|
+
stopTimer();
|
|
1383
|
+
if (isTty && remainingMs !== undefined) {
|
|
1384
|
+
stream.write("\r\x1b[K");
|
|
1385
|
+
}
|
|
1386
|
+
},
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1241
1389
|
function buildContainerImage(directory) {
|
|
1242
1390
|
const root = path.resolve(directory);
|
|
1243
1391
|
if (!existsSync(path.join(root, "Dockerfile"))) {
|
|
@@ -1291,6 +1439,25 @@ async function expectOk(response, action) {
|
|
|
1291
1439
|
const text = await response.text();
|
|
1292
1440
|
throw new Error(`Failed to ${action}: ${response.status} ${text}`);
|
|
1293
1441
|
}
|
|
1442
|
+
// Pull the control plane's own message out of its {error:{code,message}} envelope,
|
|
1443
|
+
// for the few refusals whose message is written for the tester and is the whole
|
|
1444
|
+
// point of the response (e.g. device signup being unavailable, #507). Dumping the
|
|
1445
|
+
// raw envelope the way expectOk does would bury it in JSON. Falls back to
|
|
1446
|
+
// expectOk's text when the body isn't that shape, so this can never lose detail.
|
|
1447
|
+
async function apiErrorMessage(response, action) {
|
|
1448
|
+
const text = await response.text();
|
|
1449
|
+
try {
|
|
1450
|
+
const parsed = JSON.parse(text);
|
|
1451
|
+
const message = parsed.error?.message;
|
|
1452
|
+
if (typeof message === "string" && message.trim()) {
|
|
1453
|
+
return message.trim();
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
catch {
|
|
1457
|
+
// Not JSON (proxy error page, empty body) — fall through to the raw form.
|
|
1458
|
+
}
|
|
1459
|
+
return `Failed to ${action}: ${response.status} ${text}`;
|
|
1460
|
+
}
|
|
1294
1461
|
// #374: Node's fetch rejects network-level failures (no HTTP response at all)
|
|
1295
1462
|
// as a generic "fetch failed" TypeError and stashes the real reason on
|
|
1296
1463
|
// `error.cause` (e.g. { code: "EPIPE" }). That code never reaches the user.
|
|
@@ -2538,10 +2705,45 @@ function deleteLinuxSecretServiceCredential() {
|
|
|
2538
2705
|
const result = spawnSync("secret-tool", ["clear", "service", TOKEN_SERVICE, "account", TOKEN_ACCOUNT], { encoding: "utf8" });
|
|
2539
2706
|
return result.status === 0;
|
|
2540
2707
|
}
|
|
2708
|
+
// Pure and exported so the per-platform mapping is unit-testable without
|
|
2709
|
+
// spawning anything (#509). A spawn error or a non-zero exit is a real failure.
|
|
2710
|
+
// A zero exit means the handler ran — except on Windows, where the launcher
|
|
2711
|
+
// returns 0 whether or not anything actually rendered the page, so "opened"
|
|
2712
|
+
// there would be a claim we cannot back: report "unknown" and say nothing about
|
|
2713
|
+
// the browser rather than mislead. The unconditional paste-the-URL hint covers
|
|
2714
|
+
// that case anyway.
|
|
2715
|
+
export function classifyBrowserOpen(input) {
|
|
2716
|
+
if (input.error !== undefined) {
|
|
2717
|
+
return "failed";
|
|
2718
|
+
}
|
|
2719
|
+
if (input.status !== 0) {
|
|
2720
|
+
return "failed";
|
|
2721
|
+
}
|
|
2722
|
+
return input.platform === "win32" ? "unknown" : "opened";
|
|
2723
|
+
}
|
|
2724
|
+
// Headless boxes, containers and remote SSH sessions — the cases where the
|
|
2725
|
+
// browser handoff is least likely to work, and where `xdg-open` often exits 0
|
|
2726
|
+
// having done nothing, so the absence of any display is the stronger signal.
|
|
2727
|
+
// Pure and exported for the same reason as classifyBrowserOpen: the branch has
|
|
2728
|
+
// to be assertable without a real platform or a real spawn. Note
|
|
2729
|
+
// noUncheckedIndexedAccess makes these `string | undefined`; an empty value is
|
|
2730
|
+
// as useless as an absent one, so test truthiness, not presence.
|
|
2731
|
+
export function hasNoGraphicalDisplay(platform, env) {
|
|
2732
|
+
return platform === "linux" && !env["DISPLAY"] && !env["WAYLAND_DISPLAY"];
|
|
2733
|
+
}
|
|
2541
2734
|
function openBrowser(url) {
|
|
2735
|
+
if (hasNoGraphicalDisplay(process.platform, process.env)) {
|
|
2736
|
+
return "failed";
|
|
2737
|
+
}
|
|
2542
2738
|
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
2543
2739
|
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
2544
|
-
|
|
2740
|
+
// stdio stays ignored: a browser's own chatter must not pollute the terminal.
|
|
2741
|
+
const result = spawnSync(command, args, { stdio: "ignore" });
|
|
2742
|
+
return classifyBrowserOpen({
|
|
2743
|
+
platform: process.platform,
|
|
2744
|
+
status: result.status,
|
|
2745
|
+
...(result.error ? { error: result.error } : {}),
|
|
2746
|
+
});
|
|
2545
2747
|
}
|
|
2546
2748
|
function sleep(ms) {
|
|
2547
2749
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -2576,6 +2778,16 @@ function formatBytesForCli(value) {
|
|
|
2576
2778
|
}
|
|
2577
2779
|
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}GB`;
|
|
2578
2780
|
}
|
|
2781
|
+
// Human remaining time for the device-auth wait (#509). Ceil so a window with
|
|
2782
|
+
// any time left never reads "0s", and clamp so a passed deadline reads "0s"
|
|
2783
|
+
// rather than a negative.
|
|
2784
|
+
function formatRemainingForCli(value) {
|
|
2785
|
+
const totalSeconds = Math.max(0, Math.ceil(value / 1000));
|
|
2786
|
+
if (totalSeconds < 60) {
|
|
2787
|
+
return `${totalSeconds}s`;
|
|
2788
|
+
}
|
|
2789
|
+
return `${Math.floor(totalSeconds / 60)}m ${totalSeconds % 60}s`;
|
|
2790
|
+
}
|
|
2579
2791
|
export class UsageError extends Error {
|
|
2580
2792
|
}
|
|
2581
2793
|
async function main() {
|