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 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.34.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.34.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
- openBrowser(start.verification_uri);
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
- while (Date.now() < deadline) {
770
- await sleep(intervalMs);
771
- const pollResponse = await fetchFn(`${apiUrl}/v1/oauth/device/poll`, {
772
- method: "POST",
773
- headers: { "content-type": "application/json" },
774
- body: JSON.stringify({ code: deviceCode }),
775
- });
776
- if (pollResponse.status === 400) {
777
- continue;
778
- }
779
- if (pollResponse.status === 410) {
780
- const body = await pollResponse.text();
781
- throw new Error(`Device ${options.purpose} expired or was denied: ${body}`);
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
- throw new Error(`Device ${options.purpose} expired before approval.`);
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
- const UPLOAD_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
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${UPLOAD_SPINNER_FRAMES[frame % UPLOAD_SPINNER_FRAMES.length]} ${label(last)}`);
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
- spawnSync(command, args, { stdio: "ignore" });
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() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ninepm",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "description": "CLI for deploying built apps to 9pm.ai.",
5
5
  "homepage": "https://9pm.ai",
6
6
  "keywords": [