ninepm 0.32.0 → 0.34.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.34.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;
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.34.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",
@@ -835,6 +879,14 @@ async function enableEndUserAuth(fetchFn, apiUrl, token, projectId) {
835
879
  });
836
880
  await expectJson(response, "enable end-user auth");
837
881
  }
882
+ async function enableAppEmail(fetchFn, apiUrl, token, projectId) {
883
+ const response = await fetchFn(`${apiUrl}/v1/sites/${encodeURIComponent(projectId)}/email/enable`, {
884
+ method: "POST",
885
+ headers: jsonHeaders(token),
886
+ body: "{}",
887
+ });
888
+ await expectJson(response, "enable app email");
889
+ }
838
890
  async function createDeployment(fetchFn, apiUrl, token, projectId, body) {
839
891
  const response = await fetchFn(`${apiUrl}/v1/sites/${encodeURIComponent(projectId)}/deployments`, {
840
892
  method: "POST",
@@ -1323,6 +1375,8 @@ const VALID_DEPLOY_FLAGS = new Set([
1323
1375
  "db",
1324
1376
  "with-auth",
1325
1377
  "auth",
1378
+ "with-email",
1379
+ "email",
1326
1380
  "name",
1327
1381
  "slug",
1328
1382
  "description",
@@ -1442,9 +1496,12 @@ export function parseArgs(argv) {
1442
1496
  directory,
1443
1497
  apiUrl: readApiUrl(flags, DEFAULT_API_URL),
1444
1498
  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"),
1499
+ // --with-auth implies a managed database (somewhere to store per-user data);
1500
+ // --with-email implies --with-auth (invitations need managed accounts), so it
1501
+ // pulls in the database too.
1502
+ withDatabase: flags.has("with-db") || flags.has("db") || flags.has("with-auth") || flags.has("auth") || flags.has("with-email") || flags.has("email"),
1503
+ withAuth: flags.has("with-auth") || flags.has("auth") || flags.has("with-email") || flags.has("email"),
1504
+ withEmail: flags.has("with-email") || flags.has("email"),
1448
1505
  check,
1449
1506
  };
1450
1507
  const name = readStringFlag(flags, "name");
@@ -2549,6 +2606,16 @@ async function main() {
2549
2606
  }
2550
2607
  return;
2551
2608
  }
2609
+ // Fail fast with actionable guidance when the API host is unreachable — a
2610
+ // default ("Trusted") cloud sandbox blocks it at CONNECT, and the deploy
2611
+ // would otherwise hang/error cryptically on the first request (#83/#258).
2612
+ // Real invocations only; a programmatic caller injecting fetchFn opts out.
2613
+ if (!command.options.fetchFn &&
2614
+ (await probeApiReachability(normalizeApiUrl(command.options.apiUrl), fetch)) === "blocked") {
2615
+ console.error(apiUnreachableGuidance(command.options.apiUrl));
2616
+ process.exitCode = 1;
2617
+ return;
2618
+ }
2552
2619
  let result;
2553
2620
  try {
2554
2621
  result = await deployDirectory(command.options);
@@ -2829,7 +2896,7 @@ async function runFilesCommand(command) {
2829
2896
  function printUsage() {
2830
2897
  console.log(`Usage:
2831
2898
  9pm --version
2832
- 9pm deploy [directory] --name <site-name> [--slug <slug>] [--description "<note>"] [--with-db] [--with-auth]
2899
+ 9pm deploy [directory] --name <site-name> [--slug <slug>] [--description "<note>"] [--with-db] [--with-auth] [--with-email]
2833
2900
  9pm deploy [directory] --runtime worker --entry src/index.js [--with-db]
2834
2901
  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
2902
  9pm deploy [directory] framework projects (Next.js, Vite) are detected and built automatically; --no-build skips
@@ -2877,6 +2944,7 @@ Container deploys:
2877
2944
  The app must listen on 0.0.0.0:$PORT or the selected --port. Use --health-path for a cheap readiness endpoint.
2878
2945
  With --with-db, containers receive NINEPM_SQL_URL and NINEPM_DB_BINDINGS for the managed SQL bridge.
2879
2946
  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).
2947
+ With --with-email, a signed-in end user can send app invitation emails; it implies --with-auth and works on public apps only.
2880
2948
  9pm env set <app> KEY=value [KEY2=value2 ...] [--var] (secret by default; --var = readable config)
2881
2949
  9pm env set <app> --from-file <path> [--var] (KEY=value lines; # comments, export prefix, quotes ok)
2882
2950
  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.34.0",
4
4
  "description": "CLI for deploying built apps to 9pm.ai.",
5
5
  "homepage": "https://9pm.ai",
6
6
  "keywords": [