ninepm 0.32.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
@@ -11,6 +11,7 @@ export interface DeployOptions {
11
11
  environment?: string;
12
12
  withDatabase?: boolean;
13
13
  withAuth?: boolean;
14
+ withEmail?: boolean;
14
15
  databaseName?: string;
15
16
  bindingName?: string;
16
17
  runtime?: AppRuntime;
@@ -112,7 +113,7 @@ export interface CreatedServiceToken {
112
113
  clientSecret: string;
113
114
  }
114
115
  export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
115
- export declare const CLI_VERSION = "0.32.0";
116
+ export declare const CLI_VERSION = "0.36.0";
116
117
  type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
117
118
  export interface CredentialDiagnostics {
118
119
  platform: NodeJS.Platform;
@@ -183,6 +184,9 @@ export declare class DeployError extends Error {
183
184
  constructor(message: string, slug: string | undefined);
184
185
  }
185
186
  export declare function deployDirectory(initialOptions: DeployOptions): Promise<DeployResult>;
187
+ export type ApiReachability = "reachable" | "blocked";
188
+ export declare function probeApiReachability(apiUrl: string, fetchFn: typeof fetch, timeoutMs?: number): Promise<ApiReachability>;
189
+ export declare function apiUnreachableGuidance(apiUrl: string): string;
186
190
  export declare function checkDeployDirectory(options: DeployOptions): Promise<DeployCheckResult>;
187
191
  export declare function whoami(options: ApiOptions): Promise<WhoamiResult>;
188
192
  export declare function publicWhoamiResult(result: WhoamiResult): PublicWhoamiResult;
@@ -240,12 +244,19 @@ export interface ListSitesResult {
240
244
  }
241
245
  export declare function listSites(options: ApiOptions): Promise<ListSitesResult>;
242
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
+ }
243
254
  export declare function loginDevice(options: {
244
255
  apiUrl: string;
245
256
  fetchFn?: FetchLike;
246
257
  machineName?: string;
247
258
  allowFileCredentialStore?: boolean;
248
- }): Promise<{
259
+ } & DeviceAuthFeedbackOptions): Promise<{
249
260
  email?: string;
250
261
  storage: string;
251
262
  }>;
@@ -254,10 +265,11 @@ export declare function signupDevice(options: {
254
265
  fetchFn?: FetchLike;
255
266
  machineName?: string;
256
267
  allowFileCredentialStore?: boolean;
257
- }): Promise<{
268
+ } & DeviceAuthFeedbackOptions): Promise<{
258
269
  email?: string;
259
270
  storage: string;
260
271
  }>;
272
+ export declare function expiredDeviceAuthMessage(purpose: string, userCode: string, lifetimeMs: number, verificationUri: string): string;
261
273
  export declare function listManagedFiles(options: FileCommandOptions): Promise<ManagedFileListResult>;
262
274
  export declare function putManagedFile(options: FilePutOptions): Promise<ManagedFilePutResult>;
263
275
  export declare function getManagedFile(options: FileGetOptions): Promise<{
@@ -290,6 +302,11 @@ export interface UploadProgressReporter {
290
302
  stop(): void;
291
303
  }
292
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;
293
310
  type CliCommand = {
294
311
  kind: "deploy";
295
312
  options: DeployOptions;
@@ -462,6 +479,13 @@ export declare function parseEnvFileEntries(content: string, kind: "secret" | "v
462
479
  kind: "secret" | "var";
463
480
  }>;
464
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;
465
489
  export declare class UsageError extends Error {
466
490
  }
467
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.32.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";
@@ -229,6 +229,44 @@ export async function deployDirectory(initialOptions) {
229
229
  throw new DeployError(error instanceof Error ? error.message : String(error), slugHolder.slug);
230
230
  }
231
231
  }
232
+ // Cheap unauthenticated probe of the control-plane API before a deploy. The
233
+ // sandbox egress proxy blocks at CONNECT, so ANY HTTP response (even a 5xx)
234
+ // proves the host is reachable — only a thrown/aborted request signals a block.
235
+ // A tight timeout keeps the reachable path fast and the blocked path from
236
+ // hanging.
237
+ export async function probeApiReachability(apiUrl, fetchFn,
238
+ // Comfortably above /health tail latency so a slow-but-reachable link isn't
239
+ // aborted and misread as blocked; the reachable path returns well under this.
240
+ timeoutMs = 5000) {
241
+ const controller = new AbortController();
242
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
243
+ try {
244
+ await fetchFn(`${apiUrl}/health`, { method: "GET", signal: controller.signal });
245
+ return "reachable";
246
+ }
247
+ catch {
248
+ return "blocked";
249
+ }
250
+ finally {
251
+ clearTimeout(timer);
252
+ }
253
+ }
254
+ // Actionable message when the API host can't be reached — the two fixes for a
255
+ // default ("Trusted") sandbox, matching the #258 byte-channel design.
256
+ export function apiUnreachableGuidance(apiUrl) {
257
+ let host = apiUrl;
258
+ try {
259
+ host = new URL(apiUrl).host;
260
+ }
261
+ catch {
262
+ // keep the raw value if it isn't a full URL
263
+ }
264
+ return (`Can't reach ${host}. Most often a default ("Trusted") cloud sandbox is blocking the host, ` +
265
+ `but it can also be a transient network issue or a ${host} outage (check status if the fixes below don't help). ` +
266
+ `If it's the sandbox, fix it either way:\n` +
267
+ ` 1. Add ${host} to your environment's Allowed domains (switch network access to Custom), then re-run.\n` +
268
+ ` 2. Or deploy through the 9pm MCP connector — your agent can enable it and deploy with the 9pm tools, no network change needed.`);
269
+ }
232
270
  async function runDeploy(initialOptions, slugHolder) {
233
271
  const options = prepareFrameworkDeploy(initialOptions);
234
272
  // #374: enrich network-level fetch rejections (EPIPE, header timeouts, …)
@@ -259,6 +297,9 @@ async function runDeploy(initialOptions, slugHolder) {
259
297
  if (options.withAuth) {
260
298
  await enableEndUserAuth(fetchFn, apiUrl, options.token, project.id);
261
299
  }
300
+ if (options.withEmail) {
301
+ await enableAppEmail(fetchFn, apiUrl, options.token, project.id);
302
+ }
262
303
  const created = await createDeployment(fetchFn, apiUrl, options.token, project.id, {
263
304
  files: [],
264
305
  environment: options.environment ?? "production",
@@ -385,6 +426,9 @@ async function runDeploy(initialOptions, slugHolder) {
385
426
  if (options.withAuth) {
386
427
  await enableEndUserAuth(fetchFn, apiUrl, options.token, project.id);
387
428
  }
429
+ if (options.withEmail) {
430
+ await enableAppEmail(fetchFn, apiUrl, options.token, project.id);
431
+ }
388
432
  const created = await createDeployment(fetchFn, apiUrl, options.token, project.id, {
389
433
  files: manifest,
390
434
  environment: options.environment ?? "production",
@@ -711,6 +755,12 @@ async function deviceAuthFlow(options) {
711
755
  headers: { "content-type": "application/json" },
712
756
  body: JSON.stringify({ machineName: options.machineName ?? os.hostname(), purpose: options.purpose }),
713
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
+ }
714
764
  const start = await expectJson(startResponse, `start device ${options.purpose}`);
715
765
  const userCode = start.user_code ?? start.code;
716
766
  const deviceCode = start.device_code;
@@ -719,29 +769,104 @@ async function deviceAuthFlow(options) {
719
769
  }
720
770
  console.log(`Open ${start.verification_uri}`);
721
771
  console.log(`Device code: ${userCode}`);
722
- 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");
723
786
  const deadline = Date.now() + start.expires_in * 1000;
724
787
  const intervalMs = Math.max(1, start.interval ?? 2) * 1000;
725
- while (Date.now() < deadline) {
726
- await sleep(intervalMs);
727
- const pollResponse = await fetchFn(`${apiUrl}/v1/oauth/device/poll`, {
728
- method: "POST",
729
- headers: { "content-type": "application/json" },
730
- body: JSON.stringify({ code: deviceCode }),
731
- });
732
- if (pollResponse.status === 400) {
733
- continue;
734
- }
735
- if (pollResponse.status === 410) {
736
- const body = await pollResponse.text();
737
- 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 };
738
841
  }
739
- const poll = await expectJson(pollResponse, `poll device ${options.purpose}`);
740
- const storage = storeCredential(poll.token, { allowFileFallback: options.allowFileCredentialStore });
741
- const me = await whoami({ apiUrl, token: poll.token, fetchFn });
742
- return { email: me.user.email, storage };
743
842
  }
744
- 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
+ }
745
870
  }
746
871
  async function createProject(fetchFn, apiUrl, token, body) {
747
872
  const response = await fetchFn(`${apiUrl}/v1/sites`, {
@@ -835,6 +960,14 @@ async function enableEndUserAuth(fetchFn, apiUrl, token, projectId) {
835
960
  });
836
961
  await expectJson(response, "enable end-user auth");
837
962
  }
963
+ async function enableAppEmail(fetchFn, apiUrl, token, projectId) {
964
+ const response = await fetchFn(`${apiUrl}/v1/sites/${encodeURIComponent(projectId)}/email/enable`, {
965
+ method: "POST",
966
+ headers: jsonHeaders(token),
967
+ body: "{}",
968
+ });
969
+ await expectJson(response, "enable app email");
970
+ }
838
971
  async function createDeployment(fetchFn, apiUrl, token, projectId, body) {
839
972
  const response = await fetchFn(`${apiUrl}/v1/sites/${encodeURIComponent(projectId)}/deployments`, {
840
973
  method: "POST",
@@ -1097,7 +1230,8 @@ export async function uploadContainerImage(fetchFn, imagePath, upload, retry = C
1097
1230
  await dispatcher.close().catch(() => { });
1098
1231
  }
1099
1232
  }
1100
- const UPLOAD_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1233
+ // Shared by every in-place stderr reporter in this file; do not duplicate.
1234
+ const CLI_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1101
1235
  export function createUploadProgressReporter(stream = process.stderr) {
1102
1236
  const isTty = stream.isTTY === true;
1103
1237
  let timer;
@@ -1115,7 +1249,7 @@ export function createUploadProgressReporter(stream = process.stderr) {
1115
1249
  const redraw = () => {
1116
1250
  if (!last)
1117
1251
  return;
1118
- 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)}`);
1119
1253
  };
1120
1254
  const stopTimer = () => {
1121
1255
  if (timer) {
@@ -1186,6 +1320,72 @@ export function createUploadProgressReporter(stream = process.stderr) {
1186
1320
  },
1187
1321
  };
1188
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
+ }
1189
1389
  function buildContainerImage(directory) {
1190
1390
  const root = path.resolve(directory);
1191
1391
  if (!existsSync(path.join(root, "Dockerfile"))) {
@@ -1239,6 +1439,25 @@ async function expectOk(response, action) {
1239
1439
  const text = await response.text();
1240
1440
  throw new Error(`Failed to ${action}: ${response.status} ${text}`);
1241
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
+ }
1242
1461
  // #374: Node's fetch rejects network-level failures (no HTTP response at all)
1243
1462
  // as a generic "fetch failed" TypeError and stashes the real reason on
1244
1463
  // `error.cause` (e.g. { code: "EPIPE" }). That code never reaches the user.
@@ -1323,6 +1542,8 @@ const VALID_DEPLOY_FLAGS = new Set([
1323
1542
  "db",
1324
1543
  "with-auth",
1325
1544
  "auth",
1545
+ "with-email",
1546
+ "email",
1326
1547
  "name",
1327
1548
  "slug",
1328
1549
  "description",
@@ -1442,9 +1663,12 @@ export function parseArgs(argv) {
1442
1663
  directory,
1443
1664
  apiUrl: readApiUrl(flags, DEFAULT_API_URL),
1444
1665
  token: token ?? "",
1445
- // --with-auth implies a managed database (somewhere to store per-user data).
1446
- withDatabase: flags.has("with-db") || flags.has("db") || flags.has("with-auth") || flags.has("auth"),
1447
- withAuth: flags.has("with-auth") || flags.has("auth"),
1666
+ // --with-auth implies a managed database (somewhere to store per-user data);
1667
+ // --with-email implies --with-auth (invitations need managed accounts), so it
1668
+ // pulls in the database too.
1669
+ withDatabase: flags.has("with-db") || flags.has("db") || flags.has("with-auth") || flags.has("auth") || flags.has("with-email") || flags.has("email"),
1670
+ withAuth: flags.has("with-auth") || flags.has("auth") || flags.has("with-email") || flags.has("email"),
1671
+ withEmail: flags.has("with-email") || flags.has("email"),
1448
1672
  check,
1449
1673
  };
1450
1674
  const name = readStringFlag(flags, "name");
@@ -2481,10 +2705,45 @@ function deleteLinuxSecretServiceCredential() {
2481
2705
  const result = spawnSync("secret-tool", ["clear", "service", TOKEN_SERVICE, "account", TOKEN_ACCOUNT], { encoding: "utf8" });
2482
2706
  return result.status === 0;
2483
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
+ }
2484
2734
  function openBrowser(url) {
2735
+ if (hasNoGraphicalDisplay(process.platform, process.env)) {
2736
+ return "failed";
2737
+ }
2485
2738
  const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
2486
2739
  const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
2487
- 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
+ });
2488
2747
  }
2489
2748
  function sleep(ms) {
2490
2749
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -2519,6 +2778,16 @@ function formatBytesForCli(value) {
2519
2778
  }
2520
2779
  return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}GB`;
2521
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
+ }
2522
2791
  export class UsageError extends Error {
2523
2792
  }
2524
2793
  async function main() {
@@ -2549,6 +2818,16 @@ async function main() {
2549
2818
  }
2550
2819
  return;
2551
2820
  }
2821
+ // Fail fast with actionable guidance when the API host is unreachable — a
2822
+ // default ("Trusted") cloud sandbox blocks it at CONNECT, and the deploy
2823
+ // would otherwise hang/error cryptically on the first request (#83/#258).
2824
+ // Real invocations only; a programmatic caller injecting fetchFn opts out.
2825
+ if (!command.options.fetchFn &&
2826
+ (await probeApiReachability(normalizeApiUrl(command.options.apiUrl), fetch)) === "blocked") {
2827
+ console.error(apiUnreachableGuidance(command.options.apiUrl));
2828
+ process.exitCode = 1;
2829
+ return;
2830
+ }
2552
2831
  let result;
2553
2832
  try {
2554
2833
  result = await deployDirectory(command.options);
@@ -2829,7 +3108,7 @@ async function runFilesCommand(command) {
2829
3108
  function printUsage() {
2830
3109
  console.log(`Usage:
2831
3110
  9pm --version
2832
- 9pm deploy [directory] --name <site-name> [--slug <slug>] [--description "<note>"] [--with-db] [--with-auth]
3111
+ 9pm deploy [directory] --name <site-name> [--slug <slug>] [--description "<note>"] [--with-db] [--with-auth] [--with-email]
2833
3112
  9pm deploy [directory] --runtime worker --entry src/index.js [--with-db]
2834
3113
  9pm deploy [directory] --runtime container --port 8080 [--health-path /health] [--region auto] [--with-db] [--always-on] [--volume <gb> | --no-volume] [--cpus <n>] [--memory <size>]
2835
3114
  9pm deploy [directory] framework projects (Next.js, Vite) are detected and built automatically; --no-build skips
@@ -2877,6 +3156,7 @@ Container deploys:
2877
3156
  The app must listen on 0.0.0.0:$PORT or the selected --port. Use --health-path for a cheap readiness endpoint.
2878
3157
  With --with-db, containers receive NINEPM_SQL_URL and NINEPM_DB_BINDINGS for the managed SQL bridge.
2879
3158
  With --with-auth, the app gets 9pm-managed end-user accounts (email one-time codes); it implies --with-db and is one-way (enable-only).
3159
+ With --with-email, a signed-in end user can send app invitation emails; it implies --with-auth and works on public apps only.
2880
3160
  9pm env set <app> KEY=value [KEY2=value2 ...] [--var] (secret by default; --var = readable config)
2881
3161
  9pm env set <app> --from-file <path> [--var] (KEY=value lines; # comments, export prefix, quotes ok)
2882
3162
  9pm env set <app> <NAME> --stdin [--var] (value read verbatim from stdin)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ninepm",
3
- "version": "0.32.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": [