ninepm 0.60.0 → 0.64.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.60.0";
120
+ export declare const CLI_VERSION = "0.64.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;
@@ -335,24 +340,81 @@ export interface DeviceAuthFeedbackOptions {
335
340
  apiUrl?: string | undefined;
336
341
  }) => string;
337
342
  }
343
+ export type ReplacedCredential = {
344
+ kind: "revoked";
345
+ apiUrl: string;
346
+ } | {
347
+ kind: "already_invalid";
348
+ apiUrl: string;
349
+ } | {
350
+ kind: "failed";
351
+ apiUrl: string;
352
+ reason: string;
353
+ } | {
354
+ kind: "unknown_issuer";
355
+ };
356
+ export interface DeviceAuthResult {
357
+ email?: string;
358
+ storage: string;
359
+ replaced: ReplacedCredential[];
360
+ trimmedLoginKeys?: {
361
+ count: number;
362
+ limit: number;
363
+ };
364
+ }
338
365
  export declare function loginDevice(options: {
339
366
  apiUrl: string;
340
367
  fetchFn?: FetchLike;
341
368
  machineName?: string;
342
369
  allowFileCredentialStore?: boolean;
343
- } & DeviceAuthFeedbackOptions): Promise<{
344
- email?: string;
345
- storage: string;
346
- }>;
370
+ } & DeviceAuthFeedbackOptions): Promise<DeviceAuthResult>;
347
371
  export declare function signupDevice(options: {
348
372
  apiUrl: string;
349
373
  fetchFn?: FetchLike;
350
374
  machineName?: string;
351
375
  allowFileCredentialStore?: boolean;
352
- } & DeviceAuthFeedbackOptions): Promise<{
376
+ } & DeviceAuthFeedbackOptions): Promise<DeviceAuthResult>;
377
+ /**
378
+ * Revoke every credential this login's write pushed out of the local stores (#1779).
379
+ *
380
+ * "Pushed out" means present before the write and absent after it - not merely
381
+ * different from the new token. A copy still sitting in another store (a plaintext
382
+ * fallback beside the keychain) is still reachable from this machine, and a process
383
+ * that skips the keychain may be using it, so it is left for `9pm logout`.
384
+ *
385
+ * Each key is revoked at the control plane that ISSUED it, which is not necessarily
386
+ * the one this login talked to: a production login overwrites a staging key in the
387
+ * same slot, and only staging can revoke that. A key with no recorded issuer is not
388
+ * sent anywhere. Never throws - the login has already succeeded.
389
+ */
390
+ export declare function retireReplacedCredentials(before: StoredCredentialEntry[], after: StoredCredentialEntry[], newToken: string, fetchFn?: FetchLike): Promise<ReplacedCredential[]>;
391
+ /** The lines a login prints about what it retired, for stderr. Silent when nothing needs saying. */
392
+ export declare function replacedCredentialMessages(result: Pick<DeviceAuthResult, "replaced" | "trimmedLoginKeys">, loginApiUrl: string): string[];
393
+ export type StoredLoginProbe = {
394
+ kind: "reuse";
353
395
  email?: string;
354
- storage: string;
355
- }>;
396
+ } | {
397
+ kind: "mint";
398
+ } | {
399
+ kind: "unverifiable";
400
+ reason: string;
401
+ };
402
+ /**
403
+ * Whether `9pm login` can keep the credential this machine already holds (#1779).
404
+ *
405
+ * Only the credential commands actually authenticate with is considered. One recorded
406
+ * as issued by another control plane is never sent here. One with no recorded issuer
407
+ * (stored before 0.42) IS probed here: keys are per database, so a 200 from the plane
408
+ * this login targets proves it was issued there, and leaving those to mint would keep
409
+ * the oldest CLIs' machines minting a stray key per run. Only the control plane's own
410
+ * refusal proves a key dead; a proxy's 401, a 5xx or a timeout proves nothing, so
411
+ * those come back as unverifiable rather than as permission to mint.
412
+ */
413
+ export declare function probeStoredLogin(options: {
414
+ apiUrl: string;
415
+ fetchFn?: FetchLike;
416
+ stored?: StoredCredentialEntry[];
417
+ }): Promise<StoredLoginProbe>;
356
418
  export declare function expiredDeviceAuthMessage(purpose: string, userCode: string, lifetimeMs: number, verificationUri: string): string;
357
419
  export declare function listManagedFiles(options: FileCommandOptions): Promise<ManagedFileListResult>;
358
420
  export declare function getAppUserFilesUsage(options: ApiOptions & {
@@ -396,6 +458,7 @@ export interface DeviceAuthProgressReporter {
396
458
  export declare function createDeviceAuthProgressReporter(stream?: NodeJS.WriteStream): DeviceAuthProgressReporter;
397
459
  export declare function noteCliOutdatedAdvisory(response: Response, log?: (line: string) => void): void;
398
460
  export declare function resetCliOutdatedAdvisoryForTests(): void;
461
+ export declare function dashboardUrlForApiUrl(apiUrl: string): string;
399
462
  type CliCommand = {
400
463
  kind: "deploy";
401
464
  options: DeployOptions;
@@ -405,6 +468,7 @@ type CliCommand = {
405
468
  kind: "login";
406
469
  apiUrl: string;
407
470
  allowFileCredentialStore: boolean;
471
+ fresh: boolean;
408
472
  } | {
409
473
  kind: "signup";
410
474
  apiUrl: string;
@@ -591,6 +655,12 @@ export declare function parseEnvFileEntries(content: string, kind: "secret" | "v
591
655
  kind: "secret" | "var";
592
656
  }>;
593
657
  export declare function healthWithReason(status: string | null | undefined, reason: string | null | undefined): string;
658
+ export declare function workerPublicFiles(paths: readonly string[], serverEntry: string, selection: string | null): Array<{
659
+ path: string;
660
+ url: string;
661
+ }>;
662
+ export declare function legacyPublicAssetsWarning(serverEntry: string, publicPaths: readonly string[]): string;
663
+ export declare function privateLookingPublicWarning(paths: readonly string[], selection: string | null): string;
594
664
  export declare function storedCredentialTokens(): StoredCredentialEntry[];
595
665
  /**
596
666
  * Whether a credential provably cannot be stored, decided WITHOUT writing one (#859).
package/dist/index.js CHANGED
@@ -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.60.0";
19
+ export const CLI_VERSION = "0.64.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" });
@@ -1376,6 +1450,10 @@ async function deviceAuthFlow(options) {
1376
1450
  // login (#514 review). stop() is idempotent, so the finally below stays as
1377
1451
  // the fallback for every other exit path.
1378
1452
  progress.stop();
1453
+ // What the store held before this login wrote over it (#1779). Read only from the
1454
+ // real stores: an injected store replaces the backend wholesale, so the real one
1455
+ // says nothing about what that write displaced.
1456
+ const before = options.storeCredentialFn ? [] : storedCredentialTokens();
1379
1457
  let storage;
1380
1458
  try {
1381
1459
  storage = (options.storeCredentialFn ?? storeCredential)(poll.token, {
@@ -1431,17 +1509,23 @@ async function deviceAuthFlow(options) {
1431
1509
  discardRevokedCredential(poll.token);
1432
1510
  throw new Error(joinSentences("The credential this login created was stored but cannot authenticate.", disposalSentence(revocation)));
1433
1511
  }
1512
+ // The new credential is stored and has not been refused, so whatever it pushed
1513
+ // out of the store can go: nothing on this machine can reach that key any more.
1514
+ const replaced = await retireReplacedCredentials(before, options.storeCredentialFn ? [] : storedCredentialTokens(), poll.token, options.fetchFn);
1515
+ const trimmed = typeof poll.revoked_login_keys === "number" && poll.revoked_login_keys > 0 && typeof poll.login_key_limit === "number"
1516
+ ? { trimmedLoginKeys: { count: poll.revoked_login_keys, limit: poll.login_key_limit } }
1517
+ : {};
1434
1518
  if (!identity?.ok) {
1435
1519
  // Said out loud rather than swallowed: the login DID succeed, and the user
1436
1520
  // should know which half of it did not.
1437
1521
  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 };
1522
+ return { storage, replaced, ...trimmed };
1439
1523
  }
1440
1524
  const email = await identity
1441
1525
  .json()
1442
1526
  .then((body) => body.user?.email)
1443
1527
  .catch(() => undefined);
1444
- return email ? { email, storage } : { storage };
1528
+ return { ...(email ? { email } : {}), storage, replaced, ...trimmed };
1445
1529
  }
1446
1530
  }
1447
1531
  finally {
@@ -1452,6 +1536,107 @@ async function deviceAuthFlow(options) {
1452
1536
  // same thing, so both share one message.
1453
1537
  throw new Error(expiredDeviceAuthMessage(options.purpose, userCode, start.expires_in * 1000, start.verification_uri));
1454
1538
  }
1539
+ /**
1540
+ * Revoke every credential this login's write pushed out of the local stores (#1779).
1541
+ *
1542
+ * "Pushed out" means present before the write and absent after it - not merely
1543
+ * different from the new token. A copy still sitting in another store (a plaintext
1544
+ * fallback beside the keychain) is still reachable from this machine, and a process
1545
+ * that skips the keychain may be using it, so it is left for `9pm logout`.
1546
+ *
1547
+ * Each key is revoked at the control plane that ISSUED it, which is not necessarily
1548
+ * the one this login talked to: a production login overwrites a staging key in the
1549
+ * same slot, and only staging can revoke that. A key with no recorded issuer is not
1550
+ * sent anywhere. Never throws - the login has already succeeded.
1551
+ */
1552
+ export async function retireReplacedCredentials(before, after, newToken, fetchFn) {
1553
+ const seen = new Set([newToken]);
1554
+ const outcomes = [];
1555
+ for (const entry of before) {
1556
+ if (seen.has(entry.token) || after.some((kept) => kept.token === entry.token)) {
1557
+ continue;
1558
+ }
1559
+ seen.add(entry.token);
1560
+ if (!entry.apiUrl) {
1561
+ outcomes.push({ kind: "unknown_issuer" });
1562
+ continue;
1563
+ }
1564
+ const apiUrl = normalizeApiUrl(entry.apiUrl);
1565
+ const revocation = await revokeCredential({ apiUrl, token: entry.token, ...(fetchFn ? { fetchFn } : {}) });
1566
+ outcomes.push(revocation.kind === "failed" ? { kind: "failed", apiUrl, reason: revocation.reason } : { kind: revocation.kind, apiUrl });
1567
+ }
1568
+ return outcomes;
1569
+ }
1570
+ /** The lines a login prints about what it retired, for stderr. Silent when nothing needs saying. */
1571
+ export function replacedCredentialMessages(result, loginApiUrl) {
1572
+ const lines = [];
1573
+ const manual = "revoke it from Settings > API keys in the dashboard, or it stays valid until 90 days pass without use.";
1574
+ for (const outcome of result.replaced) {
1575
+ if (outcome.kind === "revoked") {
1576
+ const elsewhere = outcome.apiUrl !== normalizeApiUrl(loginApiUrl) ? ` (issued by ${outcome.apiUrl})` : "";
1577
+ lines.push(`Revoked the credential this login replaced${elsewhere}.`);
1578
+ }
1579
+ else if (outcome.kind === "failed") {
1580
+ lines.push(`Warning: the credential this login replaced could not be revoked at ${outcome.apiUrl} (${outcome.reason}) - ${manual}`);
1581
+ }
1582
+ else if (outcome.kind === "unknown_issuer") {
1583
+ 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}`);
1584
+ }
1585
+ }
1586
+ if (result.trimmedLoginKeys) {
1587
+ const { count, limit } = result.trimmedLoginKeys;
1588
+ lines.push(`Revoked ${count} older login key${count === 1 ? "" : "s"} on this account (at most ${limit} stay live).`);
1589
+ }
1590
+ return lines;
1591
+ }
1592
+ /**
1593
+ * Whether `9pm login` can keep the credential this machine already holds (#1779).
1594
+ *
1595
+ * Only the credential commands actually authenticate with is considered. One recorded
1596
+ * as issued by another control plane is never sent here. One with no recorded issuer
1597
+ * (stored before 0.42) IS probed here: keys are per database, so a 200 from the plane
1598
+ * this login targets proves it was issued there, and leaving those to mint would keep
1599
+ * the oldest CLIs' machines minting a stray key per run. Only the control plane's own
1600
+ * refusal proves a key dead; a proxy's 401, a 5xx or a timeout proves nothing, so
1601
+ * those come back as unverifiable rather than as permission to mint.
1602
+ */
1603
+ export async function probeStoredLogin(options) {
1604
+ const apiUrl = normalizeApiUrl(options.apiUrl);
1605
+ const current = (options.stored ?? storedCredentialTokens())[0];
1606
+ if (!current || (current.apiUrl && normalizeApiUrl(current.apiUrl) !== apiUrl)) {
1607
+ return { kind: "mint" };
1608
+ }
1609
+ const fetchFn = options.fetchFn ?? fetch;
1610
+ let response;
1611
+ try {
1612
+ response = await fetchFn(`${apiUrl}/v1/me`, {
1613
+ method: "GET",
1614
+ headers: jsonHeaders(current.token),
1615
+ signal: AbortSignal.timeout(IDENTITY_TIMEOUT_MS),
1616
+ });
1617
+ }
1618
+ catch (error) {
1619
+ const reason = error instanceof Error && error.name === "TimeoutError"
1620
+ ? `the control plane did not answer within ${IDENTITY_TIMEOUT_MS / 1000}s`
1621
+ : error instanceof Error
1622
+ ? error.message
1623
+ : String(error);
1624
+ return { kind: "unverifiable", reason };
1625
+ }
1626
+ if (response.status === 401 || response.status === 403) {
1627
+ return (await isAuthRefusal(response))
1628
+ ? { kind: "mint" }
1629
+ : { kind: "unverifiable", reason: `${response.status} from something other than the control plane's own authentication` };
1630
+ }
1631
+ if (!response.ok) {
1632
+ return { kind: "unverifiable", reason: `the control plane answered ${response.status}` };
1633
+ }
1634
+ const email = await response
1635
+ .json()
1636
+ .then((body) => body.user?.email)
1637
+ .catch(() => undefined);
1638
+ return email ? { kind: "reuse", email } : { kind: "reuse" };
1639
+ }
1455
1640
  // The actionable timeout guidance, shared by both routes out of an unapproved
1456
1641
  // device flow (the server's 410 and the client's own deadline) so they can never
1457
1642
  // drift into saying different things.
@@ -2178,6 +2363,22 @@ function withFetchDiagnostics(fetchFn) {
2178
2363
  function normalizeApiUrl(value) {
2179
2364
  return value.replace(/\/+$/g, "");
2180
2365
  }
2366
+ // The dashboard that manages what this CLI just deployed lives on the same
2367
+ // control plane as the API it talked to: api-<env>.9pm.ai is served beside
2368
+ // <env>.9pm.ai, and api.9pm.ai beside the public site. An origin outside that
2369
+ // shape (local dev, an unparseable URL) names production, the only dashboard a
2370
+ // tester could hold an account on.
2371
+ export function dashboardUrlForApiUrl(apiUrl) {
2372
+ let host;
2373
+ try {
2374
+ host = new URL(normalizeApiUrl(apiUrl)).hostname.toLowerCase();
2375
+ }
2376
+ catch {
2377
+ return PUBLIC_DASHBOARD_URL;
2378
+ }
2379
+ const environment = /^api-([a-z0-9-]+)\.9pm\.ai$/.exec(host);
2380
+ return environment ? `https://${environment[1]}.9pm.ai/dashboard` : PUBLIC_DASHBOARD_URL;
2381
+ }
2181
2382
  function pathFromUpload(upload, deploymentId) {
2182
2383
  const marker = `/v1/uploads/${encodeURIComponent(deploymentId)}/`;
2183
2384
  const index = upload.path.indexOf(marker);
@@ -2222,6 +2423,8 @@ const VALID_DEPLOY_FLAGS = new Set([
2222
2423
  "memory",
2223
2424
  "no-build",
2224
2425
  "bundle",
2426
+ "assets",
2427
+ "no-assets",
2225
2428
  ]);
2226
2429
  export function parseArgs(argv) {
2227
2430
  const command = argv[0];
@@ -2248,7 +2451,12 @@ export function parseArgs(argv) {
2248
2451
  }
2249
2452
  if (command === "login") {
2250
2453
  const flags = parseFlags(argv);
2251
- return { kind: "login", apiUrl: readApiUrl(flags, DEFAULT_API_URL), allowFileCredentialStore: flags.has("allow-file-credential-store") };
2454
+ return {
2455
+ kind: "login",
2456
+ apiUrl: readApiUrl(flags, DEFAULT_API_URL),
2457
+ allowFileCredentialStore: flags.has("allow-file-credential-store"),
2458
+ fresh: flags.has("fresh"),
2459
+ };
2252
2460
  }
2253
2461
  if (command === "signup") {
2254
2462
  const flags = parseFlags(argv);
@@ -2450,6 +2658,23 @@ export function parseArgs(argv) {
2450
2658
  options.runtime = "worker";
2451
2659
  options.bundleDir = bundleDir;
2452
2660
  }
2661
+ const assetsFlag = flags.get("assets");
2662
+ if (assetsFlag !== undefined || flags.has("no-assets")) {
2663
+ if (assetsFlag !== undefined && flags.has("no-assets")) {
2664
+ throw new Error("--assets and --no-assets cannot be combined.");
2665
+ }
2666
+ if (assetsFlag === true || assetsFlag === "") {
2667
+ throw new Error("--assets needs a directory, for example --assets public. Use --no-assets if nothing should be public.");
2668
+ }
2669
+ if (options.bundleDir) {
2670
+ throw new Error("--assets and --no-assets apply to --entry worker deploys; a --bundle deploy already serves only its assets directory.");
2671
+ }
2672
+ const workerRuntime = options.runtime === "worker" || (options.runtime === undefined && options.entry !== undefined);
2673
+ if (!workerRuntime) {
2674
+ throw new Error("--assets and --no-assets apply to worker deploys (--runtime worker --entry <file>); a static site serves every file.");
2675
+ }
2676
+ options.publicAssets = typeof assetsFlag === "string" ? normalizeAssetsDirectory(assetsFlag) : NO_PUBLIC_ASSETS;
2677
+ }
2453
2678
  if (options.runtime === "container" && !options.port) {
2454
2679
  throw new Error("Container deploys require --port <port>.");
2455
2680
  }
@@ -3157,6 +3382,48 @@ function parseDurationSeconds(value) {
3157
3382
  const seconds = unit === "d" ? amount * 24 * 60 * 60 : unit === "h" ? amount * 60 * 60 : unit === "m" ? amount * 60 : amount;
3158
3383
  return Math.min(seconds, 7 * 24 * 60 * 60);
3159
3384
  }
3385
+ // #1847: what an entry-based worker deploy publishes. Shown on every deploy that still
3386
+ // uses the legacy shape rather than only under `--check`, because the app that leaks is
3387
+ // the one whose author never thought to check.
3388
+ const PUBLIC_FILE_LIST_LIMIT = 10;
3389
+ export function workerPublicFiles(paths, serverEntry, selection) {
3390
+ const files = [];
3391
+ for (const filePath of paths) {
3392
+ const url = publicAssetPath(filePath, serverEntry, selection);
3393
+ if (url !== null) {
3394
+ files.push({ path: filePath, url });
3395
+ }
3396
+ }
3397
+ return files;
3398
+ }
3399
+ export function legacyPublicAssetsWarning(serverEntry, publicPaths) {
3400
+ const shown = publicPaths.slice(0, PUBLIC_FILE_LIST_LIMIT);
3401
+ const more = publicPaths.length - shown.length;
3402
+ return [
3403
+ `this worker app serves every file in the deploy directory except its entry (${serverEntry}) at a public URL - ${publicPaths.length} file(s).`,
3404
+ "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.",
3405
+ `Public now: ${shown.length > 0 ? shown.join(", ") : "none"}${more > 0 ? `, and ${more} more (9pm deploy --check lists them all)` : ""}`,
3406
+ ].join("\n ");
3407
+ }
3408
+ export function privateLookingPublicWarning(paths, selection) {
3409
+ const shown = paths.slice(0, PUBLIC_FILE_LIST_LIMIT);
3410
+ const more = paths.length - shown.length;
3411
+ const remedy = selection === null
3412
+ ? "Choose a public directory with --assets <dir>, or move them out of the deploy directory."
3413
+ : `Move them out of ${selection}.`;
3414
+ return `these files look like server code or leftovers, and anyone can download them: ${shown.join(", ")}${more > 0 ? `, and ${more} more` : ""}. ${remedy}`;
3415
+ }
3416
+ // The files a deploy sends: everything, unless a worker selection narrows it to the entry
3417
+ // and the public files. The same set the server holds to the per-file limit.
3418
+ function uploadedFiles(manifest, serverEntry, selection) {
3419
+ if (!serverEntry || selection === null) {
3420
+ return manifest;
3421
+ }
3422
+ return manifest.filter((file) => file.path === serverEntry || publicAssetPath(file.path, serverEntry, selection) !== null);
3423
+ }
3424
+ function missingAssetsDirectoryMessage(selection) {
3425
+ return `--assets ${selection.slice(0, -1)}: no files were found under ${selection} in the deploy directory.`;
3426
+ }
3160
3427
  function normalizeServerEntry(value) {
3161
3428
  const normalized = value.trim().replaceAll("\\", "/").replace(/^\.\/+/, "");
3162
3429
  if (!normalized || normalized.startsWith("/") || normalized.split("/").includes("..")) {
@@ -3170,18 +3437,17 @@ async function validateWorkerEntrySource(source, filePath) {
3170
3437
  if (/\bfrom\s+["'][^"']+["']/.test(source) || /\bimport\s*\(/.test(source) || /(^|\n)\s*import\s+["'][^"']+["']/.test(source)) {
3171
3438
  errors.push("Worker runtime entry must be bundled into one module with no external imports.");
3172
3439
  }
3173
- const hasLiteralDefault = /\bexport\s+default\b/.test(source);
3174
- const hasDefaultReexport = /export\s*\{\s*([A-Za-z_$][\w$]*)\s+as\s+default\s*,?\s*\}\s*;?\s*$/.test(source);
3175
- if (!hasLiteralDefault && !hasDefaultReexport) {
3176
- errors.push(`Worker runtime entry ${filePath} must export a default fetch handler.`);
3177
- }
3440
+ // Whether the entry has a default export is decided by evaluating it as an ES module,
3441
+ // which is what the server uploads unmodified, not by matching its text:
3442
+ // `export { handler as default, helper }`, a comment before `default`, and a string or
3443
+ // escaped export name are all valid defaults a text match would refuse (#1851).
3178
3444
  if (errors.length === 0) {
3179
- const transformed = hasLiteralDefault
3180
- ? source
3181
- : `${source.replace(/export\s*\{\s*([A-Za-z_$][\w$]*)\s+as\s+default\s*,?\s*\}\s*;?\s*$/, "export default $1;")}`;
3182
3445
  try {
3183
- const module = await import(`data:text/javascript;base64,${Buffer.from(transformed).toString("base64")}`);
3184
- if (typeof module.default?.fetch !== "function" && typeof module.default !== "function") {
3446
+ const module = await import(`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`);
3447
+ if (!("default" in module)) {
3448
+ errors.push(`Worker runtime entry ${filePath} must export a default fetch handler.`);
3449
+ }
3450
+ else if (typeof module.default?.fetch !== "function" && typeof module.default !== "function") {
3185
3451
  errors.push("Default export must be a function or an object with fetch(request, env, ctx).");
3186
3452
  }
3187
3453
  }
@@ -3985,6 +4251,12 @@ async function main() {
3985
4251
  const subject = moduleCount === 1 ? "entry" : `${moduleCount} server modules`;
3986
4252
  console.log(`Server code (${subject}): ${compressedBytes.toLocaleString("en-US")} bytes compressed of ${MAX_WORKER_ENTRY_COMPRESSED_BYTES.toLocaleString("en-US")} allowed.`);
3987
4253
  }
4254
+ if (check.publicFiles) {
4255
+ console.log(check.publicFiles.length > 0 ? `Public files (${check.publicFiles.length}):` : "Public files: none.");
4256
+ for (const file of check.publicFiles) {
4257
+ console.log(` ${file.path} -> ${file.url}`);
4258
+ }
4259
+ }
3988
4260
  }
3989
4261
  return;
3990
4262
  }
@@ -4026,7 +4298,7 @@ async function main() {
4026
4298
  if (queued) {
4027
4299
  console.log(`Status: ${result.deployment.status}. Run 9pm apps to check when it becomes active.`);
4028
4300
  }
4029
- console.log(`Manage: ${PUBLIC_DASHBOARD_URL}${command.options.owner ? `?owner=${encodeURIComponent(command.options.owner)}` : ""}`);
4301
+ console.log(`Manage: ${dashboardUrlForApiUrl(command.options.apiUrl)}${command.options.owner ? `?owner=${encodeURIComponent(command.options.owner)}` : ""}`);
4030
4302
  // Gated the same way as the reachability probe above: a programmatic
4031
4303
  // caller injecting fetchFn opted out of the CLI's own network side trips.
4032
4304
  if (!command.options.fetchFn) {
@@ -4038,13 +4310,30 @@ async function main() {
4038
4310
  return;
4039
4311
  }
4040
4312
  if (command.kind === "login") {
4313
+ // Each login used to mint another full-scope key (#1779); keep a working one.
4314
+ if (!command.fresh) {
4315
+ const probe = await probeStoredLogin({ apiUrl: command.apiUrl });
4316
+ if (probe.kind === "reuse") {
4317
+ console.log(`Already logged in${probe.email ? ` as ${probe.email}` : ""}. Run "9pm login --fresh" to replace this machine's credential.`);
4318
+ return;
4319
+ }
4320
+ if (probe.kind === "unverifiable") {
4321
+ throw new Error(`Could not check the credential already stored on this machine (${probe.reason}). Retry, or run "9pm login --fresh" to replace it.`);
4322
+ }
4323
+ }
4041
4324
  const result = await loginDevice({ apiUrl: command.apiUrl, allowFileCredentialStore: command.allowFileCredentialStore });
4042
4325
  console.log(`Logged in${result.email ? ` as ${result.email}` : ""}. Credential stored in ${result.storage}.`);
4326
+ for (const line of replacedCredentialMessages(result, command.apiUrl)) {
4327
+ console.error(line);
4328
+ }
4043
4329
  return;
4044
4330
  }
4045
4331
  if (command.kind === "signup") {
4046
4332
  const result = await signupDevice({ apiUrl: command.apiUrl, allowFileCredentialStore: command.allowFileCredentialStore });
4047
4333
  console.log(`Signed up${result.email ? ` as ${result.email}` : ""}. Credential stored in ${result.storage}.`);
4334
+ for (const line of replacedCredentialMessages(result, command.apiUrl)) {
4335
+ console.error(line);
4336
+ }
4048
4337
  return;
4049
4338
  }
4050
4339
  if (command.kind === "logout") {
@@ -4518,14 +4807,14 @@ function printUsage() {
4518
4807
  console.log(`Usage:
4519
4808
  9pm --version
4520
4809
  9pm deploy [directory] --name <site-name> [--slug <slug>] [--owner <organisation>] [--description "<note>"] [--with-db] [--with-auth] [--with-email]
4521
- 9pm deploy [directory] --runtime worker --entry src/index.js [--with-db]
4810
+ 9pm deploy [directory] --runtime worker --entry src/index.js (--assets <dir> | --no-assets) [--with-db]
4522
4811
  9pm deploy [directory] --runtime container --port 8080 [--health-path /health] [--region auto] [--with-db] [--always-on] [--volume <gb> | --no-volume] [--cpus <n>] [--memory <size>]
4523
4812
  9pm deploy [directory] framework projects (Next.js, Vite) are detected and built automatically; --no-build skips
4524
4813
  9pm deploy [directory] --bundle <dir> deploy a pre-built Cloudflare framework bundle (e.g. .open-next)
4525
4814
  9pm deploy [directory] --description "<note>" optional short note shown next to the version in the dashboard
4526
4815
  9pm deploy [directory] --check
4527
4816
  9pm signup
4528
- 9pm login
4817
+ 9pm login [--fresh] keeps a working stored credential; --fresh replaces it and revokes the old one
4529
4818
  9pm logout [--api-url <url>] [--local] revokes the credential server-side, then removes it from this machine (--local skips the revoke)
4530
4819
  9pm doctor [--json]
4531
4820
  9pm whoami [--json]
@@ -4568,10 +4857,18 @@ Size limits:
4568
4857
  Individual files are limited to ${formatMebibytes(MAX_STATIC_ASSET_FILE_BYTES)}.
4569
4858
  Worker runtime: server code is limited to ${formatMegabytes(MAX_WORKER_ENTRY_COMPRESSED_BYTES)} compressed (gzip) — the ${formatMegabytes(MAX_WORKER_SCRIPT_COMPRESSED_BYTES)} script limit
4570
4859
  less a small reserve for the generated wrapper. That budget covers the --entry module, or
4571
- with --bundle every server module together, since they deploy as one script. Every other
4572
- file in the directory is served from the asset store instead, so it does not count toward
4573
- that limit — keep large images, fonts and datasets as sibling files rather than inlining
4574
- them into the entry. Apps needing more server code than that should deploy as containers.
4860
+ with --bundle every server module together, since they deploy as one script. Public files
4861
+ are served from the asset store instead, so they do not count toward that limit — keep large
4862
+ images, fonts and datasets as public files rather than inlining them into the entry. Apps
4863
+ needing more server code than that should deploy as containers.
4864
+
4865
+ Worker public files:
4866
+ --assets <dir> only the files under <dir> are public, served from the app's root
4867
+ (<dir>/index.html answers on /). Nothing else in the directory is uploaded.
4868
+ --no-assets nothing is public; the entry answers every request.
4869
+ The app remembers the choice, so later deploys need not repeat it; pass the other flag to
4870
+ change it. A worker app that never chose serves EVERY file except its entry publicly, and
4871
+ each deploy warns about it. --check lists the files a deploy would make public.
4575
4872
 
4576
4873
  Container deploys:
4577
4874
  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.60.0",
3
+ "version": "0.64.0",
4
4
  "description": "CLI for deploying built apps to 9pm.ai.",
5
5
  "homepage": "https://9pm.ai",
6
6
  "keywords": [