ninepm 0.61.0 → 0.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -4
- package/dist/cli-types.d.ts +1 -0
- package/dist/deploy-limits.js +5 -3
- package/dist/index.d.ts +77 -8
- package/dist/index.js +311 -30
- 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.64.0";
|
|
120
121
|
type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
|
|
121
122
|
export interface StoredCredentialEntry {
|
|
122
123
|
token: string;
|
|
@@ -186,6 +187,10 @@ export interface DeployCheckResult {
|
|
|
186
187
|
compressedBytes: number;
|
|
187
188
|
moduleCount: number;
|
|
188
189
|
};
|
|
190
|
+
publicFiles?: Array<{
|
|
191
|
+
path: string;
|
|
192
|
+
url: string;
|
|
193
|
+
}>;
|
|
189
194
|
}
|
|
190
195
|
export type DetectedFramework = "nextjs" | "astro" | "sveltekit" | "vite";
|
|
191
196
|
export declare function detectFramework(directory: string): DetectedFramework | null;
|
|
@@ -335,24 +340,81 @@ export interface DeviceAuthFeedbackOptions {
|
|
|
335
340
|
apiUrl?: string | undefined;
|
|
336
341
|
}) => string;
|
|
337
342
|
}
|
|
343
|
+
export type ReplacedCredential = {
|
|
344
|
+
kind: "revoked";
|
|
345
|
+
apiUrl: string;
|
|
346
|
+
} | {
|
|
347
|
+
kind: "already_invalid";
|
|
348
|
+
apiUrl: string;
|
|
349
|
+
} | {
|
|
350
|
+
kind: "failed";
|
|
351
|
+
apiUrl: string;
|
|
352
|
+
reason: string;
|
|
353
|
+
} | {
|
|
354
|
+
kind: "unknown_issuer";
|
|
355
|
+
};
|
|
356
|
+
export interface DeviceAuthResult {
|
|
357
|
+
email?: string;
|
|
358
|
+
storage: string;
|
|
359
|
+
replaced: ReplacedCredential[];
|
|
360
|
+
trimmedLoginKeys?: {
|
|
361
|
+
count: number;
|
|
362
|
+
limit: number;
|
|
363
|
+
};
|
|
364
|
+
}
|
|
338
365
|
export declare function loginDevice(options: {
|
|
339
366
|
apiUrl: string;
|
|
340
367
|
fetchFn?: FetchLike;
|
|
341
368
|
machineName?: string;
|
|
342
369
|
allowFileCredentialStore?: boolean;
|
|
343
|
-
} & DeviceAuthFeedbackOptions): Promise<
|
|
344
|
-
email?: string;
|
|
345
|
-
storage: string;
|
|
346
|
-
}>;
|
|
370
|
+
} & DeviceAuthFeedbackOptions): Promise<DeviceAuthResult>;
|
|
347
371
|
export declare function signupDevice(options: {
|
|
348
372
|
apiUrl: string;
|
|
349
373
|
fetchFn?: FetchLike;
|
|
350
374
|
machineName?: string;
|
|
351
375
|
allowFileCredentialStore?: boolean;
|
|
352
|
-
} & DeviceAuthFeedbackOptions): Promise<
|
|
376
|
+
} & DeviceAuthFeedbackOptions): Promise<DeviceAuthResult>;
|
|
377
|
+
/**
|
|
378
|
+
* Revoke every credential this login's write pushed out of the local stores (#1779).
|
|
379
|
+
*
|
|
380
|
+
* "Pushed out" means present before the write and absent after it - not merely
|
|
381
|
+
* different from the new token. A copy still sitting in another store (a plaintext
|
|
382
|
+
* fallback beside the keychain) is still reachable from this machine, and a process
|
|
383
|
+
* that skips the keychain may be using it, so it is left for `9pm logout`.
|
|
384
|
+
*
|
|
385
|
+
* Each key is revoked at the control plane that ISSUED it, which is not necessarily
|
|
386
|
+
* the one this login talked to: a production login overwrites a staging key in the
|
|
387
|
+
* same slot, and only staging can revoke that. A key with no recorded issuer is not
|
|
388
|
+
* sent anywhere. Never throws - the login has already succeeded.
|
|
389
|
+
*/
|
|
390
|
+
export declare function retireReplacedCredentials(before: StoredCredentialEntry[], after: StoredCredentialEntry[], newToken: string, fetchFn?: FetchLike): Promise<ReplacedCredential[]>;
|
|
391
|
+
/** The lines a login prints about what it retired, for stderr. Silent when nothing needs saying. */
|
|
392
|
+
export declare function replacedCredentialMessages(result: Pick<DeviceAuthResult, "replaced" | "trimmedLoginKeys">, loginApiUrl: string): string[];
|
|
393
|
+
export type StoredLoginProbe = {
|
|
394
|
+
kind: "reuse";
|
|
353
395
|
email?: string;
|
|
354
|
-
|
|
355
|
-
|
|
396
|
+
} | {
|
|
397
|
+
kind: "mint";
|
|
398
|
+
} | {
|
|
399
|
+
kind: "unverifiable";
|
|
400
|
+
reason: string;
|
|
401
|
+
};
|
|
402
|
+
/**
|
|
403
|
+
* Whether `9pm login` can keep the credential this machine already holds (#1779).
|
|
404
|
+
*
|
|
405
|
+
* Only the credential commands actually authenticate with is considered. One recorded
|
|
406
|
+
* as issued by another control plane is never sent here. One with no recorded issuer
|
|
407
|
+
* (stored before 0.42) IS probed here: keys are per database, so a 200 from the plane
|
|
408
|
+
* this login targets proves it was issued there, and leaving those to mint would keep
|
|
409
|
+
* the oldest CLIs' machines minting a stray key per run. Only the control plane's own
|
|
410
|
+
* refusal proves a key dead; a proxy's 401, a 5xx or a timeout proves nothing, so
|
|
411
|
+
* those come back as unverifiable rather than as permission to mint.
|
|
412
|
+
*/
|
|
413
|
+
export declare function probeStoredLogin(options: {
|
|
414
|
+
apiUrl: string;
|
|
415
|
+
fetchFn?: FetchLike;
|
|
416
|
+
stored?: StoredCredentialEntry[];
|
|
417
|
+
}): Promise<StoredLoginProbe>;
|
|
356
418
|
export declare function expiredDeviceAuthMessage(purpose: string, userCode: string, lifetimeMs: number, verificationUri: string): string;
|
|
357
419
|
export declare function listManagedFiles(options: FileCommandOptions): Promise<ManagedFileListResult>;
|
|
358
420
|
export declare function getAppUserFilesUsage(options: ApiOptions & {
|
|
@@ -406,6 +468,7 @@ type CliCommand = {
|
|
|
406
468
|
kind: "login";
|
|
407
469
|
apiUrl: string;
|
|
408
470
|
allowFileCredentialStore: boolean;
|
|
471
|
+
fresh: boolean;
|
|
409
472
|
} | {
|
|
410
473
|
kind: "signup";
|
|
411
474
|
apiUrl: string;
|
|
@@ -592,6 +655,12 @@ export declare function parseEnvFileEntries(content: string, kind: "secret" | "v
|
|
|
592
655
|
kind: "secret" | "var";
|
|
593
656
|
}>;
|
|
594
657
|
export declare function healthWithReason(status: string | null | undefined, reason: string | null | undefined): string;
|
|
658
|
+
export declare function workerPublicFiles(paths: readonly string[], serverEntry: string, selection: string | null): Array<{
|
|
659
|
+
path: string;
|
|
660
|
+
url: string;
|
|
661
|
+
}>;
|
|
662
|
+
export declare function legacyPublicAssetsWarning(serverEntry: string, publicPaths: readonly string[]): string;
|
|
663
|
+
export declare function privateLookingPublicWarning(paths: readonly string[], selection: string | null): string;
|
|
595
664
|
export declare function storedCredentialTokens(): StoredCredentialEntry[];
|
|
596
665
|
/**
|
|
597
666
|
* Whether a credential provably cannot be stored, decided WITHOUT writing one (#859).
|
package/dist/index.js
CHANGED
|
@@ -14,8 +14,9 @@ import { MAX_DEPLOY_DESCRIPTION_LENGTH, normalizeDeployDescription } from "./dep
|
|
|
14
14
|
import { managedHostnamesIn, siblingCallWarning, siblingsOf } from "./sibling-hostnames.js";
|
|
15
15
|
import { MAX_STATIC_ASSET_FILE_BYTES, MAX_WORKER_ENTRY_COMPRESSED_BYTES, MAX_WORKER_SCRIPT_COMPRESSED_BYTES, WORKER_ENTRY_COMPRESSED_WARN_BYTES, assetFileTooLargeMessage, formatMebibytes, formatMegabytes, oversizedAssets, workerBundleNearLimitMessage, workerBundleTooLargeMessage, workerEntryNearLimitMessage, workerEntryTooLargeMessage, } from "./deploy-limits.js";
|
|
16
16
|
import { buildFileManifest, readManifestFile } from "./manifest.js";
|
|
17
|
+
import { NO_PUBLIC_ASSETS, normalizeAssetsDirectory, privateLookingPaths, publicAssetPath } from "./public-assets.js";
|
|
17
18
|
import { API_KEY_HELP, API_URL_HELP, CLI_CLIENT_ID, DEFAULT_API_URL, reportCliError } from "./cli-config.js";
|
|
18
|
-
export const CLI_VERSION = "0.
|
|
19
|
+
export const CLI_VERSION = "0.64.0";
|
|
19
20
|
const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
|
|
20
21
|
const TOKEN_SERVICE = "9pm";
|
|
21
22
|
const TOKEN_ACCOUNT = "default";
|
|
@@ -466,12 +467,25 @@ async function runDeploy(initialOptions, slugHolder) {
|
|
|
466
467
|
if (serverEntry && !manifest.some((file) => file.path === serverEntry)) {
|
|
467
468
|
throw new Error(`Worker runtime entry file was not found in the deploy directory: ${serverEntry}`);
|
|
468
469
|
}
|
|
470
|
+
if (serverEntry &&
|
|
471
|
+
options.publicAssets &&
|
|
472
|
+
options.publicAssets !== NO_PUBLIC_ASSETS &&
|
|
473
|
+
workerPublicFiles(manifest.map((file) => file.path), serverEntry, options.publicAssets).length === 0) {
|
|
474
|
+
throw new Error(missingAssetsDirectoryMessage(options.publicAssets));
|
|
475
|
+
}
|
|
469
476
|
// Reject oversized assets before creating the project (#195): the server's size
|
|
470
477
|
// guard only fires on the later create-deployment call, which would otherwise
|
|
471
478
|
// leave an orphaned no-deploy app holding the slug and a Sites quota slot.
|
|
472
|
-
|
|
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" });
|
|
@@ -1376,6 +1450,10 @@ async function deviceAuthFlow(options) {
|
|
|
1376
1450
|
// login (#514 review). stop() is idempotent, so the finally below stays as
|
|
1377
1451
|
// the fallback for every other exit path.
|
|
1378
1452
|
progress.stop();
|
|
1453
|
+
// What the store held before this login wrote over it (#1779). Read only from the
|
|
1454
|
+
// real stores: an injected store replaces the backend wholesale, so the real one
|
|
1455
|
+
// says nothing about what that write displaced.
|
|
1456
|
+
const before = options.storeCredentialFn ? [] : storedCredentialTokens();
|
|
1379
1457
|
let storage;
|
|
1380
1458
|
try {
|
|
1381
1459
|
storage = (options.storeCredentialFn ?? storeCredential)(poll.token, {
|
|
@@ -1431,17 +1509,23 @@ async function deviceAuthFlow(options) {
|
|
|
1431
1509
|
discardRevokedCredential(poll.token);
|
|
1432
1510
|
throw new Error(joinSentences("The credential this login created was stored but cannot authenticate.", disposalSentence(revocation)));
|
|
1433
1511
|
}
|
|
1512
|
+
// The new credential is stored and has not been refused, so whatever it pushed
|
|
1513
|
+
// out of the store can go: nothing on this machine can reach that key any more.
|
|
1514
|
+
const replaced = await retireReplacedCredentials(before, options.storeCredentialFn ? [] : storedCredentialTokens(), poll.token, options.fetchFn);
|
|
1515
|
+
const trimmed = typeof poll.revoked_login_keys === "number" && poll.revoked_login_keys > 0 && typeof poll.login_key_limit === "number"
|
|
1516
|
+
? { trimmedLoginKeys: { count: poll.revoked_login_keys, limit: poll.login_key_limit } }
|
|
1517
|
+
: {};
|
|
1434
1518
|
if (!identity?.ok) {
|
|
1435
1519
|
// Said out loud rather than swallowed: the login DID succeed, and the user
|
|
1436
1520
|
// should know which half of it did not.
|
|
1437
1521
|
progressStream.write(`Warning: signed in, but could not read the account name (${identity ? `the control plane answered ${identity.status}` : "the request failed"}).\n`);
|
|
1438
|
-
return { storage };
|
|
1522
|
+
return { storage, replaced, ...trimmed };
|
|
1439
1523
|
}
|
|
1440
1524
|
const email = await identity
|
|
1441
1525
|
.json()
|
|
1442
1526
|
.then((body) => body.user?.email)
|
|
1443
1527
|
.catch(() => undefined);
|
|
1444
|
-
return email ? { email
|
|
1528
|
+
return { ...(email ? { email } : {}), storage, replaced, ...trimmed };
|
|
1445
1529
|
}
|
|
1446
1530
|
}
|
|
1447
1531
|
finally {
|
|
@@ -1452,6 +1536,107 @@ async function deviceAuthFlow(options) {
|
|
|
1452
1536
|
// same thing, so both share one message.
|
|
1453
1537
|
throw new Error(expiredDeviceAuthMessage(options.purpose, userCode, start.expires_in * 1000, start.verification_uri));
|
|
1454
1538
|
}
|
|
1539
|
+
/**
|
|
1540
|
+
* Revoke every credential this login's write pushed out of the local stores (#1779).
|
|
1541
|
+
*
|
|
1542
|
+
* "Pushed out" means present before the write and absent after it - not merely
|
|
1543
|
+
* different from the new token. A copy still sitting in another store (a plaintext
|
|
1544
|
+
* fallback beside the keychain) is still reachable from this machine, and a process
|
|
1545
|
+
* that skips the keychain may be using it, so it is left for `9pm logout`.
|
|
1546
|
+
*
|
|
1547
|
+
* Each key is revoked at the control plane that ISSUED it, which is not necessarily
|
|
1548
|
+
* the one this login talked to: a production login overwrites a staging key in the
|
|
1549
|
+
* same slot, and only staging can revoke that. A key with no recorded issuer is not
|
|
1550
|
+
* sent anywhere. Never throws - the login has already succeeded.
|
|
1551
|
+
*/
|
|
1552
|
+
export async function retireReplacedCredentials(before, after, newToken, fetchFn) {
|
|
1553
|
+
const seen = new Set([newToken]);
|
|
1554
|
+
const outcomes = [];
|
|
1555
|
+
for (const entry of before) {
|
|
1556
|
+
if (seen.has(entry.token) || after.some((kept) => kept.token === entry.token)) {
|
|
1557
|
+
continue;
|
|
1558
|
+
}
|
|
1559
|
+
seen.add(entry.token);
|
|
1560
|
+
if (!entry.apiUrl) {
|
|
1561
|
+
outcomes.push({ kind: "unknown_issuer" });
|
|
1562
|
+
continue;
|
|
1563
|
+
}
|
|
1564
|
+
const apiUrl = normalizeApiUrl(entry.apiUrl);
|
|
1565
|
+
const revocation = await revokeCredential({ apiUrl, token: entry.token, ...(fetchFn ? { fetchFn } : {}) });
|
|
1566
|
+
outcomes.push(revocation.kind === "failed" ? { kind: "failed", apiUrl, reason: revocation.reason } : { kind: revocation.kind, apiUrl });
|
|
1567
|
+
}
|
|
1568
|
+
return outcomes;
|
|
1569
|
+
}
|
|
1570
|
+
/** The lines a login prints about what it retired, for stderr. Silent when nothing needs saying. */
|
|
1571
|
+
export function replacedCredentialMessages(result, loginApiUrl) {
|
|
1572
|
+
const lines = [];
|
|
1573
|
+
const manual = "revoke it from Settings > API keys in the dashboard, or it stays valid until 90 days pass without use.";
|
|
1574
|
+
for (const outcome of result.replaced) {
|
|
1575
|
+
if (outcome.kind === "revoked") {
|
|
1576
|
+
const elsewhere = outcome.apiUrl !== normalizeApiUrl(loginApiUrl) ? ` (issued by ${outcome.apiUrl})` : "";
|
|
1577
|
+
lines.push(`Revoked the credential this login replaced${elsewhere}.`);
|
|
1578
|
+
}
|
|
1579
|
+
else if (outcome.kind === "failed") {
|
|
1580
|
+
lines.push(`Warning: the credential this login replaced could not be revoked at ${outcome.apiUrl} (${outcome.reason}) - ${manual}`);
|
|
1581
|
+
}
|
|
1582
|
+
else if (outcome.kind === "unknown_issuer") {
|
|
1583
|
+
lines.push(`Warning: the credential this login replaced was stored by an older CLI that did not record where it was issued, so it was not revoked - ${manual}`);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
if (result.trimmedLoginKeys) {
|
|
1587
|
+
const { count, limit } = result.trimmedLoginKeys;
|
|
1588
|
+
lines.push(`Revoked ${count} older login key${count === 1 ? "" : "s"} on this account (at most ${limit} stay live).`);
|
|
1589
|
+
}
|
|
1590
|
+
return lines;
|
|
1591
|
+
}
|
|
1592
|
+
/**
|
|
1593
|
+
* Whether `9pm login` can keep the credential this machine already holds (#1779).
|
|
1594
|
+
*
|
|
1595
|
+
* Only the credential commands actually authenticate with is considered. One recorded
|
|
1596
|
+
* as issued by another control plane is never sent here. One with no recorded issuer
|
|
1597
|
+
* (stored before 0.42) IS probed here: keys are per database, so a 200 from the plane
|
|
1598
|
+
* this login targets proves it was issued there, and leaving those to mint would keep
|
|
1599
|
+
* the oldest CLIs' machines minting a stray key per run. Only the control plane's own
|
|
1600
|
+
* refusal proves a key dead; a proxy's 401, a 5xx or a timeout proves nothing, so
|
|
1601
|
+
* those come back as unverifiable rather than as permission to mint.
|
|
1602
|
+
*/
|
|
1603
|
+
export async function probeStoredLogin(options) {
|
|
1604
|
+
const apiUrl = normalizeApiUrl(options.apiUrl);
|
|
1605
|
+
const current = (options.stored ?? storedCredentialTokens())[0];
|
|
1606
|
+
if (!current || (current.apiUrl && normalizeApiUrl(current.apiUrl) !== apiUrl)) {
|
|
1607
|
+
return { kind: "mint" };
|
|
1608
|
+
}
|
|
1609
|
+
const fetchFn = options.fetchFn ?? fetch;
|
|
1610
|
+
let response;
|
|
1611
|
+
try {
|
|
1612
|
+
response = await fetchFn(`${apiUrl}/v1/me`, {
|
|
1613
|
+
method: "GET",
|
|
1614
|
+
headers: jsonHeaders(current.token),
|
|
1615
|
+
signal: AbortSignal.timeout(IDENTITY_TIMEOUT_MS),
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
catch (error) {
|
|
1619
|
+
const reason = error instanceof Error && error.name === "TimeoutError"
|
|
1620
|
+
? `the control plane did not answer within ${IDENTITY_TIMEOUT_MS / 1000}s`
|
|
1621
|
+
: error instanceof Error
|
|
1622
|
+
? error.message
|
|
1623
|
+
: String(error);
|
|
1624
|
+
return { kind: "unverifiable", reason };
|
|
1625
|
+
}
|
|
1626
|
+
if (response.status === 401 || response.status === 403) {
|
|
1627
|
+
return (await isAuthRefusal(response))
|
|
1628
|
+
? { kind: "mint" }
|
|
1629
|
+
: { kind: "unverifiable", reason: `${response.status} from something other than the control plane's own authentication` };
|
|
1630
|
+
}
|
|
1631
|
+
if (!response.ok) {
|
|
1632
|
+
return { kind: "unverifiable", reason: `the control plane answered ${response.status}` };
|
|
1633
|
+
}
|
|
1634
|
+
const email = await response
|
|
1635
|
+
.json()
|
|
1636
|
+
.then((body) => body.user?.email)
|
|
1637
|
+
.catch(() => undefined);
|
|
1638
|
+
return email ? { kind: "reuse", email } : { kind: "reuse" };
|
|
1639
|
+
}
|
|
1455
1640
|
// The actionable timeout guidance, shared by both routes out of an unapproved
|
|
1456
1641
|
// device flow (the server's 410 and the client's own deadline) so they can never
|
|
1457
1642
|
// drift into saying different things.
|
|
@@ -2238,6 +2423,8 @@ const VALID_DEPLOY_FLAGS = new Set([
|
|
|
2238
2423
|
"memory",
|
|
2239
2424
|
"no-build",
|
|
2240
2425
|
"bundle",
|
|
2426
|
+
"assets",
|
|
2427
|
+
"no-assets",
|
|
2241
2428
|
]);
|
|
2242
2429
|
export function parseArgs(argv) {
|
|
2243
2430
|
const command = argv[0];
|
|
@@ -2264,7 +2451,12 @@ export function parseArgs(argv) {
|
|
|
2264
2451
|
}
|
|
2265
2452
|
if (command === "login") {
|
|
2266
2453
|
const flags = parseFlags(argv);
|
|
2267
|
-
return {
|
|
2454
|
+
return {
|
|
2455
|
+
kind: "login",
|
|
2456
|
+
apiUrl: readApiUrl(flags, DEFAULT_API_URL),
|
|
2457
|
+
allowFileCredentialStore: flags.has("allow-file-credential-store"),
|
|
2458
|
+
fresh: flags.has("fresh"),
|
|
2459
|
+
};
|
|
2268
2460
|
}
|
|
2269
2461
|
if (command === "signup") {
|
|
2270
2462
|
const flags = parseFlags(argv);
|
|
@@ -2466,6 +2658,23 @@ export function parseArgs(argv) {
|
|
|
2466
2658
|
options.runtime = "worker";
|
|
2467
2659
|
options.bundleDir = bundleDir;
|
|
2468
2660
|
}
|
|
2661
|
+
const assetsFlag = flags.get("assets");
|
|
2662
|
+
if (assetsFlag !== undefined || flags.has("no-assets")) {
|
|
2663
|
+
if (assetsFlag !== undefined && flags.has("no-assets")) {
|
|
2664
|
+
throw new Error("--assets and --no-assets cannot be combined.");
|
|
2665
|
+
}
|
|
2666
|
+
if (assetsFlag === true || assetsFlag === "") {
|
|
2667
|
+
throw new Error("--assets needs a directory, for example --assets public. Use --no-assets if nothing should be public.");
|
|
2668
|
+
}
|
|
2669
|
+
if (options.bundleDir) {
|
|
2670
|
+
throw new Error("--assets and --no-assets apply to --entry worker deploys; a --bundle deploy already serves only its assets directory.");
|
|
2671
|
+
}
|
|
2672
|
+
const workerRuntime = options.runtime === "worker" || (options.runtime === undefined && options.entry !== undefined);
|
|
2673
|
+
if (!workerRuntime) {
|
|
2674
|
+
throw new Error("--assets and --no-assets apply to worker deploys (--runtime worker --entry <file>); a static site serves every file.");
|
|
2675
|
+
}
|
|
2676
|
+
options.publicAssets = typeof assetsFlag === "string" ? normalizeAssetsDirectory(assetsFlag) : NO_PUBLIC_ASSETS;
|
|
2677
|
+
}
|
|
2469
2678
|
if (options.runtime === "container" && !options.port) {
|
|
2470
2679
|
throw new Error("Container deploys require --port <port>.");
|
|
2471
2680
|
}
|
|
@@ -3173,6 +3382,48 @@ function parseDurationSeconds(value) {
|
|
|
3173
3382
|
const seconds = unit === "d" ? amount * 24 * 60 * 60 : unit === "h" ? amount * 60 * 60 : unit === "m" ? amount * 60 : amount;
|
|
3174
3383
|
return Math.min(seconds, 7 * 24 * 60 * 60);
|
|
3175
3384
|
}
|
|
3385
|
+
// #1847: what an entry-based worker deploy publishes. Shown on every deploy that still
|
|
3386
|
+
// uses the legacy shape rather than only under `--check`, because the app that leaks is
|
|
3387
|
+
// the one whose author never thought to check.
|
|
3388
|
+
const PUBLIC_FILE_LIST_LIMIT = 10;
|
|
3389
|
+
export function workerPublicFiles(paths, serverEntry, selection) {
|
|
3390
|
+
const files = [];
|
|
3391
|
+
for (const filePath of paths) {
|
|
3392
|
+
const url = publicAssetPath(filePath, serverEntry, selection);
|
|
3393
|
+
if (url !== null) {
|
|
3394
|
+
files.push({ path: filePath, url });
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
return files;
|
|
3398
|
+
}
|
|
3399
|
+
export function legacyPublicAssetsWarning(serverEntry, publicPaths) {
|
|
3400
|
+
const shown = publicPaths.slice(0, PUBLIC_FILE_LIST_LIMIT);
|
|
3401
|
+
const more = publicPaths.length - shown.length;
|
|
3402
|
+
return [
|
|
3403
|
+
`this worker app serves every file in the deploy directory except its entry (${serverEntry}) at a public URL - ${publicPaths.length} file(s).`,
|
|
3404
|
+
"Choose what is public with --assets <dir> (only that directory is served; for example --assets public) or --no-assets. The app remembers the choice for later deploys.",
|
|
3405
|
+
`Public now: ${shown.length > 0 ? shown.join(", ") : "none"}${more > 0 ? `, and ${more} more (9pm deploy --check lists them all)` : ""}`,
|
|
3406
|
+
].join("\n ");
|
|
3407
|
+
}
|
|
3408
|
+
export function privateLookingPublicWarning(paths, selection) {
|
|
3409
|
+
const shown = paths.slice(0, PUBLIC_FILE_LIST_LIMIT);
|
|
3410
|
+
const more = paths.length - shown.length;
|
|
3411
|
+
const remedy = selection === null
|
|
3412
|
+
? "Choose a public directory with --assets <dir>, or move them out of the deploy directory."
|
|
3413
|
+
: `Move them out of ${selection}.`;
|
|
3414
|
+
return `these files look like server code or leftovers, and anyone can download them: ${shown.join(", ")}${more > 0 ? `, and ${more} more` : ""}. ${remedy}`;
|
|
3415
|
+
}
|
|
3416
|
+
// The files a deploy sends: everything, unless a worker selection narrows it to the entry
|
|
3417
|
+
// and the public files. The same set the server holds to the per-file limit.
|
|
3418
|
+
function uploadedFiles(manifest, serverEntry, selection) {
|
|
3419
|
+
if (!serverEntry || selection === null) {
|
|
3420
|
+
return manifest;
|
|
3421
|
+
}
|
|
3422
|
+
return manifest.filter((file) => file.path === serverEntry || publicAssetPath(file.path, serverEntry, selection) !== null);
|
|
3423
|
+
}
|
|
3424
|
+
function missingAssetsDirectoryMessage(selection) {
|
|
3425
|
+
return `--assets ${selection.slice(0, -1)}: no files were found under ${selection} in the deploy directory.`;
|
|
3426
|
+
}
|
|
3176
3427
|
function normalizeServerEntry(value) {
|
|
3177
3428
|
const normalized = value.trim().replaceAll("\\", "/").replace(/^\.\/+/, "");
|
|
3178
3429
|
if (!normalized || normalized.startsWith("/") || normalized.split("/").includes("..")) {
|
|
@@ -3186,18 +3437,17 @@ async function validateWorkerEntrySource(source, filePath) {
|
|
|
3186
3437
|
if (/\bfrom\s+["'][^"']+["']/.test(source) || /\bimport\s*\(/.test(source) || /(^|\n)\s*import\s+["'][^"']+["']/.test(source)) {
|
|
3187
3438
|
errors.push("Worker runtime entry must be bundled into one module with no external imports.");
|
|
3188
3439
|
}
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
}
|
|
3440
|
+
// Whether the entry has a default export is decided by evaluating it as an ES module,
|
|
3441
|
+
// which is what the server uploads unmodified, not by matching its text:
|
|
3442
|
+
// `export { handler as default, helper }`, a comment before `default`, and a string or
|
|
3443
|
+
// escaped export name are all valid defaults a text match would refuse (#1851).
|
|
3194
3444
|
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
3445
|
try {
|
|
3199
|
-
const module = await import(`data:text/javascript;base64,${Buffer.from(
|
|
3200
|
-
if (
|
|
3446
|
+
const module = await import(`data:text/javascript;base64,${Buffer.from(source).toString("base64")}`);
|
|
3447
|
+
if (!("default" in module)) {
|
|
3448
|
+
errors.push(`Worker runtime entry ${filePath} must export a default fetch handler.`);
|
|
3449
|
+
}
|
|
3450
|
+
else if (typeof module.default?.fetch !== "function" && typeof module.default !== "function") {
|
|
3201
3451
|
errors.push("Default export must be a function or an object with fetch(request, env, ctx).");
|
|
3202
3452
|
}
|
|
3203
3453
|
}
|
|
@@ -4001,6 +4251,12 @@ async function main() {
|
|
|
4001
4251
|
const subject = moduleCount === 1 ? "entry" : `${moduleCount} server modules`;
|
|
4002
4252
|
console.log(`Server code (${subject}): ${compressedBytes.toLocaleString("en-US")} bytes compressed of ${MAX_WORKER_ENTRY_COMPRESSED_BYTES.toLocaleString("en-US")} allowed.`);
|
|
4003
4253
|
}
|
|
4254
|
+
if (check.publicFiles) {
|
|
4255
|
+
console.log(check.publicFiles.length > 0 ? `Public files (${check.publicFiles.length}):` : "Public files: none.");
|
|
4256
|
+
for (const file of check.publicFiles) {
|
|
4257
|
+
console.log(` ${file.path} -> ${file.url}`);
|
|
4258
|
+
}
|
|
4259
|
+
}
|
|
4004
4260
|
}
|
|
4005
4261
|
return;
|
|
4006
4262
|
}
|
|
@@ -4054,13 +4310,30 @@ async function main() {
|
|
|
4054
4310
|
return;
|
|
4055
4311
|
}
|
|
4056
4312
|
if (command.kind === "login") {
|
|
4313
|
+
// Each login used to mint another full-scope key (#1779); keep a working one.
|
|
4314
|
+
if (!command.fresh) {
|
|
4315
|
+
const probe = await probeStoredLogin({ apiUrl: command.apiUrl });
|
|
4316
|
+
if (probe.kind === "reuse") {
|
|
4317
|
+
console.log(`Already logged in${probe.email ? ` as ${probe.email}` : ""}. Run "9pm login --fresh" to replace this machine's credential.`);
|
|
4318
|
+
return;
|
|
4319
|
+
}
|
|
4320
|
+
if (probe.kind === "unverifiable") {
|
|
4321
|
+
throw new Error(`Could not check the credential already stored on this machine (${probe.reason}). Retry, or run "9pm login --fresh" to replace it.`);
|
|
4322
|
+
}
|
|
4323
|
+
}
|
|
4057
4324
|
const result = await loginDevice({ apiUrl: command.apiUrl, allowFileCredentialStore: command.allowFileCredentialStore });
|
|
4058
4325
|
console.log(`Logged in${result.email ? ` as ${result.email}` : ""}. Credential stored in ${result.storage}.`);
|
|
4326
|
+
for (const line of replacedCredentialMessages(result, command.apiUrl)) {
|
|
4327
|
+
console.error(line);
|
|
4328
|
+
}
|
|
4059
4329
|
return;
|
|
4060
4330
|
}
|
|
4061
4331
|
if (command.kind === "signup") {
|
|
4062
4332
|
const result = await signupDevice({ apiUrl: command.apiUrl, allowFileCredentialStore: command.allowFileCredentialStore });
|
|
4063
4333
|
console.log(`Signed up${result.email ? ` as ${result.email}` : ""}. Credential stored in ${result.storage}.`);
|
|
4334
|
+
for (const line of replacedCredentialMessages(result, command.apiUrl)) {
|
|
4335
|
+
console.error(line);
|
|
4336
|
+
}
|
|
4064
4337
|
return;
|
|
4065
4338
|
}
|
|
4066
4339
|
if (command.kind === "logout") {
|
|
@@ -4534,14 +4807,14 @@ function printUsage() {
|
|
|
4534
4807
|
console.log(`Usage:
|
|
4535
4808
|
9pm --version
|
|
4536
4809
|
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]
|
|
4810
|
+
9pm deploy [directory] --runtime worker --entry src/index.js (--assets <dir> | --no-assets) [--with-db]
|
|
4538
4811
|
9pm deploy [directory] --runtime container --port 8080 [--health-path /health] [--region auto] [--with-db] [--always-on] [--volume <gb> | --no-volume] [--cpus <n>] [--memory <size>]
|
|
4539
4812
|
9pm deploy [directory] framework projects (Next.js, Vite) are detected and built automatically; --no-build skips
|
|
4540
4813
|
9pm deploy [directory] --bundle <dir> deploy a pre-built Cloudflare framework bundle (e.g. .open-next)
|
|
4541
4814
|
9pm deploy [directory] --description "<note>" optional short note shown next to the version in the dashboard
|
|
4542
4815
|
9pm deploy [directory] --check
|
|
4543
4816
|
9pm signup
|
|
4544
|
-
9pm login
|
|
4817
|
+
9pm login [--fresh] keeps a working stored credential; --fresh replaces it and revokes the old one
|
|
4545
4818
|
9pm logout [--api-url <url>] [--local] revokes the credential server-side, then removes it from this machine (--local skips the revoke)
|
|
4546
4819
|
9pm doctor [--json]
|
|
4547
4820
|
9pm whoami [--json]
|
|
@@ -4584,10 +4857,18 @@ Size limits:
|
|
|
4584
4857
|
Individual files are limited to ${formatMebibytes(MAX_STATIC_ASSET_FILE_BYTES)}.
|
|
4585
4858
|
Worker runtime: server code is limited to ${formatMegabytes(MAX_WORKER_ENTRY_COMPRESSED_BYTES)} compressed (gzip) — the ${formatMegabytes(MAX_WORKER_SCRIPT_COMPRESSED_BYTES)} script limit
|
|
4586
4859
|
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
|
-
|
|
4860
|
+
with --bundle every server module together, since they deploy as one script. Public files
|
|
4861
|
+
are served from the asset store instead, so they do not count toward that limit — keep large
|
|
4862
|
+
images, fonts and datasets as public files rather than inlining them into the entry. Apps
|
|
4863
|
+
needing more server code than that should deploy as containers.
|
|
4864
|
+
|
|
4865
|
+
Worker public files:
|
|
4866
|
+
--assets <dir> only the files under <dir> are public, served from the app's root
|
|
4867
|
+
(<dir>/index.html answers on /). Nothing else in the directory is uploaded.
|
|
4868
|
+
--no-assets nothing is public; the entry answers every request.
|
|
4869
|
+
The app remembers the choice, so later deploys need not repeat it; pass the other flag to
|
|
4870
|
+
change it. A worker app that never chose serves EVERY file except its entry publicly, and
|
|
4871
|
+
each deploy warns about it. --check lists the files a deploy would make public.
|
|
4591
4872
|
|
|
4592
4873
|
Container deploys:
|
|
4593
4874
|
Requires Docker locally. The CLI builds linux/amd64, exports the image, uploads it through a short-lived session, then finalizes a queued deployment.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const NO_PUBLIC_ASSETS = "none";
|
|
2
|
+
export declare function normalizeAssetsDirectory(value: string): string;
|
|
3
|
+
export declare function parsePublicAssetsSelection(value: unknown): string | null;
|
|
4
|
+
export declare function publicAssetPath(filePath: string, serverEntry: string | null, selection: string | null): string | null;
|
|
5
|
+
export declare function privateLookingPaths(filePaths: readonly string[], serverEntry: string | null): string[];
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// AUTO-GENERATED by scripts/generate-cli-package.mjs from the in-repo CLI source -- do not edit.
|
|
2
|
+
// Edit src/cli or src/shared instead; this is regenerated on typecheck/test/build/pack.
|
|
3
|
+
// Which files of a worker-runtime deploy become public URLs (#1847). Shared by the control
|
|
4
|
+
// plane, which enforces it at publish, and the CLI, which warns and trims what it uploads.
|
|
5
|
+
// Keep this module dependency-free: it is copied verbatim into the shipped ninepm tarball.
|
|
6
|
+
//
|
|
7
|
+
// A selection is one of two strings, and the same string is used everywhere it travels -
|
|
8
|
+
// the `--assets`/`--no-assets` flags, `metadata.publicAssets` on a deployment, and the
|
|
9
|
+
// project's `worker_public_assets` column:
|
|
10
|
+
// - a relative directory prefix ending in "/" (e.g. "public/"): only files under it are
|
|
11
|
+
// served, at their path below it, so `public/index.html` answers on `/index.html`;
|
|
12
|
+
// - NO_PUBLIC_ASSETS: nothing is served from the deploy directory at all.
|
|
13
|
+
// Absence (null) is the legacy shape every app had before this existed: every file except
|
|
14
|
+
// the entry is served at its own path. It is never a value a client can send, so once an
|
|
15
|
+
// app has chosen, no later deploy can widen it back to legacy by omission.
|
|
16
|
+
export const NO_PUBLIC_ASSETS = "none";
|
|
17
|
+
// Turns what a person typed after `--assets` into the canonical prefix, or throws with a
|
|
18
|
+
// message that names the problem. The deploy root itself is refused: the entry lives
|
|
19
|
+
// there, so "everything" is the legacy shape rather than a selection, and allowing it
|
|
20
|
+
// would let a flag meant to narrow publication silently reproduce the problem it fixes.
|
|
21
|
+
export function normalizeAssetsDirectory(value) {
|
|
22
|
+
const trimmed = value.trim();
|
|
23
|
+
if (trimmed.includes("\\")) {
|
|
24
|
+
throw new Error(`--assets must use "/" between directory names: ${value}`);
|
|
25
|
+
}
|
|
26
|
+
if (trimmed.startsWith("/")) {
|
|
27
|
+
throw new Error(`--assets must be a directory inside the deploy directory, not an absolute path: ${value}`);
|
|
28
|
+
}
|
|
29
|
+
const segments = trimmed.split("/").filter((segment, index) => segment !== "" && !(index === 0 && segment === "."));
|
|
30
|
+
if (segments.length === 0) {
|
|
31
|
+
throw new Error("--assets must name a subdirectory (for example --assets public). The deploy directory itself holds your server code; use --no-assets if nothing should be public.");
|
|
32
|
+
}
|
|
33
|
+
if (segments.some((segment) => segment === "." || segment === "..")) {
|
|
34
|
+
throw new Error(`--assets must be a directory inside the deploy directory: ${value}`);
|
|
35
|
+
}
|
|
36
|
+
return `${segments.join("/")}/`;
|
|
37
|
+
}
|
|
38
|
+
// The server's reading of a stored or client-sent selection: the canonical form only,
|
|
39
|
+
// never a lenient one, so the value that was validated is byte for byte the value used.
|
|
40
|
+
export function parsePublicAssetsSelection(value) {
|
|
41
|
+
if (value === NO_PUBLIC_ASSETS) {
|
|
42
|
+
return NO_PUBLIC_ASSETS;
|
|
43
|
+
}
|
|
44
|
+
if (typeof value !== "string" || !value.endsWith("/")) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
return normalizeAssetsDirectory(value) === value ? value : null;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// The URL path a deploy file is served on, or null when it must not be public. The entry
|
|
55
|
+
// is never public under any selection: it is uploaded as the Worker's own module.
|
|
56
|
+
export function publicAssetPath(filePath, serverEntry, selection) {
|
|
57
|
+
if (serverEntry !== null && filePath === serverEntry) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if (selection === null) {
|
|
61
|
+
return filePath.startsWith("/") ? filePath : `/${filePath}`;
|
|
62
|
+
}
|
|
63
|
+
if (selection === NO_PUBLIC_ASSETS || !filePath.startsWith(selection)) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return `/${filePath.slice(selection.length)}`;
|
|
67
|
+
}
|
|
68
|
+
const SCRIPT_EXTENSION = /\.(js|mjs|cjs|ts|mts|cts|jsx|tsx)$/i;
|
|
69
|
+
const LEFTOVER_FILE = /(\.(orig|bak|old|rej|swp|swo|tmp)|~)$/i;
|
|
70
|
+
// Public files that look like they were never meant to be: editor and merge leftovers,
|
|
71
|
+
// dotfiles, and other script files in the entry's own directory (which is where server
|
|
72
|
+
// helpers live). A heuristic for a warning, never a filter - a client script beside the
|
|
73
|
+
// entry is legitimate, which is why the fix is a selection and not this list.
|
|
74
|
+
export function privateLookingPaths(filePaths, serverEntry) {
|
|
75
|
+
const entryDirectory = serverEntry !== null && serverEntry.includes("/") ? serverEntry.slice(0, serverEntry.lastIndexOf("/") + 1) : "";
|
|
76
|
+
return filePaths.filter((filePath) => {
|
|
77
|
+
if (filePath === serverEntry) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
const name = filePath.slice(filePath.lastIndexOf("/") + 1);
|
|
81
|
+
if (LEFTOVER_FILE.test(name) || filePath.split("/").some((segment) => segment.startsWith(".") && segment !== ".well-known")) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
if (!SCRIPT_EXTENSION.test(name)) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
// Beside the entry: under its directory when it has one, or directly at the root
|
|
88
|
+
// when the entry itself sits there (a root-level entry says nothing about subfolders).
|
|
89
|
+
return entryDirectory === "" ? !filePath.includes("/") : filePath.startsWith(entryDirectory);
|
|
90
|
+
});
|
|
91
|
+
}
|