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 +7 -5
- package/dist/deploy-limits.d.ts +10 -0
- package/dist/deploy-limits.js +90 -0
- package/dist/index.d.ts +36 -3
- package/dist/index.js +457 -43
- package/package.json +1 -1
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 --
|
|
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.
|
|
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] [--
|
|
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
|
|
227
|
-
- `--
|
|
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
|
package/dist/deploy-limits.d.ts
CHANGED
|
@@ -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;
|
package/dist/deploy-limits.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
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
|
-
|
|
515
|
-
|
|
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 (
|
|
518
|
-
warnings.push(
|
|
660
|
+
else if (compressedBytes >= WORKER_ENTRY_COMPRESSED_WARN_BYTES) {
|
|
661
|
+
warnings.push(workerEntryNearLimitMessage(serverEntry, rawBytes, compressedBytes));
|
|
519
662
|
}
|
|
520
|
-
const source = new TextDecoder().decode(
|
|
521
|
-
const
|
|
522
|
-
warnings.push(...
|
|
523
|
-
errors.push(...
|
|
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
|
-
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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] [--
|
|
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.
|