ninepm 0.61.0 → 0.65.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/README.md CHANGED
@@ -21,6 +21,8 @@ Authenticate in your own browser. The CLI stores the token in native protected s
21
21
  9pm doctor
22
22
  ```
23
23
 
24
+ Running `9pm login` again keeps a credential that still works instead of minting another; `9pm login --fresh` replaces it and revokes the old one.
25
+
24
26
  For CI, set `NINEPM_API_KEY` yourself in the CI secret store. `--token` and `NINEPM_API_KEY` still override the stored login token.
25
27
 
26
28
  A key for CI or a server is minted from a signed-in machine with `9pm api-key create --name <name>`; the token is printed once. `9pm api-key ls` lists your active keys and `9pm api-key revoke <key-id>` retires one. Each account holds at most 10 such keys at a time (login tokens do not count). Store the key under any name but `NINEPM_*` when it lives in a 9pm app's own environment, and pass it with `--token`.
@@ -99,11 +101,13 @@ Share links are **owner-only by default**: the link opens only for you, signed i
99
101
  Deploy a first-class Worker runtime app:
100
102
 
101
103
  ```sh
102
- 9pm deploy ./examples/worker-app --name worker-app --slug worker-app --runtime worker --entry src/index.js --with-db
104
+ 9pm deploy ./examples/worker-app --name worker-app --slug worker-app --runtime worker --entry src/index.js --assets public --with-db
103
105
  ```
104
106
 
105
107
  The `--runtime worker` entry must be a bundled fetch-compatible JavaScript module with a default export. With `--with-db`, the app receives `env.DB` and should use SQLite-compatible prepared statements.
106
108
 
109
+ Choose which files are public with `--assets <dir>`: only the files under that directory are served, from the app's root (`public/index.html` answers on `/`), and nothing else in the deploy directory is uploaded. Use `--no-assets` when the entry serves every response itself. The app remembers the choice, so later deploys need not repeat the flag; pass the other flag, or another directory, to change it — there is no way back to publishing everything. A worker app that has never chosen serves **every file in the deploy directory except the entry** publicly, server helpers and leftovers like `index.js.orig` included; every deploy of such an app prints a warning naming those files, and `9pm deploy --check` lists every file a deploy would make public.
110
+
107
111
  Deploy a container app from a local Docker build:
108
112
 
109
113
  ```sh
@@ -179,10 +183,10 @@ Service token secrets returned by `access service create` are also server-side s
179
183
  ```sh
180
184
  9pm --version
181
185
  9pm deploy [directory] --name <name> [--slug <slug>] [--with-db]
182
- 9pm deploy [directory] --check [--runtime worker --entry <path>]
186
+ 9pm deploy [directory] --check [--runtime worker --entry <path> (--assets <dir> | --no-assets)]
183
187
  9pm deploy [directory] --runtime container --port 8080 [--health-path /health] [--region auto] [--with-db]
184
188
  9pm signup
185
- 9pm login
189
+ 9pm login [--fresh]
186
190
  9pm logout [--api-url <url>] [--local]
187
191
  9pm doctor [--json]
188
192
  9pm whoami
@@ -193,7 +197,7 @@ Service token secrets returned by `access service create` are also server-side s
193
197
  9pm files get <file-id> [--output <path>] [--project <site>]
194
198
  9pm files share <file-id> [--expires 24h] [--public] [--project <site>]
195
199
  9pm files delete <file-id> [--project <site>]
196
- 9pm deploy [directory] --runtime worker --entry <path> [--with-db]
200
+ 9pm deploy [directory] --runtime worker --entry <path> (--assets <dir> | --no-assets) [--with-db]
197
201
  9pm delete <site> --confirm <slug>
198
202
  9pm access show <site>
199
203
  9pm access public <site>
@@ -222,6 +226,8 @@ Options:
222
226
  - `--environment <name>` sets deployment environment metadata
223
227
  - `--runtime <static|worker|container>` selects static asset hosting, app-owned Worker code, or local Docker container deployment
224
228
  - `--entry <path>` selects the Worker runtime server entry file
229
+ - `--assets <dir>` makes only that directory of a worker deploy public (remembered for later deploys)
230
+ - `--no-assets` makes no file of a worker deploy public (remembered for later deploys)
225
231
  - `--port <port>` selects the HTTP port exposed by a container app
226
232
  - `--health-path <path>` optionally lets 9pm.ai check container health before marking it healthy
227
233
  - `--region <region>` optionally selects provider-neutral container placement
@@ -15,6 +15,7 @@ export interface Project {
15
15
  activeDeploymentId: string | null;
16
16
  createdAt: string;
17
17
  updatedAt: string;
18
+ workerPublicAssets?: string | null;
18
19
  }
19
20
  export interface AppUserFilesUsage {
20
21
  usedBytes: number;
@@ -47,8 +47,9 @@ export function assetFileTooLargeMessage(filePath, size) {
47
47
  export function oversizedAssets(files) {
48
48
  return files.filter((file) => file.size > MAX_STATIC_ASSET_FILE_BYTES);
49
49
  }
50
- // The worker-runtime server entry is the one deploy-directory file inlined into
51
- // the generated Worker script (`buildHostedAppWorker`), so it — unlike every
50
+ // The worker-runtime server entry is the one deploy-directory file uploaded as part
51
+ // of the generated Worker script (`buildHostedAppWorker`, as its own module beside the
52
+ // wrapper's main module), so it — unlike every
52
53
  // other file, which rides the Workers Assets API — counts against the runtime's
53
54
  // script-size budget. That budget is enforced on the *compressed* upload, so
54
55
  // this limit is measured on gzipped bytes, not raw ones. Measuring raw bytes is
@@ -68,7 +69,8 @@ export const MAX_WORKER_SCRIPT_COMPRESSED_BYTES = 10 * 1_000_000;
68
69
  // generates is uploaded as part of the same script. Two consumers spend this reserve,
69
70
  // depending on the deploy shape:
70
71
  // - single `--entry` workers: `buildHostedAppWorker` (analytics, end-user auth,
71
- // Access/vanity-host checks, asset fallback), inlined around the entry;
72
+ // Access/vanity-host checks, asset fallback), uploaded as the script's main module
73
+ // with the entry as a separate module it imports - the same script, so the same budget;
72
74
  // - `--bundle` deploys: `buildBundleWrapperModule`, added as the script's main module
73
75
  // alongside the bundle's own modules.
74
76
  // Both were measured, and both are near-flat across feature combinations: the hosted
package/dist/index.d.ts CHANGED
@@ -17,6 +17,7 @@ export interface DeployOptions {
17
17
  bindingName?: string;
18
18
  runtime?: AppRuntime;
19
19
  entry?: string;
20
+ publicAssets?: string;
20
21
  port?: number;
21
22
  healthPath?: string;
22
23
  region?: ContainerRegion;
@@ -116,7 +117,7 @@ export interface CreatedServiceToken {
116
117
  clientSecret: string;
117
118
  }
118
119
  export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
119
- export declare const CLI_VERSION = "0.61.0";
120
+ export declare const CLI_VERSION = "0.65.0";
120
121
  type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
121
122
  export interface StoredCredentialEntry {
122
123
  token: string;
@@ -186,6 +187,10 @@ export interface DeployCheckResult {
186
187
  compressedBytes: number;
187
188
  moduleCount: number;
188
189
  };
190
+ publicFiles?: Array<{
191
+ path: string;
192
+ url: string;
193
+ }>;
189
194
  }
190
195
  export type DetectedFramework = "nextjs" | "astro" | "sveltekit" | "vite";
191
196
  export declare function detectFramework(directory: string): DetectedFramework | null;
@@ -329,30 +334,94 @@ export declare function listSites(options: ApiOptions & {
329
334
  export declare function deleteSite(options: DeleteSiteOptions): Promise<DeletedSiteResult>;
330
335
  export interface DeviceAuthFeedbackOptions {
331
336
  progressStream?: NodeJS.WriteStream;
332
- openBrowserFn?: (url: string) => BrowserOpenResult;
337
+ openBrowserFn?: (url: string) => BrowserOpenResult | Promise<BrowserOpenResult>;
333
338
  storeCredentialFn?: (token: string, options: {
334
339
  allowFileFallback?: boolean | undefined;
335
340
  apiUrl?: string | undefined;
336
341
  }) => string;
342
+ inputStream?: NodeJS.ReadStream;
343
+ signalTarget?: Pick<NodeJS.EventEmitter, "once" | "removeListener">;
344
+ }
345
+ export declare class DeviceAuthCancelledError extends Error {
346
+ readonly purpose: "login" | "signup";
347
+ constructor(purpose: "login" | "signup");
348
+ }
349
+ export declare const CANCELLED_EXIT_CODE = 130;
350
+ export type ReplacedCredential = {
351
+ kind: "revoked";
352
+ apiUrl: string;
353
+ } | {
354
+ kind: "already_invalid";
355
+ apiUrl: string;
356
+ } | {
357
+ kind: "failed";
358
+ apiUrl: string;
359
+ reason: string;
360
+ } | {
361
+ kind: "unknown_issuer";
362
+ };
363
+ export interface DeviceAuthResult {
364
+ email?: string;
365
+ storage: string;
366
+ replaced: ReplacedCredential[];
367
+ trimmedLoginKeys?: {
368
+ count: number;
369
+ limit: number;
370
+ };
337
371
  }
338
372
  export declare function loginDevice(options: {
339
373
  apiUrl: string;
340
374
  fetchFn?: FetchLike;
341
375
  machineName?: string;
342
376
  allowFileCredentialStore?: boolean;
343
- } & DeviceAuthFeedbackOptions): Promise<{
344
- email?: string;
345
- storage: string;
346
- }>;
377
+ } & DeviceAuthFeedbackOptions): Promise<DeviceAuthResult>;
347
378
  export declare function signupDevice(options: {
348
379
  apiUrl: string;
349
380
  fetchFn?: FetchLike;
350
381
  machineName?: string;
351
382
  allowFileCredentialStore?: boolean;
352
- } & DeviceAuthFeedbackOptions): Promise<{
383
+ } & DeviceAuthFeedbackOptions): Promise<DeviceAuthResult>;
384
+ /**
385
+ * Revoke every credential this login's write pushed out of the local stores (#1779).
386
+ *
387
+ * "Pushed out" means present before the write and absent after it - not merely
388
+ * different from the new token. A copy still sitting in another store (a plaintext
389
+ * fallback beside the keychain) is still reachable from this machine, and a process
390
+ * that skips the keychain may be using it, so it is left for `9pm logout`.
391
+ *
392
+ * Each key is revoked at the control plane that ISSUED it, which is not necessarily
393
+ * the one this login talked to: a production login overwrites a staging key in the
394
+ * same slot, and only staging can revoke that. A key with no recorded issuer is not
395
+ * sent anywhere. Never throws - the login has already succeeded.
396
+ */
397
+ export declare function retireReplacedCredentials(before: StoredCredentialEntry[], after: StoredCredentialEntry[], newToken: string, fetchFn?: FetchLike): Promise<ReplacedCredential[]>;
398
+ /** The lines a login prints about what it retired, for stderr. Silent when nothing needs saying. */
399
+ export declare function replacedCredentialMessages(result: Pick<DeviceAuthResult, "replaced" | "trimmedLoginKeys">, loginApiUrl: string): string[];
400
+ export type StoredLoginProbe = {
401
+ kind: "reuse";
353
402
  email?: string;
354
- storage: string;
355
- }>;
403
+ } | {
404
+ kind: "mint";
405
+ } | {
406
+ kind: "unverifiable";
407
+ reason: string;
408
+ };
409
+ /**
410
+ * Whether `9pm login` can keep the credential this machine already holds (#1779).
411
+ *
412
+ * Only the credential commands actually authenticate with is considered. One recorded
413
+ * as issued by another control plane is never sent here. One with no recorded issuer
414
+ * (stored before 0.42) IS probed here: keys are per database, so a 200 from the plane
415
+ * this login targets proves it was issued there, and leaving those to mint would keep
416
+ * the oldest CLIs' machines minting a stray key per run. Only the control plane's own
417
+ * refusal proves a key dead; a proxy's 401, a 5xx or a timeout proves nothing, so
418
+ * those come back as unverifiable rather than as permission to mint.
419
+ */
420
+ export declare function probeStoredLogin(options: {
421
+ apiUrl: string;
422
+ fetchFn?: FetchLike;
423
+ stored?: StoredCredentialEntry[];
424
+ }): Promise<StoredLoginProbe>;
356
425
  export declare function expiredDeviceAuthMessage(purpose: string, userCode: string, lifetimeMs: number, verificationUri: string): string;
357
426
  export declare function listManagedFiles(options: FileCommandOptions): Promise<ManagedFileListResult>;
358
427
  export declare function getAppUserFilesUsage(options: ApiOptions & {
@@ -391,9 +460,12 @@ export interface UploadProgressReporter {
391
460
  export declare function createUploadProgressReporter(stream?: NodeJS.WriteStream): UploadProgressReporter;
392
461
  export interface DeviceAuthProgressReporter {
393
462
  tick(remainingMs: number): void;
463
+ note(line: string): void;
394
464
  stop(): void;
395
465
  }
396
- export declare function createDeviceAuthProgressReporter(stream?: NodeJS.WriteStream): DeviceAuthProgressReporter;
466
+ export declare function createDeviceAuthProgressReporter(stream?: NodeJS.WriteStream, options?: {
467
+ hint?: string;
468
+ }): DeviceAuthProgressReporter;
397
469
  export declare function noteCliOutdatedAdvisory(response: Response, log?: (line: string) => void): void;
398
470
  export declare function resetCliOutdatedAdvisoryForTests(): void;
399
471
  export declare function dashboardUrlForApiUrl(apiUrl: string): string;
@@ -406,6 +478,7 @@ type CliCommand = {
406
478
  kind: "login";
407
479
  apiUrl: string;
408
480
  allowFileCredentialStore: boolean;
481
+ fresh: boolean;
409
482
  } | {
410
483
  kind: "signup";
411
484
  apiUrl: string;
@@ -592,6 +665,12 @@ export declare function parseEnvFileEntries(content: string, kind: "secret" | "v
592
665
  kind: "secret" | "var";
593
666
  }>;
594
667
  export declare function healthWithReason(status: string | null | undefined, reason: string | null | undefined): string;
668
+ export declare function workerPublicFiles(paths: readonly string[], serverEntry: string, selection: string | null): Array<{
669
+ path: string;
670
+ url: string;
671
+ }>;
672
+ export declare function legacyPublicAssetsWarning(serverEntry: string, publicPaths: readonly string[]): string;
673
+ export declare function privateLookingPublicWarning(paths: readonly string[], selection: string | null): string;
595
674
  export declare function storedCredentialTokens(): StoredCredentialEntry[];
596
675
  /**
597
676
  * Whether a credential provably cannot be stored, decided WITHOUT writing one (#859).
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // AUTO-GENERATED by scripts/generate-cli-package.mjs from the in-repo CLI source -- do not edit.
3
3
  // Edit src/cli or src/shared instead; this is regenerated on typecheck/test/build/pack.
4
- import { spawnSync } from "node:child_process";
4
+ import { spawn, spawnSync } from "node:child_process";
5
5
  import { createHash } from "node:crypto";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
@@ -14,8 +14,9 @@ import { MAX_DEPLOY_DESCRIPTION_LENGTH, normalizeDeployDescription } from "./dep
14
14
  import { managedHostnamesIn, siblingCallWarning, siblingsOf } from "./sibling-hostnames.js";
15
15
  import { MAX_STATIC_ASSET_FILE_BYTES, MAX_WORKER_ENTRY_COMPRESSED_BYTES, MAX_WORKER_SCRIPT_COMPRESSED_BYTES, WORKER_ENTRY_COMPRESSED_WARN_BYTES, assetFileTooLargeMessage, formatMebibytes, formatMegabytes, oversizedAssets, workerBundleNearLimitMessage, workerBundleTooLargeMessage, workerEntryNearLimitMessage, workerEntryTooLargeMessage, } from "./deploy-limits.js";
16
16
  import { buildFileManifest, readManifestFile } from "./manifest.js";
17
+ import { NO_PUBLIC_ASSETS, normalizeAssetsDirectory, privateLookingPaths, publicAssetPath } from "./public-assets.js";
17
18
  import { API_KEY_HELP, API_URL_HELP, CLI_CLIENT_ID, DEFAULT_API_URL, reportCliError } from "./cli-config.js";
18
- export const CLI_VERSION = "0.61.0";
19
+ export const CLI_VERSION = "0.65.0";
19
20
  const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
20
21
  const TOKEN_SERVICE = "9pm";
21
22
  const TOKEN_ACCOUNT = "default";
@@ -466,12 +467,25 @@ async function runDeploy(initialOptions, slugHolder) {
466
467
  if (serverEntry && !manifest.some((file) => file.path === serverEntry)) {
467
468
  throw new Error(`Worker runtime entry file was not found in the deploy directory: ${serverEntry}`);
468
469
  }
470
+ if (serverEntry &&
471
+ options.publicAssets &&
472
+ options.publicAssets !== NO_PUBLIC_ASSETS &&
473
+ workerPublicFiles(manifest.map((file) => file.path), serverEntry, options.publicAssets).length === 0) {
474
+ throw new Error(missingAssetsDirectoryMessage(options.publicAssets));
475
+ }
469
476
  // Reject oversized assets before creating the project (#195): the server's size
470
477
  // guard only fires on the later create-deployment call, which would otherwise
471
478
  // leave an orphaned no-deploy app holding the slug and a Sites quota slot.
472
- const [oversized] = oversizedAssets(manifest);
473
- if (oversized) {
474
- throw new Error(assetFileTooLargeMessage(oversized.path, oversized.size));
479
+ // Only files this deploy uploads are held to the limit (#1847): a private file outside
480
+ // the public directory is never sent. A worker deploy without a flag cannot know its
481
+ // selection until the project resolves, so its check runs after that instead - a first
482
+ // deploy it refuses is still rolled back by deployDirectory.
483
+ const selectionKnown = !serverEntry || options.publicAssets !== undefined;
484
+ if (selectionKnown) {
485
+ const [oversized] = oversizedAssets(uploadedFiles(manifest, serverEntry, options.publicAssets ?? null));
486
+ if (oversized) {
487
+ throw new Error(assetFileTooLargeMessage(oversized.path, oversized.size));
488
+ }
475
489
  }
476
490
  // Same worker-entry budget `--check` applies, on the same compressed dimension, so
477
491
  // the two paths agree (#645). This rejects only what the runtime would refuse at
@@ -539,6 +553,38 @@ async function runDeploy(initialOptions, slugHolder) {
539
553
  if (siblings.length > 0) {
540
554
  console.warn(`Warning: ${siblingCallWarning(siblings)}`);
541
555
  }
556
+ // #1847: after the project resolves, because a choice made on an earlier deploy is the
557
+ // project's, and the control plane applies it whether or not this deploy repeats it.
558
+ if (serverEntry) {
559
+ const selection = options.publicAssets ?? project.workerPublicAssets ?? null;
560
+ const publicFiles = workerPublicFiles(manifest.map((file) => file.path), serverEntry, selection);
561
+ if (selection === null) {
562
+ // Nothing beside the entry means nothing leaks, so there is nothing to warn about.
563
+ if (publicFiles.length > 0) {
564
+ console.warn(`Warning: ${legacyPublicAssetsWarning(serverEntry, publicFiles.map((file) => file.path))}`);
565
+ }
566
+ }
567
+ else {
568
+ if (options.publicAssets === undefined) {
569
+ const chosen = selection === NO_PUBLIC_ASSETS ? "no public files" : `only ${selection} is public`;
570
+ console.warn(`Note: ${chosen}, as chosen on an earlier deploy of this app. Pass --assets <dir> or --no-assets to change it.`);
571
+ }
572
+ // Files the platform will never serve are not sent at all: the entry is the only
573
+ // server file it reads, so nothing else outside the public directory leaves this machine.
574
+ manifest = uploadedFiles(manifest, serverEntry, selection);
575
+ }
576
+ const suspicious = privateLookingPaths(publicFiles.map((file) => file.path), serverEntry);
577
+ if (suspicious.length > 0) {
578
+ console.warn(`Warning: ${privateLookingPublicWarning(suspicious, selection)}`);
579
+ }
580
+ }
581
+ if (!selectionKnown) {
582
+ // `manifest` is already narrowed to what this deploy uploads.
583
+ const [oversized] = oversizedAssets(manifest);
584
+ if (oversized) {
585
+ throw new Error(assetFileTooLargeMessage(oversized.path, oversized.size));
586
+ }
587
+ }
542
588
  const database = options.withDatabase
543
589
  ? await createDatabase(fetchFn, apiUrl, options.token, project.id, {
544
590
  name: options.databaseName ?? `${project.slug}-db`,
@@ -564,6 +610,7 @@ async function runDeploy(initialOptions, slugHolder) {
564
610
  runtime,
565
611
  serverEntry: serverEntry ?? null,
566
612
  ...(bundle ? { bundle } : {}),
613
+ ...(serverEntry && options.publicAssets ? { publicAssets: options.publicAssets } : {}),
567
614
  },
568
615
  });
569
616
  let uploaded = 0;
@@ -595,10 +642,11 @@ async function runDeploy(initialOptions, slugHolder) {
595
642
  };
596
643
  }
597
644
  // Measure the worker-runtime entry the way the platform's script-size budget is
598
- // actually applied: the entry is inlined into the generated Worker script, and the
599
- // budget is enforced against the compressed upload. gzipped bytes are therefore
600
- // the comparable figure; the raw size is carried alongside for the error message
601
- // only, so a report can name both dimensions instead of silently mixing them.
645
+ // actually applied: the entry is uploaded as its own module in the same Worker
646
+ // script, and the budget applies to the compressed upload of that whole script.
647
+ // gzipped bytes are therefore the comparable figure; the raw size is carried
648
+ // alongside for the error message only, so a report can name both dimensions
649
+ // instead of silently mixing them.
602
650
  //
603
651
  // Deliberately zlib's default level rather than 9: the platform's own compression
604
652
  // settings are unknown, and a weaker setting yields a slightly *larger* figure, so
@@ -729,8 +777,9 @@ export async function checkDeployDirectory(options) {
729
777
  warnings.push("Static deploy does not contain index.html.");
730
778
  }
731
779
  // Per-file size limit, matching the server's create-time guard (#194), so an
732
- // oversized asset is caught locally instead of failing with a 413 mid-deploy.
733
- for (const file of oversizedAssets(manifest)) {
780
+ // oversized asset is caught locally instead of failing with a 413 mid-deploy. Held to
781
+ // the files this deploy would upload under its flags (#1847), as the deploy is.
782
+ for (const file of oversizedAssets(uploadedFiles(manifest, serverEntry, options.publicAssets ?? null))) {
734
783
  errors.push(assetFileTooLargeMessage(file.path, file.size));
735
784
  }
736
785
  if (serverEntry) {
@@ -760,6 +809,23 @@ export async function checkDeployDirectory(options) {
760
809
  // people to stop reading it.
761
810
  }
762
811
  }
812
+ // #1847: `--check` has no project, so it cannot see a choice made on an earlier deploy;
813
+ // it reports what this invocation's flags would publish, and says so for the legacy case.
814
+ let publicFiles;
815
+ if (serverEntry) {
816
+ const selection = options.publicAssets ?? null;
817
+ publicFiles = workerPublicFiles(manifest.map((file) => file.path), serverEntry, selection);
818
+ if (selection !== null && selection !== NO_PUBLIC_ASSETS && publicFiles.length === 0) {
819
+ errors.push(missingAssetsDirectoryMessage(selection));
820
+ }
821
+ if (selection === null && publicFiles.length > 0) {
822
+ warnings.push(`${legacyPublicAssetsWarning(serverEntry, publicFiles.map((file) => file.path))}\n (An app that already chose on an earlier deploy keeps that choice.)`);
823
+ }
824
+ const suspicious = privateLookingPaths(publicFiles.map((file) => file.path), serverEntry);
825
+ if (suspicious.length > 0) {
826
+ warnings.push(privateLookingPublicWarning(suspicious, selection));
827
+ }
828
+ }
763
829
  // Same server-code budget the real deploy applies to a bundle (#660), measured the same
764
830
  // way — all modules compressed together, assets excluded.
765
831
  if (bundle) {
@@ -773,7 +839,15 @@ export async function checkDeployDirectory(options) {
773
839
  warnings.push(workerBundleNearLimitMessage(modulePaths.length, rawBytes, compressedBytes));
774
840
  }
775
841
  }
776
- return { ok: errors.length === 0, warnings, errors, files: manifest.length, bytes: totalBytes, ...(serverCode ? { serverCode } : {}) };
842
+ return {
843
+ ok: errors.length === 0,
844
+ warnings,
845
+ errors,
846
+ files: manifest.length,
847
+ bytes: totalBytes,
848
+ ...(serverCode ? { serverCode } : {}),
849
+ ...(publicFiles ? { publicFiles } : {}),
850
+ };
777
851
  }
778
852
  export async function whoami(options) {
779
853
  return apiRequest(options, "/v1/me", { method: "GET" });
@@ -1258,6 +1332,21 @@ export async function deleteSite(options) {
1258
1332
  });
1259
1333
  return payload.deleted;
1260
1334
  }
1335
+ // Thrown out of the device flow when the person pressed Ctrl-C while it waited
1336
+ // (#1902). It travels to main() like any other failure, so every `finally` on the
1337
+ // way has already run when the process sets its exit code; exiting from inside the
1338
+ // signal handler would skip them and leave stdin holding the process open.
1339
+ export class DeviceAuthCancelledError extends Error {
1340
+ purpose;
1341
+ constructor(purpose) {
1342
+ super(`Cancelled. Run "9pm ${purpose}" to start again.`);
1343
+ this.name = "DeviceAuthCancelledError";
1344
+ this.purpose = purpose;
1345
+ }
1346
+ }
1347
+ // The exit status of a command the person interrupted: 128 + SIGINT, which is what
1348
+ // a shell reports for one, so a script can tell a cancel from a failure.
1349
+ export const CANCELLED_EXIT_CODE = 130;
1261
1350
  export async function loginDevice(options) {
1262
1351
  return deviceAuthFlow({ ...options, purpose: "login" });
1263
1352
  }
@@ -1316,7 +1405,7 @@ async function deviceAuthFlow(options) {
1316
1405
  // lines above plus the caller's unchanged result line, so anything already
1317
1406
  // reading it is unaffected.
1318
1407
  const progressStream = options.progressStream ?? process.stderr;
1319
- const browser = (options.openBrowserFn ?? openBrowser)(start.verification_uri);
1408
+ const browser = await (options.openBrowserFn ?? openBrowser)(start.verification_uri);
1320
1409
  if (browser === "failed") {
1321
1410
  // Say it the moment it happens. A swallowed browser failure used to look
1322
1411
  // exactly like a flow nobody had approved yet.
@@ -1328,16 +1417,83 @@ async function deviceAuthFlow(options) {
1328
1417
  progressStream.write("Open the URL above in any browser — on this machine or another — to approve this device.\n");
1329
1418
  const deadline = Date.now() + start.expires_in * 1000;
1330
1419
  const intervalMs = Math.max(1, start.interval ?? 2) * 1000;
1420
+ // #1902: the way out of the wait, said on the line itself. Enter is offered only
1421
+ // when it works - stdin a terminal to read it from, stderr a terminal to answer on
1422
+ // - and is read in the terminal's normal line mode, so no terminal setting changes
1423
+ // and Ctrl-C stays the terminal's own signal.
1424
+ const input = options.inputStream ?? process.stdin;
1425
+ const signals = options.signalTarget ?? process;
1426
+ const interactive = input.isTTY === true && progressStream.isTTY === true;
1427
+ let finished = false;
1428
+ let cancelled = false;
1429
+ let wake;
1430
+ // One reopen at a time: presses made while a launch is still running are
1431
+ // ignored rather than queued into more tabs.
1432
+ let reopening = false;
1433
+ // Registered with `once`: a second Ctrl-C finds no listener and gets Node's
1434
+ // default, which ends the process at once. That is the escape hatch for a poll
1435
+ // request that never answers, since the first press waits for it (below).
1436
+ const onSigint = () => {
1437
+ cancelled = true;
1438
+ wake?.();
1439
+ };
1440
+ const onInput = (chunk) => {
1441
+ if (finished || cancelled || !/[\r\n]/.test(String(chunk)))
1442
+ return;
1443
+ // Every Enter echoes a newline that moves the cursor below the spinner line,
1444
+ // ignored presses included; step back up and clear it so the next redraw lands
1445
+ // where the line was instead of stranding a copy above it.
1446
+ progressStream.write("\x1b[1A\r\x1b[K");
1447
+ if (reopening)
1448
+ return;
1449
+ reopening = true;
1450
+ // Asynchronous and bounded (openBrowserAsync), so a slow launcher stalls
1451
+ // neither the polling nor Ctrl-C while it runs.
1452
+ void Promise.resolve()
1453
+ .then(() => (options.openBrowserFn ?? openBrowserAsync)(start.verification_uri))
1454
+ .catch(() => "failed")
1455
+ .then((reopened) => {
1456
+ reopening = false;
1457
+ if (finished)
1458
+ return;
1459
+ progress.note(reopened === "opened"
1460
+ ? "Opened the page again."
1461
+ : reopened === "failed"
1462
+ ? "Could not open a browser — use the URL above."
1463
+ : // "unknown": the launcher ran, but nothing says a page appeared.
1464
+ "Asked the system to open the page again. If it did not appear, use the URL above.");
1465
+ });
1466
+ };
1331
1467
  // stop() in a finally so no outcome — approval, denial, network throw,
1332
- // expiry — can leave the spinner timer alive or the line half-drawn.
1333
- const progress = createDeviceAuthProgressReporter(progressStream);
1468
+ // expiry, cancellation — can leave the spinner timer alive or the line half-drawn.
1469
+ const progress = createDeviceAuthProgressReporter(progressStream, {
1470
+ hint: interactive ? "Enter to reopen the page, Ctrl-C to cancel" : "Ctrl-C to cancel",
1471
+ });
1472
+ signals.once("SIGINT", onSigint);
1473
+ if (interactive)
1474
+ input.on("data", onInput);
1334
1475
  try {
1335
1476
  while (Date.now() < deadline) {
1477
+ // Checked here and after the sleep, never during a poll request: once a poll
1478
+ // is in flight the control plane may already have minted the key, and
1479
+ // abandoning that response would leave a live key nobody holds (#859). An
1480
+ // approval that lands in the same instant as Ctrl-C therefore completes.
1481
+ if (cancelled)
1482
+ throw new DeviceAuthCancelledError(options.purpose);
1336
1483
  // Tick before the sleep so the first feedback is immediate rather than
1337
1484
  // one poll interval late. Remaining time is derived from the deadline the
1338
1485
  // server's expires_in produced, never from a CLI-side constant.
1339
1486
  progress.tick(deadline - Date.now());
1340
- await sleep(intervalMs);
1487
+ await new Promise((resolve) => {
1488
+ const timer = setTimeout(resolve, intervalMs);
1489
+ wake = () => {
1490
+ clearTimeout(timer);
1491
+ resolve();
1492
+ };
1493
+ });
1494
+ wake = undefined;
1495
+ if (cancelled)
1496
+ throw new DeviceAuthCancelledError(options.purpose);
1341
1497
  const pollResponse = await fetchFn(`${apiUrl}/v1/oauth/device/poll`, {
1342
1498
  method: "POST",
1343
1499
  headers: { "content-type": "application/json", ...cliVersionHeader() },
@@ -1376,6 +1532,10 @@ async function deviceAuthFlow(options) {
1376
1532
  // login (#514 review). stop() is idempotent, so the finally below stays as
1377
1533
  // the fallback for every other exit path.
1378
1534
  progress.stop();
1535
+ // What the store held before this login wrote over it (#1779). Read only from the
1536
+ // real stores: an injected store replaces the backend wholesale, so the real one
1537
+ // says nothing about what that write displaced.
1538
+ const before = options.storeCredentialFn ? [] : storedCredentialTokens();
1379
1539
  let storage;
1380
1540
  try {
1381
1541
  storage = (options.storeCredentialFn ?? storeCredential)(poll.token, {
@@ -1431,27 +1591,142 @@ async function deviceAuthFlow(options) {
1431
1591
  discardRevokedCredential(poll.token);
1432
1592
  throw new Error(joinSentences("The credential this login created was stored but cannot authenticate.", disposalSentence(revocation)));
1433
1593
  }
1594
+ // The new credential is stored and has not been refused, so whatever it pushed
1595
+ // out of the store can go: nothing on this machine can reach that key any more.
1596
+ const replaced = await retireReplacedCredentials(before, options.storeCredentialFn ? [] : storedCredentialTokens(), poll.token, options.fetchFn);
1597
+ const trimmed = typeof poll.revoked_login_keys === "number" && poll.revoked_login_keys > 0 && typeof poll.login_key_limit === "number"
1598
+ ? { trimmedLoginKeys: { count: poll.revoked_login_keys, limit: poll.login_key_limit } }
1599
+ : {};
1434
1600
  if (!identity?.ok) {
1435
1601
  // Said out loud rather than swallowed: the login DID succeed, and the user
1436
1602
  // should know which half of it did not.
1437
1603
  progressStream.write(`Warning: signed in, but could not read the account name (${identity ? `the control plane answered ${identity.status}` : "the request failed"}).\n`);
1438
- return { storage };
1604
+ return { storage, replaced, ...trimmed };
1439
1605
  }
1440
1606
  const email = await identity
1441
1607
  .json()
1442
1608
  .then((body) => body.user?.email)
1443
1609
  .catch(() => undefined);
1444
- return email ? { email, storage } : { storage };
1610
+ return { ...(email ? { email } : {}), storage, replaced, ...trimmed };
1445
1611
  }
1446
1612
  }
1447
1613
  finally {
1614
+ finished = true;
1448
1615
  progress.stop();
1616
+ signals.removeListener("SIGINT", onSigint);
1617
+ if (interactive) {
1618
+ input.removeListener("data", onInput);
1619
+ // Adding the listener put stdin into flowing mode; left flowing, it would
1620
+ // hold the process open after the command is done.
1621
+ input.pause();
1622
+ }
1449
1623
  }
1450
1624
  // Reached only when the client's own deadline elapses without the server having
1451
1625
  // said anything terminal — rarer than the 410 route above, but it must say the
1452
1626
  // same thing, so both share one message.
1453
1627
  throw new Error(expiredDeviceAuthMessage(options.purpose, userCode, start.expires_in * 1000, start.verification_uri));
1454
1628
  }
1629
+ /**
1630
+ * Revoke every credential this login's write pushed out of the local stores (#1779).
1631
+ *
1632
+ * "Pushed out" means present before the write and absent after it - not merely
1633
+ * different from the new token. A copy still sitting in another store (a plaintext
1634
+ * fallback beside the keychain) is still reachable from this machine, and a process
1635
+ * that skips the keychain may be using it, so it is left for `9pm logout`.
1636
+ *
1637
+ * Each key is revoked at the control plane that ISSUED it, which is not necessarily
1638
+ * the one this login talked to: a production login overwrites a staging key in the
1639
+ * same slot, and only staging can revoke that. A key with no recorded issuer is not
1640
+ * sent anywhere. Never throws - the login has already succeeded.
1641
+ */
1642
+ export async function retireReplacedCredentials(before, after, newToken, fetchFn) {
1643
+ const seen = new Set([newToken]);
1644
+ const outcomes = [];
1645
+ for (const entry of before) {
1646
+ if (seen.has(entry.token) || after.some((kept) => kept.token === entry.token)) {
1647
+ continue;
1648
+ }
1649
+ seen.add(entry.token);
1650
+ if (!entry.apiUrl) {
1651
+ outcomes.push({ kind: "unknown_issuer" });
1652
+ continue;
1653
+ }
1654
+ const apiUrl = normalizeApiUrl(entry.apiUrl);
1655
+ const revocation = await revokeCredential({ apiUrl, token: entry.token, ...(fetchFn ? { fetchFn } : {}) });
1656
+ outcomes.push(revocation.kind === "failed" ? { kind: "failed", apiUrl, reason: revocation.reason } : { kind: revocation.kind, apiUrl });
1657
+ }
1658
+ return outcomes;
1659
+ }
1660
+ /** The lines a login prints about what it retired, for stderr. Silent when nothing needs saying. */
1661
+ export function replacedCredentialMessages(result, loginApiUrl) {
1662
+ const lines = [];
1663
+ const manual = "revoke it from Settings > API keys in the dashboard, or it stays valid until 90 days pass without use.";
1664
+ for (const outcome of result.replaced) {
1665
+ if (outcome.kind === "revoked") {
1666
+ const elsewhere = outcome.apiUrl !== normalizeApiUrl(loginApiUrl) ? ` (issued by ${outcome.apiUrl})` : "";
1667
+ lines.push(`Revoked the credential this login replaced${elsewhere}.`);
1668
+ }
1669
+ else if (outcome.kind === "failed") {
1670
+ lines.push(`Warning: the credential this login replaced could not be revoked at ${outcome.apiUrl} (${outcome.reason}) - ${manual}`);
1671
+ }
1672
+ else if (outcome.kind === "unknown_issuer") {
1673
+ lines.push(`Warning: the credential this login replaced was stored by an older CLI that did not record where it was issued, so it was not revoked - ${manual}`);
1674
+ }
1675
+ }
1676
+ if (result.trimmedLoginKeys) {
1677
+ const { count, limit } = result.trimmedLoginKeys;
1678
+ lines.push(`Revoked ${count} older login key${count === 1 ? "" : "s"} on this account (at most ${limit} stay live).`);
1679
+ }
1680
+ return lines;
1681
+ }
1682
+ /**
1683
+ * Whether `9pm login` can keep the credential this machine already holds (#1779).
1684
+ *
1685
+ * Only the credential commands actually authenticate with is considered. One recorded
1686
+ * as issued by another control plane is never sent here. One with no recorded issuer
1687
+ * (stored before 0.42) IS probed here: keys are per database, so a 200 from the plane
1688
+ * this login targets proves it was issued there, and leaving those to mint would keep
1689
+ * the oldest CLIs' machines minting a stray key per run. Only the control plane's own
1690
+ * refusal proves a key dead; a proxy's 401, a 5xx or a timeout proves nothing, so
1691
+ * those come back as unverifiable rather than as permission to mint.
1692
+ */
1693
+ export async function probeStoredLogin(options) {
1694
+ const apiUrl = normalizeApiUrl(options.apiUrl);
1695
+ const current = (options.stored ?? storedCredentialTokens())[0];
1696
+ if (!current || (current.apiUrl && normalizeApiUrl(current.apiUrl) !== apiUrl)) {
1697
+ return { kind: "mint" };
1698
+ }
1699
+ const fetchFn = options.fetchFn ?? fetch;
1700
+ let response;
1701
+ try {
1702
+ response = await fetchFn(`${apiUrl}/v1/me`, {
1703
+ method: "GET",
1704
+ headers: jsonHeaders(current.token),
1705
+ signal: AbortSignal.timeout(IDENTITY_TIMEOUT_MS),
1706
+ });
1707
+ }
1708
+ catch (error) {
1709
+ const reason = error instanceof Error && error.name === "TimeoutError"
1710
+ ? `the control plane did not answer within ${IDENTITY_TIMEOUT_MS / 1000}s`
1711
+ : error instanceof Error
1712
+ ? error.message
1713
+ : String(error);
1714
+ return { kind: "unverifiable", reason };
1715
+ }
1716
+ if (response.status === 401 || response.status === 403) {
1717
+ return (await isAuthRefusal(response))
1718
+ ? { kind: "mint" }
1719
+ : { kind: "unverifiable", reason: `${response.status} from something other than the control plane's own authentication` };
1720
+ }
1721
+ if (!response.ok) {
1722
+ return { kind: "unverifiable", reason: `the control plane answered ${response.status}` };
1723
+ }
1724
+ const email = await response
1725
+ .json()
1726
+ .then((body) => body.user?.email)
1727
+ .catch(() => undefined);
1728
+ return email ? { kind: "reuse", email } : { kind: "reuse" };
1729
+ }
1455
1730
  // The actionable timeout guidance, shared by both routes out of an unapproved
1456
1731
  // device flow (the server's 410 and the client's own deadline) so they can never
1457
1732
  // drift into saying different things.
@@ -1932,14 +2207,26 @@ export function createUploadProgressReporter(stream = process.stderr) {
1932
2207
  }
1933
2208
  // Off-TTY ceiling: at most one line per 30s of the window (~20 lines over 600s).
1934
2209
  const DEVICE_AUTH_PROGRESS_LINE_INTERVAL_MS = 30_000;
1935
- export function createDeviceAuthProgressReporter(stream = process.stderr) {
2210
+ export function createDeviceAuthProgressReporter(stream = process.stderr, options = {}) {
1936
2211
  const isTty = stream.isTTY === true;
1937
2212
  let timer;
1938
2213
  let frame = 0;
1939
2214
  let finished = false;
1940
2215
  let remainingMs;
1941
2216
  let lastLineRemainingMs;
1942
- const label = (ms) => `Waiting for approval… ${formatRemainingForCli(ms)} left`;
2217
+ const base = (ms) => `Waiting for approval… ${formatRemainingForCli(ms)} left`;
2218
+ // The hint (#1902) rides the TTY line only: off a TTY nobody is at a keyboard to
2219
+ // act on it. It is dropped when the line would not fit, because a wrapped line
2220
+ // defeats the `\r` redraw and the spinner would stack a copy per frame.
2221
+ const label = (ms) => {
2222
+ const plain = base(ms);
2223
+ if (!isTty || !options.hint)
2224
+ return plain;
2225
+ const full = `${plain} — ${options.hint}`;
2226
+ const width = typeof stream.columns === "number" ? stream.columns : 0;
2227
+ // +2 for the spinner glyph and its space.
2228
+ return width > 0 && full.length + 2 >= width ? plain : full;
2229
+ };
1943
2230
  const redraw = () => {
1944
2231
  if (remainingMs === undefined)
1945
2232
  return;
@@ -1978,6 +2265,16 @@ export function createDeviceAuthProgressReporter(stream = process.stderr) {
1978
2265
  lastLineRemainingMs = nextRemainingMs;
1979
2266
  stream.write(`${label(nextRemainingMs)}\n`);
1980
2267
  },
2268
+ note(line) {
2269
+ if (finished)
2270
+ return;
2271
+ if (!isTty) {
2272
+ stream.write(`${line}\n`);
2273
+ return;
2274
+ }
2275
+ stream.write(`\r\x1b[K${line}\n`);
2276
+ redraw();
2277
+ },
1981
2278
  // Idempotent; called from a finally around the poll loop. On a TTY it erases
1982
2279
  // the in-place line and writes nothing in its place: unlike the upload
1983
2280
  // reporter this deliberately leaves no ✓/✗ summary, because the flow's own
@@ -2238,6 +2535,8 @@ const VALID_DEPLOY_FLAGS = new Set([
2238
2535
  "memory",
2239
2536
  "no-build",
2240
2537
  "bundle",
2538
+ "assets",
2539
+ "no-assets",
2241
2540
  ]);
2242
2541
  export function parseArgs(argv) {
2243
2542
  const command = argv[0];
@@ -2264,7 +2563,12 @@ export function parseArgs(argv) {
2264
2563
  }
2265
2564
  if (command === "login") {
2266
2565
  const flags = parseFlags(argv);
2267
- return { kind: "login", apiUrl: readApiUrl(flags, DEFAULT_API_URL), allowFileCredentialStore: flags.has("allow-file-credential-store") };
2566
+ return {
2567
+ kind: "login",
2568
+ apiUrl: readApiUrl(flags, DEFAULT_API_URL),
2569
+ allowFileCredentialStore: flags.has("allow-file-credential-store"),
2570
+ fresh: flags.has("fresh"),
2571
+ };
2268
2572
  }
2269
2573
  if (command === "signup") {
2270
2574
  const flags = parseFlags(argv);
@@ -2466,6 +2770,23 @@ export function parseArgs(argv) {
2466
2770
  options.runtime = "worker";
2467
2771
  options.bundleDir = bundleDir;
2468
2772
  }
2773
+ const assetsFlag = flags.get("assets");
2774
+ if (assetsFlag !== undefined || flags.has("no-assets")) {
2775
+ if (assetsFlag !== undefined && flags.has("no-assets")) {
2776
+ throw new Error("--assets and --no-assets cannot be combined.");
2777
+ }
2778
+ if (assetsFlag === true || assetsFlag === "") {
2779
+ throw new Error("--assets needs a directory, for example --assets public. Use --no-assets if nothing should be public.");
2780
+ }
2781
+ if (options.bundleDir) {
2782
+ throw new Error("--assets and --no-assets apply to --entry worker deploys; a --bundle deploy already serves only its assets directory.");
2783
+ }
2784
+ const workerRuntime = options.runtime === "worker" || (options.runtime === undefined && options.entry !== undefined);
2785
+ if (!workerRuntime) {
2786
+ throw new Error("--assets and --no-assets apply to worker deploys (--runtime worker --entry <file>); a static site serves every file.");
2787
+ }
2788
+ options.publicAssets = typeof assetsFlag === "string" ? normalizeAssetsDirectory(assetsFlag) : NO_PUBLIC_ASSETS;
2789
+ }
2469
2790
  if (options.runtime === "container" && !options.port) {
2470
2791
  throw new Error("Container deploys require --port <port>.");
2471
2792
  }
@@ -3173,6 +3494,48 @@ function parseDurationSeconds(value) {
3173
3494
  const seconds = unit === "d" ? amount * 24 * 60 * 60 : unit === "h" ? amount * 60 * 60 : unit === "m" ? amount * 60 : amount;
3174
3495
  return Math.min(seconds, 7 * 24 * 60 * 60);
3175
3496
  }
3497
+ // #1847: what an entry-based worker deploy publishes. Shown on every deploy that still
3498
+ // uses the legacy shape rather than only under `--check`, because the app that leaks is
3499
+ // the one whose author never thought to check.
3500
+ const PUBLIC_FILE_LIST_LIMIT = 10;
3501
+ export function workerPublicFiles(paths, serverEntry, selection) {
3502
+ const files = [];
3503
+ for (const filePath of paths) {
3504
+ const url = publicAssetPath(filePath, serverEntry, selection);
3505
+ if (url !== null) {
3506
+ files.push({ path: filePath, url });
3507
+ }
3508
+ }
3509
+ return files;
3510
+ }
3511
+ export function legacyPublicAssetsWarning(serverEntry, publicPaths) {
3512
+ const shown = publicPaths.slice(0, PUBLIC_FILE_LIST_LIMIT);
3513
+ const more = publicPaths.length - shown.length;
3514
+ return [
3515
+ `this worker app serves every file in the deploy directory except its entry (${serverEntry}) at a public URL - ${publicPaths.length} file(s).`,
3516
+ "Choose what is public with --assets <dir> (only that directory is served; for example --assets public) or --no-assets. The app remembers the choice for later deploys.",
3517
+ `Public now: ${shown.length > 0 ? shown.join(", ") : "none"}${more > 0 ? `, and ${more} more (9pm deploy --check lists them all)` : ""}`,
3518
+ ].join("\n ");
3519
+ }
3520
+ export function privateLookingPublicWarning(paths, selection) {
3521
+ const shown = paths.slice(0, PUBLIC_FILE_LIST_LIMIT);
3522
+ const more = paths.length - shown.length;
3523
+ const remedy = selection === null
3524
+ ? "Choose a public directory with --assets <dir>, or move them out of the deploy directory."
3525
+ : `Move them out of ${selection}.`;
3526
+ return `these files look like server code or leftovers, and anyone can download them: ${shown.join(", ")}${more > 0 ? `, and ${more} more` : ""}. ${remedy}`;
3527
+ }
3528
+ // The files a deploy sends: everything, unless a worker selection narrows it to the entry
3529
+ // and the public files. The same set the server holds to the per-file limit.
3530
+ function uploadedFiles(manifest, serverEntry, selection) {
3531
+ if (!serverEntry || selection === null) {
3532
+ return manifest;
3533
+ }
3534
+ return manifest.filter((file) => file.path === serverEntry || publicAssetPath(file.path, serverEntry, selection) !== null);
3535
+ }
3536
+ function missingAssetsDirectoryMessage(selection) {
3537
+ return `--assets ${selection.slice(0, -1)}: no files were found under ${selection} in the deploy directory.`;
3538
+ }
3176
3539
  function normalizeServerEntry(value) {
3177
3540
  const normalized = value.trim().replaceAll("\\", "/").replace(/^\.\/+/, "");
3178
3541
  if (!normalized || normalized.startsWith("/") || normalized.split("/").includes("..")) {
@@ -3186,18 +3549,17 @@ async function validateWorkerEntrySource(source, filePath) {
3186
3549
  if (/\bfrom\s+["'][^"']+["']/.test(source) || /\bimport\s*\(/.test(source) || /(^|\n)\s*import\s+["'][^"']+["']/.test(source)) {
3187
3550
  errors.push("Worker runtime entry must be bundled into one module with no external imports.");
3188
3551
  }
3189
- const hasLiteralDefault = /\bexport\s+default\b/.test(source);
3190
- const hasDefaultReexport = /export\s*\{\s*([A-Za-z_$][\w$]*)\s+as\s+default\s*,?\s*\}\s*;?\s*$/.test(source);
3191
- if (!hasLiteralDefault && !hasDefaultReexport) {
3192
- errors.push(`Worker runtime entry ${filePath} must export a default fetch handler.`);
3193
- }
3552
+ // Whether the entry has a default export is decided by evaluating it as an ES module,
3553
+ // which is what the server uploads unmodified, not by matching its text:
3554
+ // `export { handler as default, helper }`, a comment before `default`, and a string or
3555
+ // escaped export name are all valid defaults a text match would refuse (#1851).
3194
3556
  if (errors.length === 0) {
3195
- const transformed = hasLiteralDefault
3196
- ? source
3197
- : `${source.replace(/export\s*\{\s*([A-Za-z_$][\w$]*)\s+as\s+default\s*,?\s*\}\s*;?\s*$/, "export default $1;")}`;
3198
3557
  try {
3199
- const module = await import(`data:text/javascript;base64,${Buffer.from(transformed).toString("base64")}`);
3200
- if (typeof module.default?.fetch !== "function" && typeof module.default !== "function") {
3558
+ const module = await import(`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`);
3559
+ if (!("default" in module)) {
3560
+ errors.push(`Worker runtime entry ${filePath} must export a default fetch handler.`);
3561
+ }
3562
+ else if (typeof module.default?.fetch !== "function" && typeof module.default !== "function") {
3201
3563
  errors.push("Default export must be a function or an object with fetch(request, env, ctx).");
3202
3564
  }
3203
3565
  }
@@ -3910,6 +4272,41 @@ function openBrowser(url) {
3910
4272
  ...(result.error ? { error: result.error } : {}),
3911
4273
  });
3912
4274
  }
4275
+ // How long a reopen waits for the launcher to say how it went (#1902). `open` and
4276
+ // `xdg-open` hand off and return in well under a second; one still running after
4277
+ // this is left to finish on its own, and the person is pointed at the URL instead.
4278
+ const BROWSER_LAUNCH_TIMEOUT_MS = 5_000;
4279
+ // openBrowser without blocking: the reopen on Enter runs while the login waits,
4280
+ // and spawnSync there would freeze the poll loop and the Ctrl-C handler for as long
4281
+ // as the launcher took. Same platform choice and verdicts as openBrowser.
4282
+ function openBrowserAsync(url) {
4283
+ if (hasNoGraphicalDisplay(process.platform, process.env)) {
4284
+ return Promise.resolve("failed");
4285
+ }
4286
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
4287
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
4288
+ return new Promise((resolve) => {
4289
+ let settled = false;
4290
+ const settle = (result) => {
4291
+ if (settled)
4292
+ return;
4293
+ settled = true;
4294
+ clearTimeout(timer);
4295
+ resolve(result);
4296
+ };
4297
+ const child = spawn(command, args, { stdio: "ignore" });
4298
+ // Unref'd at once, not when the wait below runs out: a launcher still running
4299
+ // when the login ends (Ctrl-C, approval) must not keep the CLI alive behind it.
4300
+ // Its exit event still arrives while the CLI is running for another reason.
4301
+ child.unref();
4302
+ // A launcher that has not answered in time may still open the page, so this
4303
+ // is "unknown" rather than "failed".
4304
+ const timer = setTimeout(() => settle("unknown"), BROWSER_LAUNCH_TIMEOUT_MS);
4305
+ timer.unref();
4306
+ child.once("error", (error) => settle(classifyBrowserOpen({ platform: process.platform, status: null, error })));
4307
+ child.once("exit", (status) => settle(classifyBrowserOpen({ platform: process.platform, status })));
4308
+ });
4309
+ }
3913
4310
  function sleep(ms) {
3914
4311
  return new Promise((resolve) => setTimeout(resolve, ms));
3915
4312
  }
@@ -4001,6 +4398,12 @@ async function main() {
4001
4398
  const subject = moduleCount === 1 ? "entry" : `${moduleCount} server modules`;
4002
4399
  console.log(`Server code (${subject}): ${compressedBytes.toLocaleString("en-US")} bytes compressed of ${MAX_WORKER_ENTRY_COMPRESSED_BYTES.toLocaleString("en-US")} allowed.`);
4003
4400
  }
4401
+ if (check.publicFiles) {
4402
+ console.log(check.publicFiles.length > 0 ? `Public files (${check.publicFiles.length}):` : "Public files: none.");
4403
+ for (const file of check.publicFiles) {
4404
+ console.log(` ${file.path} -> ${file.url}`);
4405
+ }
4406
+ }
4004
4407
  }
4005
4408
  return;
4006
4409
  }
@@ -4054,13 +4457,30 @@ async function main() {
4054
4457
  return;
4055
4458
  }
4056
4459
  if (command.kind === "login") {
4460
+ // Each login used to mint another full-scope key (#1779); keep a working one.
4461
+ if (!command.fresh) {
4462
+ const probe = await probeStoredLogin({ apiUrl: command.apiUrl });
4463
+ if (probe.kind === "reuse") {
4464
+ console.log(`Already logged in${probe.email ? ` as ${probe.email}` : ""}. Run "9pm login --fresh" to replace this machine's credential.`);
4465
+ return;
4466
+ }
4467
+ if (probe.kind === "unverifiable") {
4468
+ throw new Error(`Could not check the credential already stored on this machine (${probe.reason}). Retry, or run "9pm login --fresh" to replace it.`);
4469
+ }
4470
+ }
4057
4471
  const result = await loginDevice({ apiUrl: command.apiUrl, allowFileCredentialStore: command.allowFileCredentialStore });
4058
4472
  console.log(`Logged in${result.email ? ` as ${result.email}` : ""}. Credential stored in ${result.storage}.`);
4473
+ for (const line of replacedCredentialMessages(result, command.apiUrl)) {
4474
+ console.error(line);
4475
+ }
4059
4476
  return;
4060
4477
  }
4061
4478
  if (command.kind === "signup") {
4062
4479
  const result = await signupDevice({ apiUrl: command.apiUrl, allowFileCredentialStore: command.allowFileCredentialStore });
4063
4480
  console.log(`Signed up${result.email ? ` as ${result.email}` : ""}. Credential stored in ${result.storage}.`);
4481
+ for (const line of replacedCredentialMessages(result, command.apiUrl)) {
4482
+ console.error(line);
4483
+ }
4064
4484
  return;
4065
4485
  }
4066
4486
  if (command.kind === "logout") {
@@ -4289,6 +4709,13 @@ async function main() {
4289
4709
  process.exitCode = 0;
4290
4710
  return;
4291
4711
  }
4712
+ if (error instanceof DeviceAuthCancelledError) {
4713
+ // The person asked for this, so it is said plainly rather than reported as a
4714
+ // failure with whatever decoration reportCliError adds.
4715
+ console.error(error.message);
4716
+ process.exitCode = CANCELLED_EXIT_CODE;
4717
+ return;
4718
+ }
4292
4719
  reportCliError(error);
4293
4720
  process.exitCode = 1;
4294
4721
  }
@@ -4534,14 +4961,14 @@ function printUsage() {
4534
4961
  console.log(`Usage:
4535
4962
  9pm --version
4536
4963
  9pm deploy [directory] --name <site-name> [--slug <slug>] [--owner <organisation>] [--description "<note>"] [--with-db] [--with-auth] [--with-email]
4537
- 9pm deploy [directory] --runtime worker --entry src/index.js [--with-db]
4964
+ 9pm deploy [directory] --runtime worker --entry src/index.js (--assets <dir> | --no-assets) [--with-db]
4538
4965
  9pm deploy [directory] --runtime container --port 8080 [--health-path /health] [--region auto] [--with-db] [--always-on] [--volume <gb> | --no-volume] [--cpus <n>] [--memory <size>]
4539
4966
  9pm deploy [directory] framework projects (Next.js, Vite) are detected and built automatically; --no-build skips
4540
4967
  9pm deploy [directory] --bundle <dir> deploy a pre-built Cloudflare framework bundle (e.g. .open-next)
4541
4968
  9pm deploy [directory] --description "<note>" optional short note shown next to the version in the dashboard
4542
4969
  9pm deploy [directory] --check
4543
4970
  9pm signup
4544
- 9pm login
4971
+ 9pm login [--fresh] keeps a working stored credential; --fresh replaces it and revokes the old one
4545
4972
  9pm logout [--api-url <url>] [--local] revokes the credential server-side, then removes it from this machine (--local skips the revoke)
4546
4973
  9pm doctor [--json]
4547
4974
  9pm whoami [--json]
@@ -4584,10 +5011,18 @@ Size limits:
4584
5011
  Individual files are limited to ${formatMebibytes(MAX_STATIC_ASSET_FILE_BYTES)}.
4585
5012
  Worker runtime: server code is limited to ${formatMegabytes(MAX_WORKER_ENTRY_COMPRESSED_BYTES)} compressed (gzip) — the ${formatMegabytes(MAX_WORKER_SCRIPT_COMPRESSED_BYTES)} script limit
4586
5013
  less a small reserve for the generated wrapper. That budget covers the --entry module, or
4587
- with --bundle every server module together, since they deploy as one script. Every other
4588
- file in the directory is served from the asset store instead, so it does not count toward
4589
- that limit — keep large images, fonts and datasets as sibling files rather than inlining
4590
- them into the entry. Apps needing more server code than that should deploy as containers.
5014
+ with --bundle every server module together, since they deploy as one script. Public files
5015
+ are served from the asset store instead, so they do not count toward that limit — keep large
5016
+ images, fonts and datasets as public files rather than inlining them into the entry. Apps
5017
+ needing more server code than that should deploy as containers.
5018
+
5019
+ Worker public files:
5020
+ --assets <dir> only the files under <dir> are public, served from the app's root
5021
+ (<dir>/index.html answers on /). Nothing else in the directory is uploaded.
5022
+ --no-assets nothing is public; the entry answers every request.
5023
+ The app remembers the choice, so later deploys need not repeat it; pass the other flag to
5024
+ change it. A worker app that never chose serves EVERY file except its entry publicly, and
5025
+ each deploy warns about it. --check lists the files a deploy would make public.
4591
5026
 
4592
5027
  Container deploys:
4593
5028
  Requires Docker locally. The CLI builds linux/amd64, exports the image, uploads it through a short-lived session, then finalizes a queued deployment.
@@ -0,0 +1,5 @@
1
+ export declare const NO_PUBLIC_ASSETS = "none";
2
+ export declare function normalizeAssetsDirectory(value: string): string;
3
+ export declare function parsePublicAssetsSelection(value: unknown): string | null;
4
+ export declare function publicAssetPath(filePath: string, serverEntry: string | null, selection: string | null): string | null;
5
+ export declare function privateLookingPaths(filePaths: readonly string[], serverEntry: string | null): string[];
@@ -0,0 +1,91 @@
1
+ // AUTO-GENERATED by scripts/generate-cli-package.mjs from the in-repo CLI source -- do not edit.
2
+ // Edit src/cli or src/shared instead; this is regenerated on typecheck/test/build/pack.
3
+ // Which files of a worker-runtime deploy become public URLs (#1847). Shared by the control
4
+ // plane, which enforces it at publish, and the CLI, which warns and trims what it uploads.
5
+ // Keep this module dependency-free: it is copied verbatim into the shipped ninepm tarball.
6
+ //
7
+ // A selection is one of two strings, and the same string is used everywhere it travels -
8
+ // the `--assets`/`--no-assets` flags, `metadata.publicAssets` on a deployment, and the
9
+ // project's `worker_public_assets` column:
10
+ // - a relative directory prefix ending in "/" (e.g. "public/"): only files under it are
11
+ // served, at their path below it, so `public/index.html` answers on `/index.html`;
12
+ // - NO_PUBLIC_ASSETS: nothing is served from the deploy directory at all.
13
+ // Absence (null) is the legacy shape every app had before this existed: every file except
14
+ // the entry is served at its own path. It is never a value a client can send, so once an
15
+ // app has chosen, no later deploy can widen it back to legacy by omission.
16
+ export const NO_PUBLIC_ASSETS = "none";
17
+ // Turns what a person typed after `--assets` into the canonical prefix, or throws with a
18
+ // message that names the problem. The deploy root itself is refused: the entry lives
19
+ // there, so "everything" is the legacy shape rather than a selection, and allowing it
20
+ // would let a flag meant to narrow publication silently reproduce the problem it fixes.
21
+ export function normalizeAssetsDirectory(value) {
22
+ const trimmed = value.trim();
23
+ if (trimmed.includes("\\")) {
24
+ throw new Error(`--assets must use "/" between directory names: ${value}`);
25
+ }
26
+ if (trimmed.startsWith("/")) {
27
+ throw new Error(`--assets must be a directory inside the deploy directory, not an absolute path: ${value}`);
28
+ }
29
+ const segments = trimmed.split("/").filter((segment, index) => segment !== "" && !(index === 0 && segment === "."));
30
+ if (segments.length === 0) {
31
+ throw new Error("--assets must name a subdirectory (for example --assets public). The deploy directory itself holds your server code; use --no-assets if nothing should be public.");
32
+ }
33
+ if (segments.some((segment) => segment === "." || segment === "..")) {
34
+ throw new Error(`--assets must be a directory inside the deploy directory: ${value}`);
35
+ }
36
+ return `${segments.join("/")}/`;
37
+ }
38
+ // The server's reading of a stored or client-sent selection: the canonical form only,
39
+ // never a lenient one, so the value that was validated is byte for byte the value used.
40
+ export function parsePublicAssetsSelection(value) {
41
+ if (value === NO_PUBLIC_ASSETS) {
42
+ return NO_PUBLIC_ASSETS;
43
+ }
44
+ if (typeof value !== "string" || !value.endsWith("/")) {
45
+ return null;
46
+ }
47
+ try {
48
+ return normalizeAssetsDirectory(value) === value ? value : null;
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ // The URL path a deploy file is served on, or null when it must not be public. The entry
55
+ // is never public under any selection: it is uploaded as the Worker's own module.
56
+ export function publicAssetPath(filePath, serverEntry, selection) {
57
+ if (serverEntry !== null && filePath === serverEntry) {
58
+ return null;
59
+ }
60
+ if (selection === null) {
61
+ return filePath.startsWith("/") ? filePath : `/${filePath}`;
62
+ }
63
+ if (selection === NO_PUBLIC_ASSETS || !filePath.startsWith(selection)) {
64
+ return null;
65
+ }
66
+ return `/${filePath.slice(selection.length)}`;
67
+ }
68
+ const SCRIPT_EXTENSION = /\.(js|mjs|cjs|ts|mts|cts|jsx|tsx)$/i;
69
+ const LEFTOVER_FILE = /(\.(orig|bak|old|rej|swp|swo|tmp)|~)$/i;
70
+ // Public files that look like they were never meant to be: editor and merge leftovers,
71
+ // dotfiles, and other script files in the entry's own directory (which is where server
72
+ // helpers live). A heuristic for a warning, never a filter - a client script beside the
73
+ // entry is legitimate, which is why the fix is a selection and not this list.
74
+ export function privateLookingPaths(filePaths, serverEntry) {
75
+ const entryDirectory = serverEntry !== null && serverEntry.includes("/") ? serverEntry.slice(0, serverEntry.lastIndexOf("/") + 1) : "";
76
+ return filePaths.filter((filePath) => {
77
+ if (filePath === serverEntry) {
78
+ return false;
79
+ }
80
+ const name = filePath.slice(filePath.lastIndexOf("/") + 1);
81
+ if (LEFTOVER_FILE.test(name) || filePath.split("/").some((segment) => segment.startsWith(".") && segment !== ".well-known")) {
82
+ return true;
83
+ }
84
+ if (!SCRIPT_EXTENSION.test(name)) {
85
+ return false;
86
+ }
87
+ // Beside the entry: under its directory when it has one, or directly at the root
88
+ // when the entry itself sits there (a root-level entry says nothing about subfolders).
89
+ return entryDirectory === "" ? !filePath.includes("/") : filePath.startsWith(entryDirectory);
90
+ });
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ninepm",
3
- "version": "0.61.0",
3
+ "version": "0.65.0",
4
4
  "description": "CLI for deploying built apps to 9pm.ai.",
5
5
  "homepage": "https://9pm.ai",
6
6
  "keywords": [