ninepm 0.34.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.34.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;
@@ -244,12 +257,19 @@ export interface ListSitesResult {
244
257
  }
245
258
  export declare function listSites(options: ApiOptions): Promise<ListSitesResult>;
246
259
  export declare function deleteSite(options: DeleteSiteOptions): Promise<DeletedSiteResult>;
260
+ export interface DeviceAuthFeedbackOptions {
261
+ progressStream?: NodeJS.WriteStream;
262
+ openBrowserFn?: (url: string) => BrowserOpenResult;
263
+ storeCredentialFn?: (token: string, options: {
264
+ allowFileFallback?: boolean | undefined;
265
+ }) => string;
266
+ }
247
267
  export declare function loginDevice(options: {
248
268
  apiUrl: string;
249
269
  fetchFn?: FetchLike;
250
270
  machineName?: string;
251
271
  allowFileCredentialStore?: boolean;
252
- }): Promise<{
272
+ } & DeviceAuthFeedbackOptions): Promise<{
253
273
  email?: string;
254
274
  storage: string;
255
275
  }>;
@@ -258,10 +278,11 @@ export declare function signupDevice(options: {
258
278
  fetchFn?: FetchLike;
259
279
  machineName?: string;
260
280
  allowFileCredentialStore?: boolean;
261
- }): Promise<{
281
+ } & DeviceAuthFeedbackOptions): Promise<{
262
282
  email?: string;
263
283
  storage: string;
264
284
  }>;
285
+ export declare function expiredDeviceAuthMessage(purpose: string, userCode: string, lifetimeMs: number, verificationUri: string): string;
265
286
  export declare function listManagedFiles(options: FileCommandOptions): Promise<ManagedFileListResult>;
266
287
  export declare function putManagedFile(options: FilePutOptions): Promise<ManagedFilePutResult>;
267
288
  export declare function getManagedFile(options: FileGetOptions): Promise<{
@@ -294,6 +315,11 @@ export interface UploadProgressReporter {
294
315
  stop(): void;
295
316
  }
296
317
  export declare function createUploadProgressReporter(stream?: NodeJS.WriteStream): UploadProgressReporter;
318
+ export interface DeviceAuthProgressReporter {
319
+ tick(remainingMs: number): void;
320
+ stop(): void;
321
+ }
322
+ export declare function createDeviceAuthProgressReporter(stream?: NodeJS.WriteStream): DeviceAuthProgressReporter;
297
323
  type CliCommand = {
298
324
  kind: "deploy";
299
325
  options: DeployOptions;
@@ -466,6 +492,13 @@ export declare function parseEnvFileEntries(content: string, kind: "secret" | "v
466
492
  kind: "secret" | "var";
467
493
  }>;
468
494
  export declare function credentialDiagnostics(): CredentialDiagnostics;
495
+ export type BrowserOpenResult = "opened" | "failed" | "unknown";
496
+ export declare function classifyBrowserOpen(input: {
497
+ platform: string;
498
+ status: number | null;
499
+ error?: unknown;
500
+ }): BrowserOpenResult;
501
+ export declare function hasNoGraphicalDisplay(platform: string, env: Record<string, string | undefined>): boolean;
469
502
  export declare class UsageError extends Error {
470
503
  }
471
504
  export {};
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.34.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);
667
+ }
668
+ }
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));
524
680
  }
525
681
  }
526
- return { ok: errors.length === 0, warnings, errors, files: manifest.length, bytes: totalBytes };
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" });
@@ -755,6 +911,12 @@ async function deviceAuthFlow(options) {
755
911
  headers: { "content-type": "application/json" },
756
912
  body: JSON.stringify({ machineName: options.machineName ?? os.hostname(), purpose: options.purpose }),
757
913
  });
914
+ // The control plane refuses a signup it knows can never be approved (#507) with
915
+ // a message written for the tester. Surface it alone and stop here, rather than
916
+ // opening a browser and polling for something that will never arrive.
917
+ if (!startResponse.ok) {
918
+ throw new Error(await apiErrorMessage(startResponse, `start device ${options.purpose}`));
919
+ }
758
920
  const start = await expectJson(startResponse, `start device ${options.purpose}`);
759
921
  const userCode = start.user_code ?? start.code;
760
922
  const deviceCode = start.device_code;
@@ -763,29 +925,104 @@ async function deviceAuthFlow(options) {
763
925
  }
764
926
  console.log(`Open ${start.verification_uri}`);
765
927
  console.log(`Device code: ${userCode}`);
766
- openBrowser(start.verification_uri);
928
+ // Everything new below goes to stderr (#509): stdout keeps exactly the two
929
+ // lines above plus the caller's unchanged result line, so anything already
930
+ // reading it is unaffected.
931
+ const progressStream = options.progressStream ?? process.stderr;
932
+ const browser = (options.openBrowserFn ?? openBrowser)(start.verification_uri);
933
+ if (browser === "failed") {
934
+ // Say it the moment it happens. A swallowed browser failure used to look
935
+ // exactly like a flow nobody had approved yet.
936
+ progressStream.write("Could not open a browser automatically.\n");
937
+ }
938
+ // Stated on every device auth, not only after a failure: the printed URL
939
+ // carries the code, so opening it anywhere finishes the flow. That recovery
940
+ // path has to be known before it is needed, not discovered by guesswork.
941
+ progressStream.write("Open the URL above in any browser — on this machine or another — to approve this device.\n");
767
942
  const deadline = Date.now() + start.expires_in * 1000;
768
943
  const intervalMs = Math.max(1, start.interval ?? 2) * 1000;
769
- while (Date.now() < deadline) {
770
- await sleep(intervalMs);
771
- const pollResponse = await fetchFn(`${apiUrl}/v1/oauth/device/poll`, {
772
- method: "POST",
773
- headers: { "content-type": "application/json" },
774
- body: JSON.stringify({ code: deviceCode }),
775
- });
776
- if (pollResponse.status === 400) {
777
- continue;
778
- }
779
- if (pollResponse.status === 410) {
780
- const body = await pollResponse.text();
781
- throw new Error(`Device ${options.purpose} expired or was denied: ${body}`);
944
+ // stop() in a finally so no outcome — approval, denial, network throw,
945
+ // expiry — can leave the spinner timer alive or the line half-drawn.
946
+ const progress = createDeviceAuthProgressReporter(progressStream);
947
+ try {
948
+ while (Date.now() < deadline) {
949
+ // Tick before the sleep so the first feedback is immediate rather than
950
+ // one poll interval late. Remaining time is derived from the deadline the
951
+ // server's expires_in produced, never from a CLI-side constant.
952
+ progress.tick(deadline - Date.now());
953
+ await sleep(intervalMs);
954
+ const pollResponse = await fetchFn(`${apiUrl}/v1/oauth/device/poll`, {
955
+ method: "POST",
956
+ headers: { "content-type": "application/json" },
957
+ body: JSON.stringify({ code: deviceCode }),
958
+ });
959
+ if (pollResponse.status === 400) {
960
+ continue;
961
+ }
962
+ if (pollResponse.status === 410) {
963
+ // The server distinguishes three terminal states behind this one status
964
+ // (expired / denied / consumed); the old branch collapsed them into one
965
+ // message and appended the raw JSON envelope. It also swallowed the
966
+ // actionable expiry guidance below: the server starts the clock before the
967
+ // client computes `deadline`, so a login left unapproved almost always ends
968
+ // HERE rather than by the loop running out — which made that guidance
969
+ // unreachable in the normal case (#514 review).
970
+ const state = await deviceTerminalState(pollResponse);
971
+ if (state === "denied") {
972
+ throw new Error(`Device ${options.purpose} was declined in the browser. ` +
973
+ `Run "9pm ${options.purpose}" again if that was not intended.`);
974
+ }
975
+ if (state === "consumed") {
976
+ throw new Error(`Device ${options.purpose} code ${userCode} was already used. ` +
977
+ `Run "9pm ${options.purpose}" again to get a fresh code.`);
978
+ }
979
+ // "expired", and anything unrecognised: expiry is what a 410 means here by
980
+ // default, and the guidance is useful either way.
981
+ throw new Error(expiredDeviceAuthMessage(options.purpose, userCode, start.expires_in * 1000, start.verification_uri));
982
+ }
983
+ const poll = await expectJson(pollResponse, `poll device ${options.purpose}`);
984
+ // Retire the spinner the moment approval lands, BEFORE credential storage
985
+ // and the account lookup. storeCredential can fall back to plaintext and
986
+ // warn on stderr, and whoami is an awaited round trip — either would have
987
+ // its output appended to an unterminated spinner line, and the redraw timer
988
+ // could fire again underneath it, corrupting the tail of a *successful*
989
+ // login (#514 review). stop() is idempotent, so the finally below stays as
990
+ // the fallback for every other exit path.
991
+ progress.stop();
992
+ const storage = (options.storeCredentialFn ?? storeCredential)(poll.token, {
993
+ allowFileFallback: options.allowFileCredentialStore,
994
+ });
995
+ const me = await whoami({ apiUrl, token: poll.token, fetchFn });
996
+ return { email: me.user.email, storage };
782
997
  }
783
- const poll = await expectJson(pollResponse, `poll device ${options.purpose}`);
784
- const storage = storeCredential(poll.token, { allowFileFallback: options.allowFileCredentialStore });
785
- const me = await whoami({ apiUrl, token: poll.token, fetchFn });
786
- return { email: me.user.email, storage };
787
998
  }
788
- throw new Error(`Device ${options.purpose} expired before approval.`);
999
+ finally {
1000
+ progress.stop();
1001
+ }
1002
+ // Reached only when the client's own deadline elapses without the server having
1003
+ // said anything terminal — rarer than the 410 route above, but it must say the
1004
+ // same thing, so both share one message.
1005
+ throw new Error(expiredDeviceAuthMessage(options.purpose, userCode, start.expires_in * 1000, start.verification_uri));
1006
+ }
1007
+ // The actionable timeout guidance, shared by both routes out of an unapproved
1008
+ // device flow (the server's 410 and the client's own deadline) so they can never
1009
+ // drift into saying different things.
1010
+ export function expiredDeviceAuthMessage(purpose, userCode, lifetimeMs, verificationUri) {
1011
+ return (`Device ${purpose} expired: code ${userCode} was not approved within ${formatRemainingForCli(lifetimeMs)}. ` +
1012
+ `Run "9pm ${purpose}" again, then open the printed URL in any browser — on this machine or another — to approve it. ` +
1013
+ `If no page ever loaded, check that ${verificationUri} is reachable from this network.`);
1014
+ }
1015
+ // Which terminal state the control plane meant by its 410. Returns "" when the
1016
+ // body is not the expected envelope, so callers fall back rather than guess.
1017
+ async function deviceTerminalState(response) {
1018
+ const text = await response.text();
1019
+ try {
1020
+ const parsed = JSON.parse(text);
1021
+ return typeof parsed.state === "string" ? parsed.state : "";
1022
+ }
1023
+ catch {
1024
+ return "";
1025
+ }
789
1026
  }
790
1027
  async function createProject(fetchFn, apiUrl, token, body) {
791
1028
  const response = await fetchFn(`${apiUrl}/v1/sites`, {
@@ -1149,7 +1386,8 @@ export async function uploadContainerImage(fetchFn, imagePath, upload, retry = C
1149
1386
  await dispatcher.close().catch(() => { });
1150
1387
  }
1151
1388
  }
1152
- const UPLOAD_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1389
+ // Shared by every in-place stderr reporter in this file; do not duplicate.
1390
+ const CLI_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1153
1391
  export function createUploadProgressReporter(stream = process.stderr) {
1154
1392
  const isTty = stream.isTTY === true;
1155
1393
  let timer;
@@ -1167,7 +1405,7 @@ export function createUploadProgressReporter(stream = process.stderr) {
1167
1405
  const redraw = () => {
1168
1406
  if (!last)
1169
1407
  return;
1170
- stream.write(`\r\x1b[K${UPLOAD_SPINNER_FRAMES[frame % UPLOAD_SPINNER_FRAMES.length]} ${label(last)}`);
1408
+ stream.write(`\r\x1b[K${CLI_SPINNER_FRAMES[frame % CLI_SPINNER_FRAMES.length]} ${label(last)}`);
1171
1409
  };
1172
1410
  const stopTimer = () => {
1173
1411
  if (timer) {
@@ -1238,6 +1476,72 @@ export function createUploadProgressReporter(stream = process.stderr) {
1238
1476
  },
1239
1477
  };
1240
1478
  }
1479
+ // Off-TTY ceiling: at most one line per 30s of the window (~20 lines over 600s).
1480
+ const DEVICE_AUTH_PROGRESS_LINE_INTERVAL_MS = 30_000;
1481
+ export function createDeviceAuthProgressReporter(stream = process.stderr) {
1482
+ const isTty = stream.isTTY === true;
1483
+ let timer;
1484
+ let frame = 0;
1485
+ let finished = false;
1486
+ let remainingMs;
1487
+ let lastLineRemainingMs;
1488
+ const label = (ms) => `Waiting for approval… ${formatRemainingForCli(ms)} left`;
1489
+ const redraw = () => {
1490
+ if (remainingMs === undefined)
1491
+ return;
1492
+ stream.write(`\r\x1b[K${CLI_SPINNER_FRAMES[frame % CLI_SPINNER_FRAMES.length]} ${label(remainingMs)}`);
1493
+ };
1494
+ const stopTimer = () => {
1495
+ if (timer) {
1496
+ clearInterval(timer);
1497
+ timer = undefined;
1498
+ }
1499
+ };
1500
+ return {
1501
+ tick(nextRemainingMs) {
1502
+ if (finished)
1503
+ return;
1504
+ const first = remainingMs === undefined;
1505
+ remainingMs = nextRemainingMs;
1506
+ if (isTty) {
1507
+ redraw();
1508
+ if (first) {
1509
+ // Sub-second liveness: the countdown itself only advances on a poll
1510
+ // boundary, which would look frozen for a whole interval at a time.
1511
+ timer = setInterval(() => {
1512
+ frame += 1;
1513
+ redraw();
1514
+ }, 120);
1515
+ // Never hold the process open on the reporter's account.
1516
+ if (typeof timer.unref === "function")
1517
+ timer.unref();
1518
+ }
1519
+ return;
1520
+ }
1521
+ if (lastLineRemainingMs !== undefined && lastLineRemainingMs - nextRemainingMs < DEVICE_AUTH_PROGRESS_LINE_INTERVAL_MS) {
1522
+ return;
1523
+ }
1524
+ lastLineRemainingMs = nextRemainingMs;
1525
+ stream.write(`${label(nextRemainingMs)}\n`);
1526
+ },
1527
+ // Idempotent; called from a finally around the poll loop. On a TTY it erases
1528
+ // the in-place line and writes nothing in its place: unlike the upload
1529
+ // reporter this deliberately leaves no ✓/✗ summary, because the flow's own
1530
+ // success line or its thrown error is the outcome, and a second line here
1531
+ // would be exactly the leftover progress noise #509 forbids. Off-TTY the
1532
+ // lines are already terminated, and before the first tick there is nothing
1533
+ // to clean up.
1534
+ stop() {
1535
+ if (finished)
1536
+ return;
1537
+ finished = true;
1538
+ stopTimer();
1539
+ if (isTty && remainingMs !== undefined) {
1540
+ stream.write("\r\x1b[K");
1541
+ }
1542
+ },
1543
+ };
1544
+ }
1241
1545
  function buildContainerImage(directory) {
1242
1546
  const root = path.resolve(directory);
1243
1547
  if (!existsSync(path.join(root, "Dockerfile"))) {
@@ -1291,6 +1595,25 @@ async function expectOk(response, action) {
1291
1595
  const text = await response.text();
1292
1596
  throw new Error(`Failed to ${action}: ${response.status} ${text}`);
1293
1597
  }
1598
+ // Pull the control plane's own message out of its {error:{code,message}} envelope,
1599
+ // for the few refusals whose message is written for the tester and is the whole
1600
+ // point of the response (e.g. device signup being unavailable, #507). Dumping the
1601
+ // raw envelope the way expectOk does would bury it in JSON. Falls back to
1602
+ // expectOk's text when the body isn't that shape, so this can never lose detail.
1603
+ async function apiErrorMessage(response, action) {
1604
+ const text = await response.text();
1605
+ try {
1606
+ const parsed = JSON.parse(text);
1607
+ const message = parsed.error?.message;
1608
+ if (typeof message === "string" && message.trim()) {
1609
+ return message.trim();
1610
+ }
1611
+ }
1612
+ catch {
1613
+ // Not JSON (proxy error page, empty body) — fall through to the raw form.
1614
+ }
1615
+ return `Failed to ${action}: ${response.status} ${text}`;
1616
+ }
1294
1617
  // #374: Node's fetch rejects network-level failures (no HTTP response at all)
1295
1618
  // as a generic "fetch failed" TypeError and stashes the real reason on
1296
1619
  // `error.cause` (e.g. { code: "EPIPE" }). That code never reaches the user.
@@ -1674,7 +1997,22 @@ function parseFilesArgs(argv) {
1674
1997
  throw new Error("Missing file id.");
1675
1998
  }
1676
1999
  const expires = readStringFlag(flags, "expires") ?? "24h";
1677
- 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";
1678
2016
  return { kind: "files", ...base, action: { kind: "share", fileId, mode, expiresInSeconds: parseDurationSeconds(expires), ...withProject } };
1679
2017
  }
1680
2018
  throw new Error(`Unknown files command: ${subcommand}`);
@@ -2126,6 +2464,13 @@ function bundleMetadataFromManifest(manifest, contentRoot) {
2126
2464
  compatibilityFlags: ["nodejs_compat"],
2127
2465
  };
2128
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
+ }
2129
2474
  function parseMemorySize(value) {
2130
2475
  const match = value.trim().toLowerCase().match(/^(\d+)\s*(g|gb|m|mb)?$/);
2131
2476
  if (!match) {
@@ -2538,10 +2883,45 @@ function deleteLinuxSecretServiceCredential() {
2538
2883
  const result = spawnSync("secret-tool", ["clear", "service", TOKEN_SERVICE, "account", TOKEN_ACCOUNT], { encoding: "utf8" });
2539
2884
  return result.status === 0;
2540
2885
  }
2886
+ // Pure and exported so the per-platform mapping is unit-testable without
2887
+ // spawning anything (#509). A spawn error or a non-zero exit is a real failure.
2888
+ // A zero exit means the handler ran — except on Windows, where the launcher
2889
+ // returns 0 whether or not anything actually rendered the page, so "opened"
2890
+ // there would be a claim we cannot back: report "unknown" and say nothing about
2891
+ // the browser rather than mislead. The unconditional paste-the-URL hint covers
2892
+ // that case anyway.
2893
+ export function classifyBrowserOpen(input) {
2894
+ if (input.error !== undefined) {
2895
+ return "failed";
2896
+ }
2897
+ if (input.status !== 0) {
2898
+ return "failed";
2899
+ }
2900
+ return input.platform === "win32" ? "unknown" : "opened";
2901
+ }
2902
+ // Headless boxes, containers and remote SSH sessions — the cases where the
2903
+ // browser handoff is least likely to work, and where `xdg-open` often exits 0
2904
+ // having done nothing, so the absence of any display is the stronger signal.
2905
+ // Pure and exported for the same reason as classifyBrowserOpen: the branch has
2906
+ // to be assertable without a real platform or a real spawn. Note
2907
+ // noUncheckedIndexedAccess makes these `string | undefined`; an empty value is
2908
+ // as useless as an absent one, so test truthiness, not presence.
2909
+ export function hasNoGraphicalDisplay(platform, env) {
2910
+ return platform === "linux" && !env["DISPLAY"] && !env["WAYLAND_DISPLAY"];
2911
+ }
2541
2912
  function openBrowser(url) {
2913
+ if (hasNoGraphicalDisplay(process.platform, process.env)) {
2914
+ return "failed";
2915
+ }
2542
2916
  const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
2543
2917
  const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
2544
- spawnSync(command, args, { stdio: "ignore" });
2918
+ // stdio stays ignored: a browser's own chatter must not pollute the terminal.
2919
+ const result = spawnSync(command, args, { stdio: "ignore" });
2920
+ return classifyBrowserOpen({
2921
+ platform: process.platform,
2922
+ status: result.status,
2923
+ ...(result.error ? { error: result.error } : {}),
2924
+ });
2545
2925
  }
2546
2926
  function sleep(ms) {
2547
2927
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -2576,6 +2956,16 @@ function formatBytesForCli(value) {
2576
2956
  }
2577
2957
  return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}GB`;
2578
2958
  }
2959
+ // Human remaining time for the device-auth wait (#509). Ceil so a window with
2960
+ // any time left never reads "0s", and clamp so a passed deadline reads "0s"
2961
+ // rather than a negative.
2962
+ function formatRemainingForCli(value) {
2963
+ const totalSeconds = Math.max(0, Math.ceil(value / 1000));
2964
+ if (totalSeconds < 60) {
2965
+ return `${totalSeconds}s`;
2966
+ }
2967
+ return `${Math.floor(totalSeconds / 60)}m ${totalSeconds % 60}s`;
2968
+ }
2579
2969
  export class UsageError extends Error {
2580
2970
  }
2581
2971
  async function main() {
@@ -2603,6 +2993,14 @@ async function main() {
2603
2993
  }
2604
2994
  else {
2605
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
+ }
2606
3004
  }
2607
3005
  return;
2608
3006
  }
@@ -2890,7 +3288,14 @@ async function runFilesCommand(command) {
2890
3288
  expiresInSeconds: command.action.expiresInSeconds,
2891
3289
  });
2892
3290
  console.log(result.url);
2893
- 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
+ }
2894
3299
  }
2895
3300
  }
2896
3301
  function printUsage() {
@@ -2914,7 +3319,7 @@ function printUsage() {
2914
3319
  9pm files list [--project <site>]
2915
3320
  9pm files put <local-path> [--path <remote-path>] [--project <site>]
2916
3321
  9pm files get <file-id> [--output <path>] [--project <site>]
2917
- 9pm files share <file-id> [--expires 24h] [--login-required] [--project <site>]
3322
+ 9pm files share <file-id> [--expires 24h] [--public] [--project <site>]
2918
3323
  9pm files delete <file-id> [--project <site>]
2919
3324
  9pm deployments ls <app> [--json]
2920
3325
  9pm deployments rm <deployment-id>
@@ -2939,6 +3344,15 @@ Environment:
2939
3344
  ${ALLOW_FILE_CREDENTIAL_STORE_ENV}
2940
3345
  Allow plaintext credential file fallback when native storage is unavailable
2941
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
+
2942
3356
  Container deploys:
2943
3357
  Requires Docker locally. The CLI builds linux/amd64, exports the image, uploads it through a short-lived session, then finalizes a queued deployment.
2944
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.34.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": [