ninepm 0.36.0 → 0.39.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
@@ -86,10 +86,12 @@ Store and share working files:
86
86
  9pm files put ./report.pdf
87
87
  9pm files share <file-id> --expires 24h
88
88
  9pm files put ./notes.md --project my-app --path notes/release.md
89
- 9pm files share <file-id> --project my-app --login-required
89
+ 9pm files share <file-id> --project my-app --public
90
90
  ```
91
91
 
92
- Personal files and project files both count toward the account file storage limit. Share links expire and are capped at 7 days. Login-required links are owner-only.
92
+ Personal files and project files both count toward the account file storage limit. Share links expire and are capped at 7 days.
93
+
94
+ Share links are **owner-only by default**: the link opens only for you, signed in. Add `--public` when the recipient has no account — that link needs no sign-in, so anyone who obtains the URL can download the file until it expires. Do not use `--public` for anything containing personal data, credentials or a database export.
93
95
 
94
96
  Deploy a first-class Worker runtime app:
95
97
 
@@ -186,7 +188,7 @@ Service token secrets returned by `access service create` are also server-side s
186
188
  9pm files list [--project <site>]
187
189
  9pm files put <local-path> [--path <remote-path>] [--project <site>]
188
190
  9pm files get <file-id> [--output <path>] [--project <site>]
189
- 9pm files share <file-id> [--expires 24h] [--login-required] [--project <site>]
191
+ 9pm files share <file-id> [--expires 24h] [--public] [--project <site>]
190
192
  9pm files delete <file-id> [--project <site>]
191
193
  9pm deploy [directory] --runtime worker --entry <path> [--with-db]
192
194
  9pm delete <site> --confirm <slug>
@@ -223,8 +225,8 @@ Options:
223
225
  - `--project <site>` scopes file commands to an app
224
226
  - `--path <remote-path>` stores a file at a specific path
225
227
  - `--output <path>` writes a downloaded file to disk
226
- - `--expires <duration>` sets public share-link expiry, capped at 7 days
227
- - `--login-required` creates an owner-only share link
228
+ - `--expires <duration>` sets share-link expiry, capped at 7 days
229
+ - `--public` makes a share link downloadable by anyone with the URL, no sign-in (share links are owner-only without it)
228
230
  - `--confirm <slug>` confirms app deletion by requiring the slug
229
231
  - `--provider <google|github|entra>` selects a configured identity provider
230
232
  - `--duration <duration>` sets a service token duration
@@ -1,7 +1,17 @@
1
1
  export declare const MAX_STATIC_ASSET_FILE_BYTES: number;
2
2
  export declare function formatMebibytes(bytes: number): string;
3
+ export declare function formatMegabytes(bytes: number): string;
4
+ export declare function formatKilobytes(bytes: number): string;
3
5
  export declare function assetFileTooLargeMessage(filePath: string, size: number): string;
4
6
  export declare function oversizedAssets<T extends {
5
7
  path: string;
6
8
  size: number;
7
9
  }>(files: readonly T[]): T[];
10
+ export declare const MAX_WORKER_SCRIPT_COMPRESSED_BYTES: number;
11
+ export declare const WORKER_WRAPPER_COMPRESSED_RESERVE_BYTES = 100000;
12
+ export declare const MAX_WORKER_ENTRY_COMPRESSED_BYTES: number;
13
+ export declare const WORKER_ENTRY_COMPRESSED_WARN_BYTES: number;
14
+ export declare function workerEntryTooLargeMessage(filePath: string, rawBytes: number, compressedBytes: number): string;
15
+ export declare function workerEntryNearLimitMessage(filePath: string, rawBytes: number, compressedBytes: number): string;
16
+ export declare function workerBundleTooLargeMessage(moduleCount: number, rawBytes: number, compressedBytes: number): string;
17
+ export declare function workerBundleNearLimitMessage(moduleCount: number, rawBytes: number, compressedBytes: number): string;
@@ -20,6 +20,18 @@ export function formatMebibytes(bytes) {
20
20
  // Whole numbers read cleanly (25 MiB); fractional sizes get one decimal.
21
21
  return Number.isInteger(mib) ? `${mib} MiB` : `${mib.toFixed(1)} MiB`;
22
22
  }
23
+ // Decimal formatters for the worker script/entry budget. That limit is documented
24
+ // in decimal MB (below), so rendering it in MiB would both misstate the unit and —
25
+ // at 10 MB vs 9.5 MiB — print two different-looking numbers for the same ceiling.
26
+ export function formatMegabytes(bytes) {
27
+ const mb = Math.max(0, bytes) / 1_000_000;
28
+ return Number.isInteger(mb) ? `${mb} MB` : `${mb.toFixed(1)} MB`;
29
+ }
30
+ // Sub-megabyte figures (the wrapper reserve) would render as a misleading "0.1 MB".
31
+ export function formatKilobytes(bytes) {
32
+ const kb = Math.max(0, bytes) / 1000;
33
+ return Number.isInteger(kb) ? `${kb} kB` : `${kb.toFixed(1)} kB`;
34
+ }
23
35
  export function assetFileTooLargeMessage(filePath, size) {
24
36
  return `File "${filePath}" is ${formatMebibytes(size)}; individual files are limited to ${formatMebibytes(MAX_STATIC_ASSET_FILE_BYTES)}.`;
25
37
  }
@@ -28,3 +40,81 @@ export function assetFileTooLargeMessage(filePath, size) {
28
40
  export function oversizedAssets(files) {
29
41
  return files.filter((file) => file.size > MAX_STATIC_ASSET_FILE_BYTES);
30
42
  }
43
+ // The worker-runtime server entry is the one deploy-directory file inlined into
44
+ // the generated Worker script (`buildHostedAppWorker`), so it — unlike every
45
+ // other file, which rides the Workers Assets API — counts against the runtime's
46
+ // script-size budget. That budget is enforced on the *compressed* upload, so
47
+ // this limit is measured on gzipped bytes, not raw ones. Measuring raw bytes is
48
+ // what made `deploy --check` reject bundles the real deploy path published
49
+ // happily (#645): a 2.4 MB entry of inlined SVG/base64 compresses to well under
50
+ // a megabyte, and the platform only ever saw the compressed figure.
51
+ // The runtime's ceiling on the whole generated script, compressed. Documented as
52
+ // "10 MB after compression" on the paid plan:
53
+ // https://developers.cloudflare.com/workers/platform/limits/
54
+ // Read as decimal (10,000,000), not 10 MiB. The doc's unit is ambiguous, so take the
55
+ // smaller reading: a ceiling below the real one only rejects deploys slightly early,
56
+ // while one above it lets an entry pass every local check and still fail the upload.
57
+ // 10 MiB would sit 485,760 bytes high — far more than the wrapper reserve below could
58
+ // absorb, which would defeat the point of having a reserve at all.
59
+ export const MAX_WORKER_SCRIPT_COMPRESSED_BYTES = 10 * 1_000_000;
60
+ // What the app's own code does *not* get to spend: whichever wrapper the platform
61
+ // generates is uploaded as part of the same script. Two consumers spend this reserve,
62
+ // depending on the deploy shape:
63
+ // - single `--entry` workers: `buildHostedAppWorker` (analytics, end-user auth,
64
+ // Access/vanity-host checks, asset fallback), inlined around the entry;
65
+ // - `--bundle` deploys: `buildBundleWrapperModule`, added as the script's main module
66
+ // alongside the bundle's own modules.
67
+ // Both were measured, and both are near-flat across feature combinations: the hosted
68
+ // wrapper is ~36 KB raw / ~12 KB gzipped, the bundle wrapper ~23 KB raw / ~8 KB gzipped.
69
+ // 100 kB is therefore a ~8x margin on the larger of the two, with room for growth in either.
70
+ //
71
+ // Without this reserve, code just under the ceiling passes both `--check` and the pre-create
72
+ // deploy guard, then still blows the real upload once the wrapper is added — failing after
73
+ // the project and slug have been created, which is the exact late failure the preflight
74
+ // exists to prevent.
75
+ export const WORKER_WRAPPER_COMPRESSED_RESERVE_BYTES = 100_000;
76
+ // The budget actually available to the user's entry module.
77
+ export const MAX_WORKER_ENTRY_COMPRESSED_BYTES = MAX_WORKER_SCRIPT_COMPRESSED_BYTES - WORKER_WRAPPER_COMPRESSED_RESERVE_BYTES;
78
+ // Warn once the entry is within 20% of its budget.
79
+ export const WORKER_ENTRY_COMPRESSED_WARN_BYTES = Math.floor(MAX_WORKER_ENTRY_COMPRESSED_BYTES * 0.8);
80
+ // Exact bytes lead, with the rounded figure in parentheses for readability. Rounding
81
+ // alone is not enough at the boundary: a size just over the cap renders identically to
82
+ // the cap at one decimal, so the message read "is 9.9 MB compressed ... limited to
83
+ // 9.9 MB compressed" for anything in 9,900,001..~9,950,000. That is the same
84
+ // two-numbers-you-cannot-reconcile confusion this message exists to remove, and the
85
+ // boundary is exactly where a rejected user lands. Fixed locale so the grouping is
86
+ // stable regardless of the machine's default.
87
+ function exactWithRounded(bytes) {
88
+ return `${bytes.toLocaleString("en-US")} bytes (${formatMegabytes(bytes)})`;
89
+ }
90
+ // Both messages name the measured dimension explicitly. The previous wording
91
+ // ("is 2487048 bytes; limit is 1000000 bytes") gave no way to tell that the two
92
+ // numbers described different things — raw against a compressed-derived cap.
93
+ export function workerEntryTooLargeMessage(filePath, rawBytes, compressedBytes) {
94
+ return (`Worker runtime entry "${filePath}" is ${exactWithRounded(compressedBytes)} compressed, ` +
95
+ `${exactWithRounded(rawBytes)} raw; the entry is limited to ${exactWithRounded(MAX_WORKER_ENTRY_COMPRESSED_BYTES)} compressed ` +
96
+ `(the ${formatMegabytes(MAX_WORKER_SCRIPT_COMPRESSED_BYTES)} script limit, less ${formatKilobytes(WORKER_WRAPPER_COMPRESSED_RESERVE_BYTES)} reserved for the generated wrapper). ` +
97
+ `Move large static assets out of the entry into sibling files in the deploy directory — they are served ` +
98
+ `from the asset store and do not count toward this limit — or deploy as a container.`);
99
+ }
100
+ export function workerEntryNearLimitMessage(filePath, rawBytes, compressedBytes) {
101
+ return (`Worker runtime entry "${filePath}" is ${exactWithRounded(compressedBytes)} compressed, ` +
102
+ `${exactWithRounded(rawBytes)} raw — over 80% of the ${exactWithRounded(MAX_WORKER_ENTRY_COMPRESSED_BYTES)} compressed entry limit.`);
103
+ }
104
+ // Framework-bundle equivalents (#660). A bundle's modules are uploaded as one script,
105
+ // so the same budget applies to their combined compressed size rather than to any single
106
+ // file — worth saying explicitly, because the per-file asset cap is a different limit and
107
+ // conflating the two is what put a wrong figure in the hosted skill in the first place.
108
+ // The remedy differs too: a bundle's assets already live outside the modules, so the
109
+ // actionable advice is about the server build, not about un-inlining assets.
110
+ export function workerBundleTooLargeMessage(moduleCount, rawBytes, compressedBytes) {
111
+ return (`Worker bundle's ${moduleCount} server module${moduleCount === 1 ? "" : "s"} total ${exactWithRounded(compressedBytes)} compressed, ` +
112
+ `${exactWithRounded(rawBytes)} raw; server code is limited to ${exactWithRounded(MAX_WORKER_ENTRY_COMPRESSED_BYTES)} compressed ` +
113
+ `(the ${formatMegabytes(MAX_WORKER_SCRIPT_COMPRESSED_BYTES)} script limit, less ${formatKilobytes(WORKER_WRAPPER_COMPRESSED_RESERVE_BYTES)} reserved for the generated wrapper). ` +
114
+ `Files under the bundle's assets directory are served from the asset store and do not count toward this limit, ` +
115
+ `so move what you can there; otherwise trim the server build or deploy as a container.`);
116
+ }
117
+ export function workerBundleNearLimitMessage(moduleCount, rawBytes, compressedBytes) {
118
+ return (`Worker bundle's ${moduleCount} server module${moduleCount === 1 ? "" : "s"} total ${exactWithRounded(compressedBytes)} compressed, ` +
119
+ `${exactWithRounded(rawBytes)} raw — over 80% of the ${exactWithRounded(MAX_WORKER_ENTRY_COMPRESSED_BYTES)} compressed server-code limit.`);
120
+ }
package/dist/index.d.ts CHANGED
@@ -113,7 +113,7 @@ export interface CreatedServiceToken {
113
113
  clientSecret: string;
114
114
  }
115
115
  export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
116
- export declare const CLI_VERSION = "0.36.0";
116
+ export declare const CLI_VERSION = "0.39.0";
117
117
  type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
118
118
  export interface CredentialDiagnostics {
119
119
  platform: NodeJS.Platform;
@@ -173,6 +173,11 @@ export interface DeployCheckResult {
173
173
  errors: string[];
174
174
  files: number;
175
175
  bytes: number;
176
+ serverCode?: {
177
+ rawBytes: number;
178
+ compressedBytes: number;
179
+ moduleCount: number;
180
+ };
176
181
  }
177
182
  export type DetectedFramework = "nextjs" | "astro" | "sveltekit" | "vite";
178
183
  export declare function detectFramework(directory: string): DetectedFramework | null;
@@ -187,6 +192,14 @@ export declare function deployDirectory(initialOptions: DeployOptions): Promise<
187
192
  export type ApiReachability = "reachable" | "blocked";
188
193
  export declare function probeApiReachability(apiUrl: string, fetchFn: typeof fetch, timeoutMs?: number): Promise<ApiReachability>;
189
194
  export declare function apiUnreachableGuidance(apiUrl: string): string;
195
+ export declare function measureWorkerEntry(source: Uint8Array): {
196
+ rawBytes: number;
197
+ compressedBytes: number;
198
+ };
199
+ export declare function measureWorkerModuleFiles(root: string, modulePaths: readonly string[]): Promise<{
200
+ rawBytes: number;
201
+ compressedBytes: number;
202
+ }>;
190
203
  export declare function checkDeployDirectory(options: DeployOptions): Promise<DeployCheckResult>;
191
204
  export declare function whoami(options: ApiOptions): Promise<WhoamiResult>;
192
205
  export declare function publicWhoamiResult(result: WhoamiResult): PublicWhoamiResult;
package/dist/index.js CHANGED
@@ -7,12 +7,14 @@ import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, openAsBlob, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
9
9
  import { fileURLToPath } from "node:url";
10
+ import { once } from "node:events";
11
+ import { createGzip, gzipSync } from "node:zlib";
10
12
  import { Agent, fetch as undiciFetch } from "undici";
11
13
  import { MAX_DEPLOY_DESCRIPTION_LENGTH, normalizeDeployDescription } from "./deploy-description.js";
12
- import { assetFileTooLargeMessage, oversizedAssets } from "./deploy-limits.js";
14
+ 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";
13
15
  import { buildFileManifest, readManifestFile } from "./manifest.js";
14
16
  import { API_KEY_HELP, API_URL_HELP, CLI_CLIENT_ID, DEFAULT_API_URL, reportCliError } from "./cli-config.js";
15
- export const CLI_VERSION = "0.36.0";
17
+ export const CLI_VERSION = "0.39.0";
16
18
  const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
17
19
  const TOKEN_SERVICE = "9pm";
18
20
  const TOKEN_ACCOUNT = "default";
@@ -21,8 +23,6 @@ const WINDOWS_CREDENTIAL_FILE = "credential.dpapi";
21
23
  const PLAINTEXT_CREDENTIAL_FILE = "credential.plaintext.json";
22
24
  const ALLOW_FILE_CREDENTIAL_STORE_ENV = "NINEPM_ALLOW_FILE_CREDENTIAL_STORE";
23
25
  const DISABLE_NATIVE_CREDENTIAL_STORE_ENV = "NINEPM_DISABLE_NATIVE_CREDENTIAL_STORE";
24
- const WORKER_ENTRY_LIMIT_BYTES = 1_000_000;
25
- const WORKER_ENTRY_WARN_BYTES = Math.floor(WORKER_ENTRY_LIMIT_BYTES * 0.8);
26
26
  const DEFAULT_CONTAINER_POLL_TIMEOUT_MS = 15 * 60 * 1000;
27
27
  export function detectFramework(directory) {
28
28
  let dependencies = {};
@@ -273,7 +273,11 @@ async function runDeploy(initialOptions, slugHolder) {
273
273
  // across every deploy call — create, upload, finalize, poll — with one wrap.
274
274
  const fetchFn = withFetchDiagnostics(options.fetchFn ?? fetch);
275
275
  const apiUrl = normalizeApiUrl(options.apiUrl);
276
- const runtime = options.runtime ?? (options.entry ? "worker" : "static");
276
+ // A bundle is a worker deploy. Parsing `--bundle` already forces runtime to "worker", so
277
+ // this is a no-op for every CLI invocation; it matters for programmatic callers that pass
278
+ // bundleDir alone, which would otherwise record the deployment as static here while
279
+ // checkDeployDirectory classified the same input as a worker (#661 self-review).
280
+ const runtime = options.runtime ?? (options.entry || options.bundleDir ? "worker" : "static");
277
281
  if (!options.runtime && runtime === "static" && hasDockerfile(options.directory)) {
278
282
  throw new Error(DOCKERFILE_STATIC_HINT);
279
283
  }
@@ -403,6 +407,8 @@ async function runDeploy(initialOptions, slugHolder) {
403
407
  }
404
408
  const bundle = options.bundleDir ? bundleMetadataFromManifest(manifest, contentRoot) : null;
405
409
  const serverEntry = runtime === "worker" && !bundle ? normalizeServerEntry(options.entry ?? "src/index.js") : undefined;
410
+ // Set by the size preflight below and reused by the upload loop, so the entry is read once.
411
+ let serverEntryBytes;
406
412
  if (serverEntry && !manifest.some((file) => file.path === serverEntry)) {
407
413
  throw new Error(`Worker runtime entry file was not found in the deploy directory: ${serverEntry}`);
408
414
  }
@@ -413,6 +419,39 @@ async function runDeploy(initialOptions, slugHolder) {
413
419
  if (oversized) {
414
420
  throw new Error(assetFileTooLargeMessage(oversized.path, oversized.size));
415
421
  }
422
+ // Same worker-entry budget `--check` applies, on the same compressed dimension, so
423
+ // the two paths agree (#645). This rejects only what the runtime would refuse at
424
+ // publish time anyway — it turns an opaque late failure into a local one, before
425
+ // the project is created and the slug claimed.
426
+ if (serverEntry) {
427
+ // Held for the upload below: re-reading the entry there would read a file of up to
428
+ // 25 MiB twice on every worker deploy.
429
+ serverEntryBytes = await readManifestFile(contentRoot, serverEntry);
430
+ const { rawBytes, compressedBytes } = measureWorkerEntry(serverEntryBytes);
431
+ if (compressedBytes > MAX_WORKER_ENTRY_COMPRESSED_BYTES) {
432
+ throw new Error(workerEntryTooLargeMessage(serverEntry, rawBytes, compressedBytes));
433
+ }
434
+ if (compressedBytes >= WORKER_ENTRY_COMPRESSED_WARN_BYTES) {
435
+ // Same band, same wording, same channel as `--check` (see the CLI entry point), so a
436
+ // near-limit entry doesn't deploy silently and then get rejected on a slightly
437
+ // larger build with no prior notice.
438
+ console.warn(`Warning: ${workerEntryNearLimitMessage(serverEntry, rawBytes, compressedBytes)}`);
439
+ }
440
+ }
441
+ // Bundles carry the same budget, applied to all their server modules together (#660).
442
+ // Without this the `--bundle` path — the one the hosted skill's size guidance is about —
443
+ // was the only worker shape with no local size check at all, so an over-budget framework
444
+ // build failed at publish instead, after the project and slug existed.
445
+ if (bundle) {
446
+ const modulePaths = bundleModulePaths(bundle);
447
+ const { rawBytes, compressedBytes } = await measureWorkerModuleFiles(contentRoot, modulePaths);
448
+ if (compressedBytes > MAX_WORKER_ENTRY_COMPRESSED_BYTES) {
449
+ throw new Error(workerBundleTooLargeMessage(modulePaths.length, rawBytes, compressedBytes));
450
+ }
451
+ if (compressedBytes >= WORKER_ENTRY_COMPRESSED_WARN_BYTES) {
452
+ console.warn(`Warning: ${workerBundleNearLimitMessage(modulePaths.length, rawBytes, compressedBytes)}`);
453
+ }
454
+ }
416
455
  const projectInput = { name: options.name ?? path.basename(path.resolve(options.directory)) };
417
456
  const { project, created: projectCreated } = await createProject(fetchFn, apiUrl, options.token, options.slug ? { ...projectInput, slug: options.slug } : projectInput);
418
457
  slugHolder.slug = project.slug;
@@ -448,7 +487,9 @@ async function runDeploy(initialOptions, slugHolder) {
448
487
  for (const upload of created.uploads) {
449
488
  const file = manifest.find((candidate) => candidate.path === upload.path.split("/").slice(4).map(decodeURIComponent).join("/"));
450
489
  const manifestPath = file?.path ?? pathFromUpload(upload, created.deployment.id);
451
- const body = await readManifestFile(contentRoot, manifestPath);
490
+ // The worker entry was already read for the size preflight above; reuse those bytes
491
+ // rather than reading a file of up to 25 MiB a second time.
492
+ const body = manifestPath === serverEntry && serverEntryBytes ? serverEntryBytes : await readManifestFile(contentRoot, manifestPath);
452
493
  const response = await fetchFn(upload.uploadUrl, {
453
494
  method: upload.method,
454
495
  headers: {
@@ -469,8 +510,76 @@ async function runDeploy(initialOptions, slugHolder) {
469
510
  url: finalized.url,
470
511
  };
471
512
  }
513
+ // Measure the worker-runtime entry the way the platform's script-size budget is
514
+ // actually applied: the entry is inlined into the generated Worker script, and the
515
+ // budget is enforced against the compressed upload. gzipped bytes are therefore
516
+ // the comparable figure; the raw size is carried alongside for the error message
517
+ // only, so a report can name both dimensions instead of silently mixing them.
518
+ //
519
+ // Deliberately zlib's default level rather than 9: the platform's own compression
520
+ // settings are unknown, and a weaker setting yields a slightly *larger* figure, so
521
+ // the guard errs toward rejecting early rather than passing an entry the real upload
522
+ // would refuse. It is also markedly cheaper on a large entry, and this now runs on
523
+ // every worker deploy, not just `--check`.
524
+ export function measureWorkerEntry(source) {
525
+ return { rawBytes: source.byteLength, compressedBytes: gzipSync(source).byteLength };
526
+ }
527
+ // A framework bundle's modules all ride one script upload, so the budget applies to their
528
+ // combined compressed size (#660) — one gzip pass over the concatenation, not the sum of
529
+ // each module's own gzip: separate streams share no dictionary, and on a many-module bundle
530
+ // (OpenNext emits hundreds) summing overstates the total enough to reject a bundle the
531
+ // platform would accept, which is the over-strict failure #645 was about.
532
+ //
533
+ // Streamed rather than concatenated in memory. Only the compressed *length* is needed, so
534
+ // output chunks are counted and dropped, and modules are read one at a time: peak memory is
535
+ // one module plus gzip's window regardless of bundle size. Buffering every module and then
536
+ // copying them into one combined buffer peaked above twice the raw bundle and could be
537
+ // OOM-killed before it managed to report the size error it existed to report.
538
+ //
539
+ // It deliberately does not stop early once the limit is passed. The messages report exact
540
+ // byte counts — that precision is what keeps them unambiguous at the boundary — and a floor
541
+ // like "at least N bytes" would give that up to save a second on an already-failing deploy.
542
+ export async function measureWorkerModuleFiles(root, modulePaths) {
543
+ const gzip = createGzip();
544
+ let rawBytes = 0;
545
+ let compressedBytes = 0;
546
+ // Attaching a data listener puts the stream in flowing mode, so "end" arrives once the
547
+ // whole compressed output has been counted.
548
+ gzip.on("data", (chunk) => {
549
+ compressedBytes += chunk.byteLength;
550
+ });
551
+ const finished = new Promise((resolve, reject) => {
552
+ gzip.once("end", () => resolve());
553
+ gzip.once("error", reject);
554
+ });
555
+ // A read failing mid-bundle leaves the loop without awaiting `finished`. Register a
556
+ // handler now so that path cannot surface as an unhandled rejection; `await finished`
557
+ // below still observes a real error, since a promise may carry several handlers.
558
+ finished.catch(() => { });
559
+ try {
560
+ for (const modulePath of modulePaths) {
561
+ const bytes = await readManifestFile(root, modulePath);
562
+ rawBytes += bytes.byteLength;
563
+ if (!gzip.write(bytes)) {
564
+ // Respect backpressure so a large bundle cannot balloon the stream's internal queue.
565
+ await once(gzip, "drain");
566
+ }
567
+ }
568
+ gzip.end();
569
+ await finished;
570
+ }
571
+ catch (error) {
572
+ // Don't leave a half-written stream alive when a module can't be read.
573
+ gzip.destroy();
574
+ throw error;
575
+ }
576
+ return { rawBytes, compressedBytes };
577
+ }
472
578
  export async function checkDeployDirectory(options) {
473
- const runtime = options.runtime ?? (options.entry ? "worker" : "static");
579
+ // `--bundle` implies the worker runtime, matching runDeploy's derivation exactly. Without
580
+ // this a bundle check fell through to "static" and silently graded a framework bundle as
581
+ // a static site: no server-code check, and a spurious missing-index.html warning (#660).
582
+ const runtime = options.runtime ?? (options.entry || options.bundleDir ? "worker" : "static");
474
583
  if (runtime === "container") {
475
584
  const warnings = [];
476
585
  const errors = [];
@@ -486,11 +595,42 @@ export async function checkDeployDirectory(options) {
486
595
  }
487
596
  return { ok: errors.length === 0, warnings, errors, files: 0, bytes: 0 };
488
597
  }
489
- const manifest = await buildFileManifest(options.directory);
490
- const serverEntry = runtime === "worker" ? normalizeServerEntry(options.entry ?? "src/index.js") : undefined;
598
+ // Mirror runDeploy: a bundle deploy reads from `--bundle <dir>` rather than the project
599
+ // root, and drops source maps. Checking the wrong directory is how `--check` and `deploy`
600
+ // drift apart, which is the whole failure mode this guard exists to prevent.
601
+ const contentRoot = options.bundleDir ? path.resolve(options.directory, options.bundleDir) : options.directory;
602
+ if (options.bundleDir) {
603
+ // `--check` reports problems rather than throwing them. Before this path knew about
604
+ // bundles it never touched the bundle directory, so a wrong `--bundle` argument now
605
+ // reaches the manifest walk and would otherwise surface as a raw ENOENT, not a finding.
606
+ const stats = statSync(contentRoot, { throwIfNoEntry: false });
607
+ if (!stats?.isDirectory()) {
608
+ return { ok: false, warnings: [], errors: [`Bundle directory was not found: ${contentRoot}`], files: 0, bytes: 0 };
609
+ }
610
+ }
611
+ let manifest = await buildFileManifest(contentRoot);
612
+ if (options.bundleDir) {
613
+ manifest = manifest.filter((file) => !file.path.endsWith(".map"));
614
+ }
491
615
  const totalBytes = manifest.reduce((sum, file) => sum + file.size, 0);
492
616
  const warnings = [];
493
617
  const errors = [];
618
+ // Reported even when the check passes, so `--check` actually prints the compressed figure
619
+ // the hosted skill tells agents to read (#661 review). Warnings only fire in the top 20%
620
+ // of the budget, so without this a healthy bundle produced no size information at all.
621
+ let serverCode;
622
+ // bundleMetadataFromManifest throws when the layout is wrong; `--check` reports that as a
623
+ // finding rather than an exception, since reporting problems is its entire job.
624
+ let bundle = null;
625
+ if (options.bundleDir) {
626
+ try {
627
+ bundle = bundleMetadataFromManifest(manifest, contentRoot);
628
+ }
629
+ catch (error) {
630
+ errors.push(error instanceof Error ? error.message : String(error));
631
+ }
632
+ }
633
+ const serverEntry = runtime === "worker" && !options.bundleDir ? normalizeServerEntry(options.entry ?? "src/index.js") : undefined;
494
634
  if (manifest.length === 0) {
495
635
  errors.push("Directory contains no deployable files.");
496
636
  }
@@ -511,19 +651,35 @@ export async function checkDeployDirectory(options) {
511
651
  errors.push(`Worker runtime entry file was not found: ${serverEntry}`);
512
652
  }
513
653
  else {
514
- if (entry.size > WORKER_ENTRY_LIMIT_BYTES) {
515
- errors.push(`Worker runtime entry is ${entry.size} bytes; limit is ${WORKER_ENTRY_LIMIT_BYTES} bytes.`);
654
+ const bytes = await readManifestFile(contentRoot, serverEntry);
655
+ const { rawBytes, compressedBytes } = measureWorkerEntry(bytes);
656
+ serverCode = { rawBytes, compressedBytes, moduleCount: 1 };
657
+ if (compressedBytes > MAX_WORKER_ENTRY_COMPRESSED_BYTES) {
658
+ errors.push(workerEntryTooLargeMessage(serverEntry, rawBytes, compressedBytes));
516
659
  }
517
- else if (entry.size >= WORKER_ENTRY_WARN_BYTES) {
518
- warnings.push(`Worker runtime entry is over 80% of the ${WORKER_ENTRY_LIMIT_BYTES} byte limit.`);
660
+ else if (compressedBytes >= WORKER_ENTRY_COMPRESSED_WARN_BYTES) {
661
+ warnings.push(workerEntryNearLimitMessage(serverEntry, rawBytes, compressedBytes));
519
662
  }
520
- const source = new TextDecoder().decode(await readManifestFile(options.directory, serverEntry));
521
- const bundle = await validateWorkerEntrySource(source, serverEntry);
522
- warnings.push(...bundle.warnings);
523
- errors.push(...bundle.errors);
663
+ const source = new TextDecoder().decode(bytes);
664
+ const entrySource = await validateWorkerEntrySource(source, serverEntry);
665
+ warnings.push(...entrySource.warnings);
666
+ errors.push(...entrySource.errors);
524
667
  }
525
668
  }
526
- return { ok: errors.length === 0, warnings, errors, files: manifest.length, bytes: totalBytes };
669
+ // Same server-code budget the real deploy applies to a bundle (#660), measured the same
670
+ // way — all modules compressed together, assets excluded.
671
+ if (bundle) {
672
+ const modulePaths = bundleModulePaths(bundle);
673
+ const { rawBytes, compressedBytes } = await measureWorkerModuleFiles(contentRoot, modulePaths);
674
+ serverCode = { rawBytes, compressedBytes, moduleCount: modulePaths.length };
675
+ if (compressedBytes > MAX_WORKER_ENTRY_COMPRESSED_BYTES) {
676
+ errors.push(workerBundleTooLargeMessage(modulePaths.length, rawBytes, compressedBytes));
677
+ }
678
+ else if (compressedBytes >= WORKER_ENTRY_COMPRESSED_WARN_BYTES) {
679
+ warnings.push(workerBundleNearLimitMessage(modulePaths.length, rawBytes, compressedBytes));
680
+ }
681
+ }
682
+ return { ok: errors.length === 0, warnings, errors, files: manifest.length, bytes: totalBytes, ...(serverCode ? { serverCode } : {}) };
527
683
  }
528
684
  export async function whoami(options) {
529
685
  return apiRequest(options, "/v1/me", { method: "GET" });
@@ -1841,7 +1997,22 @@ function parseFilesArgs(argv) {
1841
1997
  throw new Error("Missing file id.");
1842
1998
  }
1843
1999
  const expires = readStringFlag(flags, "expires") ?? "24h";
1844
- const mode = flags.has("login-required") ? "login_required" : "public";
2000
+ // Publishing to the open internet is opt-IN: a public share needs no sign-in,
2001
+ // so the bare form must not produce one. `--login-required` is still accepted
2002
+ // (it is now the default) so existing callers keep working. Both flags together
2003
+ // is ambiguous on a control that decides public exposure, so it fails rather
2004
+ // than applying a precedence rule the caller cannot see. See #637.
2005
+ if (flags.has("public") && flags.has("login-required")) {
2006
+ throw new Error("Pass either --public or --login-required, not both.");
2007
+ }
2008
+ // parseFlags stores the next non-`--` token as a flag's value, so `--public false`
2009
+ // sets "false" and `flags.has` is still true — the value reads as a disable and
2010
+ // publishes anyway. Only guarded on this flag: a misread `--login-required <value>`
2011
+ // still resolves to owner-only, which is safe, whereas this one exposes a file.
2012
+ if (typeof flags.get("public") === "string") {
2013
+ throw new Error("--public takes no value. Omit it for an owner-only link.");
2014
+ }
2015
+ const mode = flags.has("public") ? "public" : "login_required";
1845
2016
  return { kind: "files", ...base, action: { kind: "share", fileId, mode, expiresInSeconds: parseDurationSeconds(expires), ...withProject } };
1846
2017
  }
1847
2018
  throw new Error(`Unknown files command: ${subcommand}`);
@@ -2293,6 +2464,13 @@ function bundleMetadataFromManifest(manifest, contentRoot) {
2293
2464
  compatibilityFlags: ["nodejs_compat"],
2294
2465
  };
2295
2466
  }
2467
+ // bundleMetadataFromManifest returns the shape the API stores verbatim
2468
+ // (Record<string, unknown>), so narrow the module list here rather than loosening
2469
+ // that contract just to read it back.
2470
+ function bundleModulePaths(bundle) {
2471
+ const modules = bundle.modules;
2472
+ return Array.isArray(modules) ? modules.filter((entry) => typeof entry === "string") : [];
2473
+ }
2296
2474
  function parseMemorySize(value) {
2297
2475
  const match = value.trim().toLowerCase().match(/^(\d+)\s*(g|gb|m|mb)?$/);
2298
2476
  if (!match) {
@@ -2815,6 +2993,14 @@ async function main() {
2815
2993
  }
2816
2994
  else {
2817
2995
  console.log(`Deploy check passed: ${check.files} file(s), ${check.bytes} byte(s).`);
2996
+ if (check.serverCode) {
2997
+ // The compressed server-code figure, printed on success as well as on
2998
+ // warning/rejection, so the number the size limit is enforced on is always
2999
+ // visible — the hosted skill tells agents to read it from here.
3000
+ const { compressedBytes, moduleCount } = check.serverCode;
3001
+ const subject = moduleCount === 1 ? "entry" : `${moduleCount} server modules`;
3002
+ console.log(`Server code (${subject}): ${compressedBytes.toLocaleString("en-US")} bytes compressed of ${MAX_WORKER_ENTRY_COMPRESSED_BYTES.toLocaleString("en-US")} allowed.`);
3003
+ }
2818
3004
  }
2819
3005
  return;
2820
3006
  }
@@ -3102,7 +3288,14 @@ async function runFilesCommand(command) {
3102
3288
  expiresInSeconds: command.action.expiresInSeconds,
3103
3289
  });
3104
3290
  console.log(result.url);
3105
- console.log(`Share mode: ${result.share.mode === "login_required" ? "owner-only" : "public"}, expires ${result.share.expiresAt}.`);
3291
+ if (result.share.mode === "public") {
3292
+ // Say it plainly at the moment it becomes true: an agent pasting this into a
3293
+ // chat should not be the only place the word "public" appears (#637).
3294
+ console.log(`Share mode: PUBLIC — anyone with this URL can download the file, no sign-in. Expires ${result.share.expiresAt}.`);
3295
+ }
3296
+ else {
3297
+ console.log(`Share mode: owner-only — only you can open it, signed in. Expires ${result.share.expiresAt}.`);
3298
+ }
3106
3299
  }
3107
3300
  }
3108
3301
  function printUsage() {
@@ -3126,7 +3319,7 @@ function printUsage() {
3126
3319
  9pm files list [--project <site>]
3127
3320
  9pm files put <local-path> [--path <remote-path>] [--project <site>]
3128
3321
  9pm files get <file-id> [--output <path>] [--project <site>]
3129
- 9pm files share <file-id> [--expires 24h] [--login-required] [--project <site>]
3322
+ 9pm files share <file-id> [--expires 24h] [--public] [--project <site>]
3130
3323
  9pm files delete <file-id> [--project <site>]
3131
3324
  9pm deployments ls <app> [--json]
3132
3325
  9pm deployments rm <deployment-id>
@@ -3151,6 +3344,15 @@ Environment:
3151
3344
  ${ALLOW_FILE_CREDENTIAL_STORE_ENV}
3152
3345
  Allow plaintext credential file fallback when native storage is unavailable
3153
3346
 
3347
+ Size limits:
3348
+ Individual files are limited to ${formatMebibytes(MAX_STATIC_ASSET_FILE_BYTES)}.
3349
+ Worker runtime: server code is limited to ${formatMegabytes(MAX_WORKER_ENTRY_COMPRESSED_BYTES)} compressed (gzip) — the ${formatMegabytes(MAX_WORKER_SCRIPT_COMPRESSED_BYTES)} script limit
3350
+ less a small reserve for the generated wrapper. That budget covers the --entry module, or
3351
+ with --bundle every server module together, since they deploy as one script. Every other
3352
+ file in the directory is served from the asset store instead, so it does not count toward
3353
+ that limit — keep large images, fonts and datasets as sibling files rather than inlining
3354
+ them into the entry. Apps needing more server code than that should deploy as containers.
3355
+
3154
3356
  Container deploys:
3155
3357
  Requires Docker locally. The CLI builds linux/amd64, exports the image, uploads it through a short-lived session, then finalizes a queued deployment.
3156
3358
  The app must listen on 0.0.0.0:$PORT or the selected --port. Use --health-path for a cheap readiness endpoint.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ninepm",
3
- "version": "0.36.0",
3
+ "version": "0.39.0",
4
4
  "description": "CLI for deploying built apps to 9pm.ai.",
5
5
  "homepage": "https://9pm.ai",
6
6
  "keywords": [