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