ninepm 0.60.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 +78 -8
- package/dist/index.js +328 -31
- 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 & {
|
|
@@ -396,6 +458,7 @@ export interface DeviceAuthProgressReporter {
|
|
|
396
458
|
export declare function createDeviceAuthProgressReporter(stream?: NodeJS.WriteStream): DeviceAuthProgressReporter;
|
|
397
459
|
export declare function noteCliOutdatedAdvisory(response: Response, log?: (line: string) => void): void;
|
|
398
460
|
export declare function resetCliOutdatedAdvisoryForTests(): void;
|
|
461
|
+
export declare function dashboardUrlForApiUrl(apiUrl: string): string;
|
|
399
462
|
type CliCommand = {
|
|
400
463
|
kind: "deploy";
|
|
401
464
|
options: DeployOptions;
|
|
@@ -405,6 +468,7 @@ type CliCommand = {
|
|
|
405
468
|
kind: "login";
|
|
406
469
|
apiUrl: string;
|
|
407
470
|
allowFileCredentialStore: boolean;
|
|
471
|
+
fresh: boolean;
|
|
408
472
|
} | {
|
|
409
473
|
kind: "signup";
|
|
410
474
|
apiUrl: string;
|
|
@@ -591,6 +655,12 @@ export declare function parseEnvFileEntries(content: string, kind: "secret" | "v
|
|
|
591
655
|
kind: "secret" | "var";
|
|
592
656
|
}>;
|
|
593
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;
|
|
594
664
|
export declare function storedCredentialTokens(): StoredCredentialEntry[];
|
|
595
665
|
/**
|
|
596
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.
|
|
@@ -2178,6 +2363,22 @@ function withFetchDiagnostics(fetchFn) {
|
|
|
2178
2363
|
function normalizeApiUrl(value) {
|
|
2179
2364
|
return value.replace(/\/+$/g, "");
|
|
2180
2365
|
}
|
|
2366
|
+
// The dashboard that manages what this CLI just deployed lives on the same
|
|
2367
|
+
// control plane as the API it talked to: api-<env>.9pm.ai is served beside
|
|
2368
|
+
// <env>.9pm.ai, and api.9pm.ai beside the public site. An origin outside that
|
|
2369
|
+
// shape (local dev, an unparseable URL) names production, the only dashboard a
|
|
2370
|
+
// tester could hold an account on.
|
|
2371
|
+
export function dashboardUrlForApiUrl(apiUrl) {
|
|
2372
|
+
let host;
|
|
2373
|
+
try {
|
|
2374
|
+
host = new URL(normalizeApiUrl(apiUrl)).hostname.toLowerCase();
|
|
2375
|
+
}
|
|
2376
|
+
catch {
|
|
2377
|
+
return PUBLIC_DASHBOARD_URL;
|
|
2378
|
+
}
|
|
2379
|
+
const environment = /^api-([a-z0-9-]+)\.9pm\.ai$/.exec(host);
|
|
2380
|
+
return environment ? `https://${environment[1]}.9pm.ai/dashboard` : PUBLIC_DASHBOARD_URL;
|
|
2381
|
+
}
|
|
2181
2382
|
function pathFromUpload(upload, deploymentId) {
|
|
2182
2383
|
const marker = `/v1/uploads/${encodeURIComponent(deploymentId)}/`;
|
|
2183
2384
|
const index = upload.path.indexOf(marker);
|
|
@@ -2222,6 +2423,8 @@ const VALID_DEPLOY_FLAGS = new Set([
|
|
|
2222
2423
|
"memory",
|
|
2223
2424
|
"no-build",
|
|
2224
2425
|
"bundle",
|
|
2426
|
+
"assets",
|
|
2427
|
+
"no-assets",
|
|
2225
2428
|
]);
|
|
2226
2429
|
export function parseArgs(argv) {
|
|
2227
2430
|
const command = argv[0];
|
|
@@ -2248,7 +2451,12 @@ export function parseArgs(argv) {
|
|
|
2248
2451
|
}
|
|
2249
2452
|
if (command === "login") {
|
|
2250
2453
|
const flags = parseFlags(argv);
|
|
2251
|
-
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
|
+
};
|
|
2252
2460
|
}
|
|
2253
2461
|
if (command === "signup") {
|
|
2254
2462
|
const flags = parseFlags(argv);
|
|
@@ -2450,6 +2658,23 @@ export function parseArgs(argv) {
|
|
|
2450
2658
|
options.runtime = "worker";
|
|
2451
2659
|
options.bundleDir = bundleDir;
|
|
2452
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
|
+
}
|
|
2453
2678
|
if (options.runtime === "container" && !options.port) {
|
|
2454
2679
|
throw new Error("Container deploys require --port <port>.");
|
|
2455
2680
|
}
|
|
@@ -3157,6 +3382,48 @@ function parseDurationSeconds(value) {
|
|
|
3157
3382
|
const seconds = unit === "d" ? amount * 24 * 60 * 60 : unit === "h" ? amount * 60 * 60 : unit === "m" ? amount * 60 : amount;
|
|
3158
3383
|
return Math.min(seconds, 7 * 24 * 60 * 60);
|
|
3159
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
|
+
}
|
|
3160
3427
|
function normalizeServerEntry(value) {
|
|
3161
3428
|
const normalized = value.trim().replaceAll("\\", "/").replace(/^\.\/+/, "");
|
|
3162
3429
|
if (!normalized || normalized.startsWith("/") || normalized.split("/").includes("..")) {
|
|
@@ -3170,18 +3437,17 @@ async function validateWorkerEntrySource(source, filePath) {
|
|
|
3170
3437
|
if (/\bfrom\s+["'][^"']+["']/.test(source) || /\bimport\s*\(/.test(source) || /(^|\n)\s*import\s+["'][^"']+["']/.test(source)) {
|
|
3171
3438
|
errors.push("Worker runtime entry must be bundled into one module with no external imports.");
|
|
3172
3439
|
}
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
}
|
|
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).
|
|
3178
3444
|
if (errors.length === 0) {
|
|
3179
|
-
const transformed = hasLiteralDefault
|
|
3180
|
-
? source
|
|
3181
|
-
: `${source.replace(/export\s*\{\s*([A-Za-z_$][\w$]*)\s+as\s+default\s*,?\s*\}\s*;?\s*$/, "export default $1;")}`;
|
|
3182
3445
|
try {
|
|
3183
|
-
const module = await import(`data:text/javascript;base64,${Buffer.from(
|
|
3184
|
-
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") {
|
|
3185
3451
|
errors.push("Default export must be a function or an object with fetch(request, env, ctx).");
|
|
3186
3452
|
}
|
|
3187
3453
|
}
|
|
@@ -3985,6 +4251,12 @@ async function main() {
|
|
|
3985
4251
|
const subject = moduleCount === 1 ? "entry" : `${moduleCount} server modules`;
|
|
3986
4252
|
console.log(`Server code (${subject}): ${compressedBytes.toLocaleString("en-US")} bytes compressed of ${MAX_WORKER_ENTRY_COMPRESSED_BYTES.toLocaleString("en-US")} allowed.`);
|
|
3987
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
|
+
}
|
|
3988
4260
|
}
|
|
3989
4261
|
return;
|
|
3990
4262
|
}
|
|
@@ -4026,7 +4298,7 @@ async function main() {
|
|
|
4026
4298
|
if (queued) {
|
|
4027
4299
|
console.log(`Status: ${result.deployment.status}. Run 9pm apps to check when it becomes active.`);
|
|
4028
4300
|
}
|
|
4029
|
-
console.log(`Manage: ${
|
|
4301
|
+
console.log(`Manage: ${dashboardUrlForApiUrl(command.options.apiUrl)}${command.options.owner ? `?owner=${encodeURIComponent(command.options.owner)}` : ""}`);
|
|
4030
4302
|
// Gated the same way as the reachability probe above: a programmatic
|
|
4031
4303
|
// caller injecting fetchFn opted out of the CLI's own network side trips.
|
|
4032
4304
|
if (!command.options.fetchFn) {
|
|
@@ -4038,13 +4310,30 @@ async function main() {
|
|
|
4038
4310
|
return;
|
|
4039
4311
|
}
|
|
4040
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
|
+
}
|
|
4041
4324
|
const result = await loginDevice({ apiUrl: command.apiUrl, allowFileCredentialStore: command.allowFileCredentialStore });
|
|
4042
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
|
+
}
|
|
4043
4329
|
return;
|
|
4044
4330
|
}
|
|
4045
4331
|
if (command.kind === "signup") {
|
|
4046
4332
|
const result = await signupDevice({ apiUrl: command.apiUrl, allowFileCredentialStore: command.allowFileCredentialStore });
|
|
4047
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
|
+
}
|
|
4048
4337
|
return;
|
|
4049
4338
|
}
|
|
4050
4339
|
if (command.kind === "logout") {
|
|
@@ -4518,14 +4807,14 @@ function printUsage() {
|
|
|
4518
4807
|
console.log(`Usage:
|
|
4519
4808
|
9pm --version
|
|
4520
4809
|
9pm deploy [directory] --name <site-name> [--slug <slug>] [--owner <organisation>] [--description "<note>"] [--with-db] [--with-auth] [--with-email]
|
|
4521
|
-
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]
|
|
4522
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>]
|
|
4523
4812
|
9pm deploy [directory] framework projects (Next.js, Vite) are detected and built automatically; --no-build skips
|
|
4524
4813
|
9pm deploy [directory] --bundle <dir> deploy a pre-built Cloudflare framework bundle (e.g. .open-next)
|
|
4525
4814
|
9pm deploy [directory] --description "<note>" optional short note shown next to the version in the dashboard
|
|
4526
4815
|
9pm deploy [directory] --check
|
|
4527
4816
|
9pm signup
|
|
4528
|
-
9pm login
|
|
4817
|
+
9pm login [--fresh] keeps a working stored credential; --fresh replaces it and revokes the old one
|
|
4529
4818
|
9pm logout [--api-url <url>] [--local] revokes the credential server-side, then removes it from this machine (--local skips the revoke)
|
|
4530
4819
|
9pm doctor [--json]
|
|
4531
4820
|
9pm whoami [--json]
|
|
@@ -4568,10 +4857,18 @@ Size limits:
|
|
|
4568
4857
|
Individual files are limited to ${formatMebibytes(MAX_STATIC_ASSET_FILE_BYTES)}.
|
|
4569
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
|
|
4570
4859
|
less a small reserve for the generated wrapper. That budget covers the --entry module, or
|
|
4571
|
-
with --bundle every server module together, since they deploy as one script.
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
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.
|
|
4575
4872
|
|
|
4576
4873
|
Container deploys:
|
|
4577
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
|
+
}
|