ninepm 0.0.1 → 0.26.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/dist/index.js ADDED
@@ -0,0 +1,2530 @@
1
+ #!/usr/bin/env node
2
+ // AUTO-GENERATED by scripts/generate-cli-package.mjs from the in-repo CLI source -- do not edit.
3
+ // Edit src/cli or src/shared instead; this is regenerated on typecheck/test/build/pack.
4
+ import { spawnSync } from "node:child_process";
5
+ import { createHash } from "node:crypto";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
9
+ import { fileURLToPath } from "node:url";
10
+ import { MAX_DEPLOY_DESCRIPTION_LENGTH, normalizeDeployDescription } from "./deploy-description.js";
11
+ import { assetFileTooLargeMessage, oversizedAssets } from "./deploy-limits.js";
12
+ import { buildFileManifest, readManifestFile } from "./manifest.js";
13
+ import { API_KEY_HELP, API_URL_HELP, CLI_CLIENT_ID, DEFAULT_API_URL, reportCliError } from "./cli-config.js";
14
+ export const CLI_VERSION = "0.26.0";
15
+ const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
16
+ const TOKEN_SERVICE = "9pm";
17
+ const TOKEN_ACCOUNT = "default";
18
+ const CREDENTIAL_METADATA_FILE = "credentials.json";
19
+ const WINDOWS_CREDENTIAL_FILE = "credential.dpapi";
20
+ const PLAINTEXT_CREDENTIAL_FILE = "credential.plaintext.json";
21
+ const ALLOW_FILE_CREDENTIAL_STORE_ENV = "NINEPM_ALLOW_FILE_CREDENTIAL_STORE";
22
+ const DISABLE_NATIVE_CREDENTIAL_STORE_ENV = "NINEPM_DISABLE_NATIVE_CREDENTIAL_STORE";
23
+ const WORKER_ENTRY_LIMIT_BYTES = 1_000_000;
24
+ const WORKER_ENTRY_WARN_BYTES = Math.floor(WORKER_ENTRY_LIMIT_BYTES * 0.8);
25
+ const DEFAULT_CONTAINER_POLL_TIMEOUT_MS = 15 * 60 * 1000;
26
+ export function detectFramework(directory) {
27
+ let dependencies = {};
28
+ try {
29
+ const pkg = JSON.parse(readFileSync(path.join(directory, "package.json"), "utf8"));
30
+ dependencies = { ...pkg.dependencies, ...pkg.devDependencies };
31
+ }
32
+ catch {
33
+ // No package.json: plain static directory.
34
+ }
35
+ const hasConfig = (base) => ["js", "mjs", "cjs", "ts", "mts"].some((ext) => existsSync(path.join(directory, `${base}.${ext}`)));
36
+ if (hasConfig("next.config") || dependencies.next) {
37
+ return "nextjs";
38
+ }
39
+ if (hasConfig("astro.config") || dependencies.astro) {
40
+ return "astro";
41
+ }
42
+ if (dependencies["@sveltejs/kit"]) {
43
+ return "sveltekit";
44
+ }
45
+ if (hasConfig("vite.config") || dependencies.vite) {
46
+ return "vite";
47
+ }
48
+ return null;
49
+ }
50
+ export const DEFAULT_OPEN_NEXT_CONFIG = `import { defineCloudflareConfig } from "@opennextjs/cloudflare";
51
+ export default defineCloudflareConfig();
52
+ `;
53
+ export function defaultNextWranglerConfig(appName) {
54
+ return `${JSON.stringify({
55
+ name: appName,
56
+ main: ".open-next/worker.js",
57
+ compatibility_date: "2026-05-01",
58
+ compatibility_flags: ["nodejs_compat"],
59
+ assets: { directory: ".open-next/assets", binding: "ASSETS" },
60
+ }, null, 2)}\n`;
61
+ }
62
+ // Build-step runner: the framework's own output streams straight through —
63
+ // when a deploying agent reads it, the error IS the fix instruction.
64
+ function runBuildStep(label, command, args, cwd) {
65
+ console.log(`[9pm] ${label}: ${command} ${args.join(" ")}`);
66
+ const result = spawnSync(command, args, { cwd, stdio: "inherit", shell: process.platform === "win32" });
67
+ if (result.status !== 0) {
68
+ throw new Error(`${label} failed (exit ${result.status ?? "signal"}). The output above is the build tool's own error — fix the app and re-run 9pm deploy, or skip builds with --no-build.`);
69
+ }
70
+ }
71
+ function ensureProjectDependencies(directory) {
72
+ if (!existsSync(path.join(directory, "node_modules"))) {
73
+ runBuildStep("Installing dependencies", "npm", ["install"], directory);
74
+ }
75
+ }
76
+ // Exported for tests: the install-vs-skip decision is pure filesystem state.
77
+ // Walk up node_modules the way Node resolves modules, so a package hoisted to an
78
+ // ancestor node_modules (npm/yarn/pnpm workspaces) is detected and we don't
79
+ // force a redundant install that, in a workspace, often fails on workspace:*
80
+ // deps. Check the package's package.json rather than just the directory, so a
81
+ // stale/empty dir left by a failed install is still treated as not installed.
82
+ export function isOpenNextInstalled(directory) {
83
+ let dir = path.resolve(directory);
84
+ for (;;) {
85
+ if (existsSync(path.join(dir, "node_modules", "@opennextjs", "cloudflare", "package.json"))) {
86
+ return true;
87
+ }
88
+ const parent = path.dirname(dir);
89
+ if (parent === dir)
90
+ return false;
91
+ dir = parent;
92
+ }
93
+ }
94
+ // The OpenNext build and the generated open-next.config.ts both import from
95
+ // `@opennextjs/cloudflare`, so it must resolve inside the project - not just be
96
+ // runnable via npx. ensureProjectDependencies won't add it when node_modules
97
+ // already exists but the package isn't a dependency, so install it explicitly.
98
+ function ensureOpenNextInstalled(directory) {
99
+ if (!isOpenNextInstalled(directory)) {
100
+ console.log("[9pm] Installing the framework's Worker-runtime adapter (added to your devDependencies).");
101
+ runBuildStep("Installing the Worker-runtime adapter", "npm", ["install", "--save-dev", "--include=dev", "@opennextjs/cloudflare@1"], directory);
102
+ }
103
+ }
104
+ function prepareNextDeploy(directory) {
105
+ console.log("[9pm] Detected Next.js — building for the Worker runtime.");
106
+ ensureProjectDependencies(directory);
107
+ ensureOpenNextInstalled(directory);
108
+ const openNextConfig = path.join(directory, "open-next.config.ts");
109
+ if (!existsSync(openNextConfig)) {
110
+ writeFileSync(openNextConfig, DEFAULT_OPEN_NEXT_CONFIG, "utf8");
111
+ console.log("[9pm] Created open-next.config.ts (default Worker-runtime adapter config) — consider committing it.");
112
+ }
113
+ const hasWranglerConfig = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"].some((file) => existsSync(path.join(directory, file)));
114
+ if (!hasWranglerConfig) {
115
+ writeFileSync(path.join(directory, "wrangler.jsonc"), defaultNextWranglerConfig(path.basename(path.resolve(directory))), "utf8");
116
+ console.log("[9pm] Created wrangler.jsonc (drives the local bundling step) — consider committing it.");
117
+ }
118
+ runBuildStep("OpenNext build", "npx", ["--yes", "@opennextjs/cloudflare@1", "build"], directory);
119
+ const distDir = path.join(directory, ".9pm", "dist");
120
+ rmSync(distDir, { recursive: true, force: true });
121
+ runBuildStep("Bundling worker", "npx", ["--yes", "wrangler@4", "deploy", "--dry-run", "--outdir", path.join(".9pm", "dist")], directory);
122
+ const workerModule = path.join(distDir, "worker.js");
123
+ if (!existsSync(workerModule)) {
124
+ throw new Error(`Bundling produced no ${workerModule}. Check the wrangler output above; its config must point main at .open-next/worker.js.`);
125
+ }
126
+ const bundleDir = path.join(directory, ".9pm", "bundle");
127
+ rmSync(bundleDir, { recursive: true, force: true });
128
+ mkdirSync(path.join(bundleDir), { recursive: true });
129
+ cpSync(workerModule, path.join(bundleDir, "worker.js"));
130
+ const assetsDir = path.join(directory, ".open-next", "assets");
131
+ if (existsSync(assetsDir)) {
132
+ cpSync(assetsDir, path.join(bundleDir, "assets"), { recursive: true });
133
+ }
134
+ return path.join(".9pm", "bundle");
135
+ }
136
+ function prepareViteDeploy(directory) {
137
+ console.log("[9pm] Detected Vite — building the static app.");
138
+ ensureProjectDependencies(directory);
139
+ runBuildStep("Vite build", "npm", ["run", "build"], directory);
140
+ const dist = path.join(directory, "dist");
141
+ if (!existsSync(path.join(dist))) {
142
+ throw new Error("Vite build finished but no dist/ directory exists. If the project uses a custom outDir, deploy that directory directly: 9pm deploy <outDir> --no-build.");
143
+ }
144
+ return dist;
145
+ }
146
+ // Zero-flag framework deploys: detect, run the framework's own Cloudflare
147
+ // pipeline locally, then continue as a bundle (Next) or static (Vite) deploy.
148
+ function prepareFrameworkDeploy(options) {
149
+ if (options.noBuild || options.runtime || options.bundleDir || options.entry) {
150
+ return options;
151
+ }
152
+ const framework = detectFramework(options.directory);
153
+ if (!framework) {
154
+ return options;
155
+ }
156
+ const appName = options.name ?? path.basename(path.resolve(options.directory));
157
+ if (framework === "nextjs") {
158
+ const bundleDir = prepareNextDeploy(options.directory);
159
+ return { ...options, name: appName, runtime: "worker", bundleDir };
160
+ }
161
+ if (framework === "vite") {
162
+ const dist = prepareViteDeploy(options.directory);
163
+ return { ...options, name: appName, directory: dist, runtime: "static" };
164
+ }
165
+ throw new Error(`${framework === "astro" ? "Astro" : "SvelteKit"} projects are not auto-built yet. Run the framework's Worker-runtime adapter build yourself (${framework === "astro" ? "@astrojs/cloudflare" : "@sveltejs/adapter-cloudflare"}), then deploy the output with: 9pm deploy . --bundle <adapter output dir> (Worker bundles) or 9pm deploy <static output dir> --no-build (static output).`);
166
+ }
167
+ function hasDockerfile(directory) {
168
+ return existsSync(path.join(path.resolve(directory), "Dockerfile"));
169
+ }
170
+ // A directory whose only meaningful artifact is a Dockerfile would otherwise be
171
+ // uploaded as static assets (server never runs, every route 404s). Refuse the
172
+ // silent misdeploy unless the runtime was chosen explicitly.
173
+ const DOCKERFILE_STATIC_HINT = "Found a Dockerfile but no runtime was selected, so this would deploy as a static site and the server would not run. Re-run with `--runtime container --port <n>` for a container app, or pass `--runtime static` to deploy the directory as static assets.";
174
+ // Raised when a deploy fails, carrying the *server-resolved* project slug (when
175
+ // the project was already created) so the failure hint can suggest
176
+ // `9pm support --site <slug>` — the slug /v1/me/support needs to attach the
177
+ // failed deploy's id/status/error. The display name (e.g. "My App") is not the
178
+ // slug (server-normalized to "my-app"), so the raw name would not resolve.
179
+ export class DeployError extends Error {
180
+ slug;
181
+ constructor(message, slug) {
182
+ super(message);
183
+ this.name = "DeployError";
184
+ this.slug = slug;
185
+ }
186
+ }
187
+ // Shell-quote a value for a copy-pasteable command. Clean slugs pass through
188
+ // unquoted; anything with shell-significant characters is single-quoted.
189
+ function quoteSiteArg(value) {
190
+ return /^[A-Za-z0-9._-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
191
+ }
192
+ export async function deployDirectory(initialOptions) {
193
+ const slugHolder = initialOptions.slug ? { slug: initialOptions.slug } : {};
194
+ try {
195
+ return await runDeploy(initialOptions, slugHolder);
196
+ }
197
+ catch (error) {
198
+ // If this invocation created the project (vs. redeploying an existing app)
199
+ // and the deploy then failed, roll the project back so a rejected first
200
+ // deploy leaves no orphaned app holding the slug and a Sites quota slot
201
+ // (#195). Best-effort: a cleanup failure must not mask the deploy error.
202
+ if (slugHolder.created && slugHolder.slug) {
203
+ try {
204
+ await deleteSite({
205
+ apiUrl: initialOptions.apiUrl,
206
+ token: initialOptions.token,
207
+ ...(initialOptions.fetchFn ? { fetchFn: initialOptions.fetchFn } : {}),
208
+ site: slugHolder.slug,
209
+ confirmSlug: slugHolder.slug,
210
+ });
211
+ }
212
+ catch {
213
+ // Swallow: surface the original deploy failure, not the cleanup failure.
214
+ }
215
+ }
216
+ if (error instanceof DeployError)
217
+ throw error;
218
+ throw new DeployError(error instanceof Error ? error.message : String(error), slugHolder.slug);
219
+ }
220
+ }
221
+ async function runDeploy(initialOptions, slugHolder) {
222
+ const options = prepareFrameworkDeploy(initialOptions);
223
+ const fetchFn = options.fetchFn ?? fetch;
224
+ const apiUrl = normalizeApiUrl(options.apiUrl);
225
+ const runtime = options.runtime ?? (options.entry ? "worker" : "static");
226
+ if (!options.runtime && runtime === "static" && hasDockerfile(options.directory)) {
227
+ throw new Error(DOCKERFILE_STATIC_HINT);
228
+ }
229
+ if (runtime === "container") {
230
+ if (!options.port) {
231
+ throw new Error("Container deploys require --port.");
232
+ }
233
+ const image = buildContainerImage(options.directory);
234
+ try {
235
+ const projectInput = { name: options.name ?? path.basename(path.resolve(options.directory)) };
236
+ const { project, created: projectCreated } = await createProject(fetchFn, apiUrl, options.token, options.slug ? { ...projectInput, slug: options.slug } : projectInput);
237
+ slugHolder.slug = project.slug;
238
+ slugHolder.created = projectCreated;
239
+ const database = options.withDatabase
240
+ ? await createDatabase(fetchFn, apiUrl, options.token, project.id, {
241
+ name: options.databaseName ?? `${project.slug}-db`,
242
+ binding: options.bindingName ?? "DB",
243
+ })
244
+ : null;
245
+ if (options.withAuth) {
246
+ await enableEndUserAuth(fetchFn, apiUrl, options.token, project.id);
247
+ }
248
+ const created = await createDeployment(fetchFn, apiUrl, options.token, project.id, {
249
+ files: [],
250
+ environment: options.environment ?? "production",
251
+ ...(options.description ? { description: options.description } : {}),
252
+ runtime,
253
+ container: {
254
+ imageSize: image.size,
255
+ imageSha256: image.sha256,
256
+ port: options.port,
257
+ healthPath: options.healthPath ?? null,
258
+ region: options.region ?? "auto",
259
+ alwaysOn: options.alwaysOn ?? false,
260
+ // Only send volumeGb when the user specified a disk (--volume) or
261
+ // explicitly none (--no-volume). Omitting it lets the server carry the
262
+ // prior deployment's disk forward instead of dropping it.
263
+ ...(options.volumeGb !== undefined ? { volumeGb: options.volumeGb } : {}),
264
+ ...(options.allowVolumeDestroy ? { allowVolumeDestroy: true } : {}),
265
+ ...(options.allowNamespaceReset ? { allowNamespaceReset: true } : {}),
266
+ ...(options.cpus !== undefined ? { cpus: options.cpus } : {}),
267
+ ...(options.memoryMb !== undefined ? { memoryMb: options.memoryMb } : {}),
268
+ },
269
+ metadata: {
270
+ cli: CLI_CLIENT_ID,
271
+ inspiredBy: "here.now",
272
+ databaseBinding: database?.bindingName ?? null,
273
+ runtime,
274
+ port: options.port,
275
+ healthPath: options.healthPath ?? null,
276
+ region: options.region ?? "auto",
277
+ },
278
+ });
279
+ if (!created.containerImageUpload) {
280
+ throw new Error("Container deployment did not return an image upload session.");
281
+ }
282
+ await uploadContainerImage(fetchFn, image.path, created.containerImageUpload);
283
+ const queued = await finalizeDeployment(fetchFn, apiUrl, options.token, created.deployment.id);
284
+ const finalized = await pollDeployment(fetchFn, apiUrl, options.token, queued.id, options.pollTimeoutMs ?? DEFAULT_CONTAINER_POLL_TIMEOUT_MS, queued);
285
+ if (finalized.status === "failed") {
286
+ throw new Error(deploymentFailureMessage(finalized));
287
+ }
288
+ return {
289
+ project,
290
+ deployment: finalized,
291
+ database,
292
+ uploaded: 1,
293
+ url: finalized.url,
294
+ containerImageBytes: image.size,
295
+ };
296
+ }
297
+ finally {
298
+ cleanupContainerImage(image);
299
+ }
300
+ }
301
+ const contentRoot = options.bundleDir ? path.resolve(options.directory, options.bundleDir) : options.directory;
302
+ let manifest = await buildFileManifest(contentRoot);
303
+ if (options.bundleDir) {
304
+ // Source maps are debugging aids, not runtime files; keep uploads lean.
305
+ manifest = manifest.filter((file) => !file.path.endsWith(".map"));
306
+ }
307
+ if (manifest.length === 0) {
308
+ throw new Error(options.bundleDir ? `Bundle directory contains no files: ${contentRoot}` : "Directory contains no deployable files.");
309
+ }
310
+ const bundle = options.bundleDir ? bundleMetadataFromManifest(manifest, contentRoot) : null;
311
+ const serverEntry = runtime === "worker" && !bundle ? normalizeServerEntry(options.entry ?? "src/index.js") : undefined;
312
+ if (serverEntry && !manifest.some((file) => file.path === serverEntry)) {
313
+ throw new Error(`Worker runtime entry file was not found in the deploy directory: ${serverEntry}`);
314
+ }
315
+ // Reject oversized assets before creating the project (#195): the server's size
316
+ // guard only fires on the later create-deployment call, which would otherwise
317
+ // leave an orphaned no-deploy app holding the slug and a Sites quota slot.
318
+ const [oversized] = oversizedAssets(manifest);
319
+ if (oversized) {
320
+ throw new Error(assetFileTooLargeMessage(oversized.path, oversized.size));
321
+ }
322
+ const projectInput = { name: options.name ?? path.basename(path.resolve(options.directory)) };
323
+ const { project, created: projectCreated } = await createProject(fetchFn, apiUrl, options.token, options.slug ? { ...projectInput, slug: options.slug } : projectInput);
324
+ slugHolder.slug = project.slug;
325
+ slugHolder.created = projectCreated;
326
+ const database = options.withDatabase
327
+ ? await createDatabase(fetchFn, apiUrl, options.token, project.id, {
328
+ name: options.databaseName ?? `${project.slug}-db`,
329
+ binding: options.bindingName ?? "DB",
330
+ })
331
+ : null;
332
+ if (options.withAuth) {
333
+ await enableEndUserAuth(fetchFn, apiUrl, options.token, project.id);
334
+ }
335
+ const created = await createDeployment(fetchFn, apiUrl, options.token, project.id, {
336
+ files: manifest,
337
+ environment: options.environment ?? "production",
338
+ ...(options.description ? { description: options.description } : {}),
339
+ runtime,
340
+ ...(serverEntry ? { serverEntry } : {}),
341
+ metadata: {
342
+ cli: CLI_CLIENT_ID,
343
+ inspiredBy: "here.now",
344
+ databaseBinding: database?.bindingName ?? null,
345
+ runtime,
346
+ serverEntry: serverEntry ?? null,
347
+ ...(bundle ? { bundle } : {}),
348
+ },
349
+ });
350
+ let uploaded = 0;
351
+ for (const upload of created.uploads) {
352
+ const file = manifest.find((candidate) => candidate.path === upload.path.split("/").slice(4).map(decodeURIComponent).join("/"));
353
+ const manifestPath = file?.path ?? pathFromUpload(upload, created.deployment.id);
354
+ const body = await readManifestFile(contentRoot, manifestPath);
355
+ const response = await fetchFn(upload.uploadUrl, {
356
+ method: upload.method,
357
+ headers: {
358
+ authorization: `Bearer ${options.token}`,
359
+ ...upload.headers,
360
+ },
361
+ body: arrayBufferFromBytes(body),
362
+ });
363
+ await expectOk(response, `upload ${manifestPath}`);
364
+ uploaded += 1;
365
+ }
366
+ const finalized = await finalizeDeployment(fetchFn, apiUrl, options.token, created.deployment.id);
367
+ return {
368
+ project,
369
+ deployment: finalized,
370
+ database,
371
+ uploaded,
372
+ url: finalized.url,
373
+ };
374
+ }
375
+ export async function checkDeployDirectory(options) {
376
+ const runtime = options.runtime ?? (options.entry ? "worker" : "static");
377
+ if (runtime === "container") {
378
+ const warnings = [];
379
+ const errors = [];
380
+ if (!options.port) {
381
+ errors.push("Container deploys require --port.");
382
+ }
383
+ if (!existsSync(path.join(path.resolve(options.directory), "Dockerfile"))) {
384
+ errors.push("Container deploys require a Dockerfile in the deployment directory.");
385
+ }
386
+ const docker = spawnSync("docker", ["--version"], { encoding: "utf8" });
387
+ if (docker.status !== 0) {
388
+ errors.push("Docker is required for local container builds.");
389
+ }
390
+ return { ok: errors.length === 0, warnings, errors, files: 0, bytes: 0 };
391
+ }
392
+ const manifest = await buildFileManifest(options.directory);
393
+ const serverEntry = runtime === "worker" ? normalizeServerEntry(options.entry ?? "src/index.js") : undefined;
394
+ const totalBytes = manifest.reduce((sum, file) => sum + file.size, 0);
395
+ const warnings = [];
396
+ const errors = [];
397
+ if (manifest.length === 0) {
398
+ errors.push("Directory contains no deployable files.");
399
+ }
400
+ if (runtime === "static" && !options.runtime && hasDockerfile(options.directory)) {
401
+ errors.push(DOCKERFILE_STATIC_HINT);
402
+ }
403
+ if (runtime === "static" && !manifest.some((file) => file.path === "index.html")) {
404
+ warnings.push("Static deploy does not contain index.html.");
405
+ }
406
+ // Per-file size limit, matching the server's create-time guard (#194), so an
407
+ // oversized asset is caught locally instead of failing with a 413 mid-deploy.
408
+ for (const file of oversizedAssets(manifest)) {
409
+ errors.push(assetFileTooLargeMessage(file.path, file.size));
410
+ }
411
+ if (serverEntry) {
412
+ const entry = manifest.find((file) => file.path === serverEntry);
413
+ if (!entry) {
414
+ errors.push(`Worker runtime entry file was not found: ${serverEntry}`);
415
+ }
416
+ else {
417
+ if (entry.size > WORKER_ENTRY_LIMIT_BYTES) {
418
+ errors.push(`Worker runtime entry is ${entry.size} bytes; limit is ${WORKER_ENTRY_LIMIT_BYTES} bytes.`);
419
+ }
420
+ else if (entry.size >= WORKER_ENTRY_WARN_BYTES) {
421
+ warnings.push(`Worker runtime entry is over 80% of the ${WORKER_ENTRY_LIMIT_BYTES} byte limit.`);
422
+ }
423
+ const source = new TextDecoder().decode(await readManifestFile(options.directory, serverEntry));
424
+ const bundle = await validateWorkerEntrySource(source, serverEntry);
425
+ warnings.push(...bundle.warnings);
426
+ errors.push(...bundle.errors);
427
+ }
428
+ }
429
+ return { ok: errors.length === 0, warnings, errors, files: manifest.length, bytes: totalBytes };
430
+ }
431
+ export async function whoami(options) {
432
+ return apiRequest(options, "/v1/me", { method: "GET" });
433
+ }
434
+ export function publicWhoamiResult(result) {
435
+ return {
436
+ user: result.user,
437
+ account: {
438
+ ...(result.account?.org ? { org: result.account.org } : {}),
439
+ ...(result.account?.plan ? { plan: result.account.plan } : {}),
440
+ ...(typeof result.account?.sitesUsed === "number" ? { sitesUsed: result.account.sitesUsed } : {}),
441
+ ...(typeof result.account?.sitesQuota === "number" ? { sitesQuota: result.account.sitesQuota } : {}),
442
+ ...(typeof result.account?.storageUsedBytes === "number" ? { storageUsedBytes: result.account.storageUsedBytes } : {}),
443
+ ...(typeof result.account?.storageQuotaBytes === "number" ? { storageQuotaBytes: result.account.storageQuotaBytes } : {}),
444
+ ...(typeof result.account?.fileStorageUsedBytes === "number" ? { fileStorageUsedBytes: result.account.fileStorageUsedBytes } : {}),
445
+ ...(typeof result.account?.fileStorageQuotaBytes === "number" ? { fileStorageQuotaBytes: result.account.fileStorageQuotaBytes } : {}),
446
+ credentialExpiresAt: result.account?.tokenExpiresAt ?? result.apiKey.expiresAt ?? null,
447
+ },
448
+ };
449
+ }
450
+ async function doctor(options) {
451
+ const credentials = credentialDiagnostics();
452
+ const token = process.env.NINEPM_API_KEY ?? readStoredCredential();
453
+ let account = null;
454
+ let accountError = null;
455
+ let versionCheck = null;
456
+ let versionError = null;
457
+ if (token) {
458
+ try {
459
+ account = publicWhoamiResult(await whoami({ apiUrl: options.apiUrl, token }));
460
+ }
461
+ catch (error) {
462
+ accountError = error instanceof Error ? error.message : String(error);
463
+ }
464
+ }
465
+ try {
466
+ versionCheck = await checkCliVersion({ apiUrl: options.apiUrl });
467
+ }
468
+ catch (error) {
469
+ versionError = error instanceof Error ? error.message : String(error);
470
+ }
471
+ if (options.json) {
472
+ console.log(JSON.stringify({ apiUrl: normalizeApiUrl(options.apiUrl), credentials, account, accountError, version: versionCheck, versionError }, null, 2));
473
+ return;
474
+ }
475
+ console.log("9pm.ai doctor");
476
+ console.log(`API URL: ${normalizeApiUrl(options.apiUrl)}`);
477
+ console.log(`Credential backend: ${credentials.preferredBackend}${credentials.secure ? " (secure)" : ""}`);
478
+ console.log(`Stored credential: ${credentials.stored ? credentials.storedBackend : "none"}`);
479
+ console.log(`Metadata path: ${credentials.metadataPath}`);
480
+ if (credentials.secretPath) {
481
+ console.log(`Protected secret path: ${credentials.secretPath}`);
482
+ }
483
+ if (credentials.storedBackend === "file-plaintext") {
484
+ console.log(`Plaintext credential path: ${credentials.plaintextPath}`);
485
+ }
486
+ for (const warning of credentials.warnings) {
487
+ console.log(`Warning: ${warning}`);
488
+ }
489
+ if (account) {
490
+ console.log(`Account: ${account.user.email}`);
491
+ console.log(`Plan: ${account.account?.plan ?? "unknown"}`);
492
+ }
493
+ else if (accountError) {
494
+ console.log(`Account: unavailable (${accountError})`);
495
+ }
496
+ else {
497
+ console.log("Account: not logged in");
498
+ }
499
+ if (versionCheck) {
500
+ const status = versionCheck.comparison === "behind"
501
+ ? "update available"
502
+ : versionCheck.comparison === "ahead"
503
+ ? "ahead of published"
504
+ : "up to date";
505
+ console.log(`CLI: ${versionCheck.current} (latest ${versionCheck.latest.version}, ${status})`);
506
+ if (versionCheck.comparison === "behind") {
507
+ console.log("Update: npm i -g ninepm@latest");
508
+ }
509
+ }
510
+ else if (versionError) {
511
+ console.log(`CLI: ${CLI_VERSION} (latest unavailable: ${versionError})`);
512
+ }
513
+ }
514
+ export async function getSiteAccess(options) {
515
+ return apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}/access`, {
516
+ method: "GET",
517
+ });
518
+ }
519
+ export async function setSiteAccessPublic(options) {
520
+ const payload = await apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}/access`, {
521
+ method: "PUT",
522
+ body: JSON.stringify({ mode: "public" }),
523
+ });
524
+ return payload.access;
525
+ }
526
+ export async function setSiteAccessOtp(options) {
527
+ const payload = await apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}/access`, {
528
+ method: "PUT",
529
+ body: JSON.stringify({ mode: "private_otp" }),
530
+ });
531
+ return payload.access;
532
+ }
533
+ export async function setSiteAccessIdp(options) {
534
+ const payload = await apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}/access`, {
535
+ method: "PUT",
536
+ body: JSON.stringify({ mode: "private_idp", ...(options.providers?.length ? { providers: options.providers } : {}) }),
537
+ });
538
+ return payload.access;
539
+ }
540
+ export async function createAccessServiceToken(options) {
541
+ const body = {};
542
+ if (options.name) {
543
+ body.name = options.name;
544
+ }
545
+ if (options.duration) {
546
+ body.duration = options.duration;
547
+ }
548
+ return apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}/access/service-tokens`, {
549
+ method: "POST",
550
+ body: JSON.stringify(body),
551
+ });
552
+ }
553
+ export async function revokeAccessServiceToken(options) {
554
+ const payload = await apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}/access/service-tokens/${encodeURIComponent(options.tokenId)}/revoke`, {
555
+ method: "POST",
556
+ body: "{}",
557
+ });
558
+ return payload.serviceToken;
559
+ }
560
+ export async function addAccessGrant(options) {
561
+ const payload = await apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}/access/grants`, {
562
+ method: "POST",
563
+ body: JSON.stringify({ type: options.type, value: options.value }),
564
+ });
565
+ return payload.grant;
566
+ }
567
+ export async function revokeAccessGrant(options) {
568
+ const payload = await apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}/access/grants/revoke`, {
569
+ method: "POST",
570
+ body: JSON.stringify({ type: options.type, value: options.value }),
571
+ });
572
+ return payload.grant;
573
+ }
574
+ export async function createRuntimeAccessToken(options) {
575
+ const body = {};
576
+ if (options.name) {
577
+ body.name = options.name;
578
+ }
579
+ if (options.expiresAt) {
580
+ body.expiresAt = options.expiresAt;
581
+ }
582
+ return apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}/access/tokens`, {
583
+ method: "POST",
584
+ body: JSON.stringify(body),
585
+ });
586
+ }
587
+ export async function revokeRuntimeAccessToken(options) {
588
+ const payload = await apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}/access/tokens/${encodeURIComponent(options.tokenId)}/revoke`, {
589
+ method: "POST",
590
+ body: "{}",
591
+ });
592
+ return payload.appAccessToken;
593
+ }
594
+ export async function fetchLatestCliVersion(options) {
595
+ const fetchFn = options.fetchFn ?? fetch;
596
+ const apiUrl = normalizeApiUrl(options.apiUrl);
597
+ const controller = new AbortController();
598
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 5000);
599
+ try {
600
+ void apiUrl; // updater now sources the public npm registry, not the platform host
601
+ const response = await fetchFn("https://registry.npmjs.org/ninepm/latest", { signal: controller.signal });
602
+ if (!response.ok) {
603
+ throw new Error(`npm registry returned ${response.status} for ninepm.`);
604
+ }
605
+ const payload = (await response.json());
606
+ if (typeof payload.version !== "string" || !payload.version.trim()) {
607
+ throw new Error("npm registry response is missing a version.");
608
+ }
609
+ return { version: payload.version.trim() };
610
+ }
611
+ finally {
612
+ clearTimeout(timer);
613
+ }
614
+ }
615
+ export function compareCliVersions(current, latest) {
616
+ const parse = (value) => value
617
+ .split(".")
618
+ .map((part) => Number.parseInt(part, 10))
619
+ .map((part) => (Number.isFinite(part) ? part : 0));
620
+ const a = parse(current);
621
+ const b = parse(latest);
622
+ const length = Math.max(a.length, b.length);
623
+ for (let i = 0; i < length; i += 1) {
624
+ const diff = (a[i] ?? 0) - (b[i] ?? 0);
625
+ if (diff < 0)
626
+ return "behind";
627
+ if (diff > 0)
628
+ return "ahead";
629
+ }
630
+ return "current";
631
+ }
632
+ export async function checkCliVersion(options) {
633
+ const latest = await fetchLatestCliVersion(options);
634
+ return { current: CLI_VERSION, latest, comparison: compareCliVersions(CLI_VERSION, latest.version) };
635
+ }
636
+ export async function listSites(options) {
637
+ const payload = await apiRequest(options, "/v1/sites", { method: "GET" });
638
+ return { projects: payload.projects };
639
+ }
640
+ export async function deleteSite(options) {
641
+ const payload = await apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}`, {
642
+ method: "DELETE",
643
+ body: JSON.stringify({ confirmSlug: options.confirmSlug }),
644
+ });
645
+ return payload.deleted;
646
+ }
647
+ export async function loginDevice(options) {
648
+ return deviceAuthFlow({ ...options, purpose: "login" });
649
+ }
650
+ export async function signupDevice(options) {
651
+ return deviceAuthFlow({ ...options, purpose: "signup" });
652
+ }
653
+ async function deviceAuthFlow(options) {
654
+ const fetchFn = options.fetchFn ?? fetch;
655
+ const apiUrl = normalizeApiUrl(options.apiUrl);
656
+ const startResponse = await fetchFn(`${apiUrl}/v1/oauth/device/start`, {
657
+ method: "POST",
658
+ headers: { "content-type": "application/json" },
659
+ body: JSON.stringify({ machineName: options.machineName ?? os.hostname(), purpose: options.purpose }),
660
+ });
661
+ const start = await expectJson(startResponse, `start device ${options.purpose}`);
662
+ const userCode = start.user_code ?? start.code;
663
+ const deviceCode = start.device_code;
664
+ if (!userCode || !deviceCode) {
665
+ throw new Error(`Device ${options.purpose} response did not include a device code.`);
666
+ }
667
+ console.log(`Open ${start.verification_uri}`);
668
+ console.log(`Device code: ${userCode}`);
669
+ openBrowser(start.verification_uri);
670
+ const deadline = Date.now() + start.expires_in * 1000;
671
+ const intervalMs = Math.max(1, start.interval ?? 2) * 1000;
672
+ while (Date.now() < deadline) {
673
+ await sleep(intervalMs);
674
+ const pollResponse = await fetchFn(`${apiUrl}/v1/oauth/device/poll`, {
675
+ method: "POST",
676
+ headers: { "content-type": "application/json" },
677
+ body: JSON.stringify({ code: deviceCode }),
678
+ });
679
+ if (pollResponse.status === 400) {
680
+ continue;
681
+ }
682
+ if (pollResponse.status === 410) {
683
+ const body = await pollResponse.text();
684
+ throw new Error(`Device ${options.purpose} expired or was denied: ${body}`);
685
+ }
686
+ const poll = await expectJson(pollResponse, `poll device ${options.purpose}`);
687
+ const storage = storeCredential(poll.token, { allowFileFallback: options.allowFileCredentialStore });
688
+ const me = await whoami({ apiUrl, token: poll.token, fetchFn });
689
+ return { email: me.user.email, storage };
690
+ }
691
+ throw new Error(`Device ${options.purpose} expired before approval.`);
692
+ }
693
+ async function createProject(fetchFn, apiUrl, token, body) {
694
+ const response = await fetchFn(`${apiUrl}/v1/sites`, {
695
+ method: "POST",
696
+ headers: jsonHeaders(token),
697
+ body: JSON.stringify(body),
698
+ });
699
+ const payload = await expectJson(response, "create site");
700
+ return { project: payload.project, created: payload.created ?? false };
701
+ }
702
+ async function apiRequest(options, route, init) {
703
+ const fetchFn = options.fetchFn ?? fetch;
704
+ const apiUrl = normalizeApiUrl(options.apiUrl);
705
+ const headers = new Headers(init.headers);
706
+ headers.set("authorization", `Bearer ${options.token}`);
707
+ headers.set("x-9pm-cli-version", CLI_VERSION);
708
+ if (init.body && !headers.has("content-type")) {
709
+ headers.set("content-type", "application/json");
710
+ }
711
+ const response = await fetchFn(`${apiUrl}${route}`, {
712
+ ...init,
713
+ headers,
714
+ });
715
+ return expectJson(response, `${init.method ?? "GET"} ${route}`);
716
+ }
717
+ export async function listManagedFiles(options) {
718
+ return apiRequest(options, filesRoute(options), { method: "GET" });
719
+ }
720
+ export async function putManagedFile(options) {
721
+ const fetchFn = options.fetchFn ?? fetch;
722
+ const apiUrl = normalizeApiUrl(options.apiUrl);
723
+ const bytes = readFileSync(options.localPath);
724
+ const remotePath = normalizeRemoteFilePath(options.remotePath ?? path.basename(options.localPath));
725
+ const response = await fetchFn(`${apiUrl}${filesRoute(options)}?path=${encodeURIComponent(remotePath)}`, {
726
+ method: "POST",
727
+ headers: {
728
+ authorization: `Bearer ${options.token}`,
729
+ "content-type": "application/octet-stream",
730
+ },
731
+ body: bytes,
732
+ });
733
+ return expectJson(response, "upload file");
734
+ }
735
+ export async function getManagedFile(options) {
736
+ const fetchFn = options.fetchFn ?? fetch;
737
+ const apiUrl = normalizeApiUrl(options.apiUrl);
738
+ const response = await fetchFn(`${apiUrl}${filesRoute(options)}/${encodeURIComponent(options.fileId)}`, {
739
+ method: "GET",
740
+ headers: { authorization: `Bearer ${options.token}` },
741
+ });
742
+ await expectOk(response, "download file");
743
+ const bytes = new Uint8Array(await response.arrayBuffer());
744
+ if (options.outputPath) {
745
+ writeFileSync(options.outputPath, bytes);
746
+ }
747
+ return { fileId: options.fileId, bytes, contentType: response.headers.get("content-type") ?? "application/octet-stream" };
748
+ }
749
+ export async function deleteManagedFile(options) {
750
+ return apiRequest(options, `${filesRoute(options)}/${encodeURIComponent(options.fileId)}`, { method: "DELETE" });
751
+ }
752
+ export async function shareManagedFile(options) {
753
+ return apiRequest(options, `${filesRoute(options)}/${encodeURIComponent(options.fileId)}/share`, {
754
+ method: "POST",
755
+ body: JSON.stringify({ mode: options.mode, expiresInSeconds: options.expiresInSeconds }),
756
+ });
757
+ }
758
+ function filesRoute(options) {
759
+ return options.project ? `/v1/sites/${encodeURIComponent(options.project)}/files` : "/v1/files";
760
+ }
761
+ function normalizeRemoteFilePath(value) {
762
+ const normalized = value.trim().replaceAll("\\", "/").replace(/^\.\/+/, "");
763
+ if (!normalized || normalized.startsWith("/") || normalized.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
764
+ throw new Error("File path must be relative.");
765
+ }
766
+ return normalized;
767
+ }
768
+ async function createDatabase(fetchFn, apiUrl, token, projectId, body) {
769
+ const response = await fetchFn(`${apiUrl}/v1/sites/${encodeURIComponent(projectId)}/databases`, {
770
+ method: "POST",
771
+ headers: jsonHeaders(token),
772
+ body: JSON.stringify(body),
773
+ });
774
+ const payload = await expectJson(response, "create database");
775
+ return payload.database;
776
+ }
777
+ async function enableEndUserAuth(fetchFn, apiUrl, token, projectId) {
778
+ const response = await fetchFn(`${apiUrl}/v1/sites/${encodeURIComponent(projectId)}/auth/enable`, {
779
+ method: "POST",
780
+ headers: jsonHeaders(token),
781
+ body: "{}",
782
+ });
783
+ await expectJson(response, "enable end-user auth");
784
+ }
785
+ async function createDeployment(fetchFn, apiUrl, token, projectId, body) {
786
+ const response = await fetchFn(`${apiUrl}/v1/sites/${encodeURIComponent(projectId)}/deployments`, {
787
+ method: "POST",
788
+ headers: jsonHeaders(token),
789
+ body: JSON.stringify(body),
790
+ });
791
+ return expectJson(response, "create deployment");
792
+ }
793
+ async function finalizeDeployment(fetchFn, apiUrl, token, deploymentId) {
794
+ const response = await fetchFn(`${apiUrl}/v1/deployments/${encodeURIComponent(deploymentId)}/finalize`, {
795
+ method: "POST",
796
+ headers: jsonHeaders(token),
797
+ body: "{}",
798
+ });
799
+ const payload = await expectJson(response, "finalize deployment");
800
+ return payload.deployment;
801
+ }
802
+ async function getDeployment(fetchFn, apiUrl, token, deploymentId) {
803
+ const response = await fetchFn(`${apiUrl}/v1/deployments/${encodeURIComponent(deploymentId)}`, {
804
+ method: "GET",
805
+ headers: { authorization: `Bearer ${token}` },
806
+ });
807
+ const payload = await expectJson(response, "get deployment");
808
+ return payload.deployment;
809
+ }
810
+ async function pollDeployment(fetchFn, apiUrl, token, deploymentId, timeoutMs, initial) {
811
+ const deadline = Date.now() + Math.max(0, timeoutMs);
812
+ let current = initial;
813
+ while (Date.now() < deadline) {
814
+ if (current.status === "active" || current.status === "failed") {
815
+ return current;
816
+ }
817
+ await sleep(2000);
818
+ current = await getDeployment(fetchFn, apiUrl, token, deploymentId);
819
+ }
820
+ return current;
821
+ }
822
+ function deploymentFailureMessage(deployment) {
823
+ const error = deployment.metadata["error"];
824
+ return typeof error === "string" && error.trim() ? error.trim() : "Deployment failed.";
825
+ }
826
+ async function uploadContainerImage(fetchFn, imagePath, upload) {
827
+ const bytes = readFileSync(imagePath);
828
+ if (bytes.byteLength > upload.maxBytes) {
829
+ throw new Error(`Container image is ${bytes.byteLength} bytes; upload session allows ${upload.maxBytes} bytes.`);
830
+ }
831
+ const partBytes = upload.partBytes && upload.partBytes > 0 ? upload.partBytes : 0;
832
+ if (!partBytes || !upload.completeUrl || bytes.byteLength <= partBytes) {
833
+ const response = await fetchFn(upload.uploadUrl, {
834
+ method: upload.method,
835
+ headers: upload.headers,
836
+ body: arrayBufferFromBytes(bytes),
837
+ });
838
+ await expectOk(response, "upload container image");
839
+ return;
840
+ }
841
+ // Large images go up in parts so no single request exceeds the platform's
842
+ // request-body limits.
843
+ const totalParts = Math.ceil(bytes.byteLength / partBytes);
844
+ let uploadId = "";
845
+ const parts = [];
846
+ for (let part = 1; part <= totalParts; part += 1) {
847
+ const chunk = bytes.subarray((part - 1) * partBytes, Math.min(part * partBytes, bytes.byteLength));
848
+ const query = uploadId ? `?part=${part}&uploadId=${encodeURIComponent(uploadId)}` : `?part=${part}`;
849
+ const response = await fetchFn(`${upload.uploadUrl}${query}`, {
850
+ method: upload.method,
851
+ headers: upload.headers,
852
+ body: arrayBufferFromBytes(chunk),
853
+ });
854
+ await expectOk(response, `upload container image part ${part}/${totalParts}`);
855
+ const payload = (await response.json());
856
+ if (!payload.uploadId || !payload.etag) {
857
+ throw new Error(`Container image part ${part} did not return an upload receipt.`);
858
+ }
859
+ uploadId = payload.uploadId;
860
+ parts.push({ part, etag: payload.etag });
861
+ }
862
+ const completed = await fetchFn(upload.completeUrl, {
863
+ method: "POST",
864
+ headers: { ...upload.headers, "content-type": "application/json" },
865
+ body: JSON.stringify({ uploadId, parts }),
866
+ });
867
+ await expectOk(completed, "complete container image upload");
868
+ }
869
+ function buildContainerImage(directory) {
870
+ const root = path.resolve(directory);
871
+ if (!existsSync(path.join(root, "Dockerfile"))) {
872
+ throw new Error("Container deploys require a Dockerfile in the deployment directory.");
873
+ }
874
+ const docker = spawnSync("docker", ["--version"], { encoding: "utf8" });
875
+ if (docker.status !== 0) {
876
+ throw new Error("Docker is required for local container builds.");
877
+ }
878
+ const tempDir = mkdtempSync(path.join(os.tmpdir(), "9pm-container-"));
879
+ const tag = `9pm-local-${Date.now()}-${process.pid}`;
880
+ const imagePath = path.join(tempDir, "image.tar");
881
+ runDocker(["build", "--platform", "linux/amd64", "-t", tag, root], "build container image");
882
+ const inspected = spawnSync("docker", ["image", "inspect", tag, "--format", "{{.Os}}/{{.Architecture}}"], { encoding: "utf8" });
883
+ if (inspected.status !== 0) {
884
+ throw new Error(`Failed to inspect container image: ${inspected.stderr || inspected.stdout}`.trim());
885
+ }
886
+ if (inspected.stdout.trim() !== "linux/amd64") {
887
+ throw new Error(`Container image must be linux/amd64, got ${inspected.stdout.trim() || "unknown"}.`);
888
+ }
889
+ runDocker(["save", "-o", imagePath, tag], "export container image");
890
+ const size = statSync(imagePath).size;
891
+ const sha256 = createHash("sha256").update(readFileSync(imagePath)).digest("hex");
892
+ return { tag, path: imagePath, size, sha256 };
893
+ }
894
+ function runDocker(args, action) {
895
+ const result = spawnSync("docker", args, { stdio: "inherit" });
896
+ if (result.status !== 0) {
897
+ throw new Error(`Failed to ${action}.`);
898
+ }
899
+ }
900
+ function cleanupContainerImage(image) {
901
+ spawnSync("docker", ["image", "rm", "-f", image.tag], { stdio: "ignore" });
902
+ rmSync(path.dirname(image.path), { recursive: true, force: true });
903
+ }
904
+ function jsonHeaders(token) {
905
+ return {
906
+ authorization: `Bearer ${token}`,
907
+ "content-type": "application/json",
908
+ "x-9pm-cli-version": CLI_VERSION,
909
+ };
910
+ }
911
+ async function expectJson(response, action) {
912
+ await expectOk(response, action);
913
+ return (await response.json());
914
+ }
915
+ async function expectOk(response, action) {
916
+ if (response.ok) {
917
+ return;
918
+ }
919
+ const text = await response.text();
920
+ throw new Error(`Failed to ${action}: ${response.status} ${text}`);
921
+ }
922
+ function normalizeApiUrl(value) {
923
+ return value.replace(/\/+$/g, "");
924
+ }
925
+ function pathFromUpload(upload, deploymentId) {
926
+ const marker = `/v1/uploads/${encodeURIComponent(deploymentId)}/`;
927
+ const index = upload.path.indexOf(marker);
928
+ const encoded = index >= 0 ? upload.path.slice(index + marker.length) : upload.path.split("/").slice(4).join("/");
929
+ return encoded.split("/").map(decodeURIComponent).join("/");
930
+ }
931
+ // Every flag the deploy branch below reads (via flags.has / read*Flag, plus
932
+ // `token`→readApiToken and `api-url`→readApiUrl). An arg not in this set is a
933
+ // typo, not a deploy option — reject it instead of silently deploying `.`.
934
+ // MAINTENANCE: this is a manual contract with no compile-time guard. Any new
935
+ // deploy flag must be added here, or `9pm deploy --<new-flag>` throws "Unknown
936
+ // deploy flag". This file is the single source of truth; the shipped ninepm
937
+ // tarball is generated from it by scripts/generate-cli-package.mjs.
938
+ const VALID_DEPLOY_FLAGS = new Set([
939
+ "check",
940
+ "token",
941
+ "api-url",
942
+ "with-db",
943
+ "db",
944
+ "with-auth",
945
+ "auth",
946
+ "name",
947
+ "slug",
948
+ "description",
949
+ "environment",
950
+ "database-name",
951
+ "binding",
952
+ "runtime",
953
+ "entry",
954
+ "port",
955
+ "health-path",
956
+ "region",
957
+ "always-on",
958
+ "volume",
959
+ "no-volume",
960
+ "allow-data-wipe",
961
+ "allow-namespace-reset",
962
+ "cpus",
963
+ "memory",
964
+ "no-build",
965
+ "bundle",
966
+ ]);
967
+ export function parseArgs(argv) {
968
+ const command = argv[0];
969
+ if (!command || command === "help" || command === "--help" || command === "-h") {
970
+ throw new UsageError();
971
+ }
972
+ if (command === "version" || command === "--version" || command === "-v") {
973
+ return { kind: "version" };
974
+ }
975
+ if (command === "access") {
976
+ return parseAccessArgs(argv.slice(1));
977
+ }
978
+ if (command === "env") {
979
+ return parseEnvArgs(argv.slice(1));
980
+ }
981
+ if (command === "deployments") {
982
+ return parseDeploymentsArgs(argv.slice(1));
983
+ }
984
+ if (command === "logs") {
985
+ return parseLogsArgs(argv.slice(1));
986
+ }
987
+ if (command === "files") {
988
+ return parseFilesArgs(argv.slice(1));
989
+ }
990
+ if (command === "login") {
991
+ const flags = parseFlags(argv);
992
+ return { kind: "login", apiUrl: readApiUrl(flags, DEFAULT_API_URL), allowFileCredentialStore: flags.has("allow-file-credential-store") };
993
+ }
994
+ if (command === "signup") {
995
+ const flags = parseFlags(argv);
996
+ return { kind: "signup", apiUrl: readApiUrl(flags, DEFAULT_API_URL), allowFileCredentialStore: flags.has("allow-file-credential-store") };
997
+ }
998
+ if (command === "logout") {
999
+ return { kind: "logout" };
1000
+ }
1001
+ if (command === "whoami") {
1002
+ const flags = parseFlags(argv);
1003
+ return { kind: "whoami", ...readBaseApiOptions(flags), json: flags.has("json") };
1004
+ }
1005
+ if (command === "doctor") {
1006
+ const flags = parseFlags(argv);
1007
+ return { kind: "doctor", apiUrl: readApiUrl(flags, DEFAULT_API_URL), json: flags.has("json") };
1008
+ }
1009
+ if (command === "delete") {
1010
+ return parseDeleteArgs(argv.slice(1));
1011
+ }
1012
+ if (command === "apps" || command === "list" || command === "ls" || command === "sites" || command === "projects") {
1013
+ const flags = parseFlags(argv);
1014
+ return { kind: "apps", ...readBaseApiOptions(flags), json: flags.has("json") };
1015
+ }
1016
+ if (command === "latest" || command === "upgrade" || command === "update") {
1017
+ const flags = parseFlags(argv);
1018
+ return { kind: "latest", apiUrl: readApiUrl(flags, DEFAULT_API_URL), json: flags.has("json") };
1019
+ }
1020
+ if (command === "support") {
1021
+ return parseSupportArgs(argv.slice(1));
1022
+ }
1023
+ if (command !== "deploy") {
1024
+ throw new Error(`Unknown command: ${command}`);
1025
+ }
1026
+ // `deploy` has no help handling of its own, so without this a bare `--help`
1027
+ // falls through to deploying `.` (issue #146). Mirror the other subcommands:
1028
+ // a help token in the first position is a usage request, never a deploy.
1029
+ // (Help tokens elsewhere, e.g. `deploy ./dir --help`, are caught as unknown
1030
+ // flags below — non-destructive — rather than matching a `--name help` value.)
1031
+ const firstDeployArg = argv[1];
1032
+ if (firstDeployArg === "--help" || firstDeployArg === "-h" || firstDeployArg === "help") {
1033
+ throw new UsageError();
1034
+ }
1035
+ const directory = argv[1] && !argv[1].startsWith("-") ? argv[1] : ".";
1036
+ const flags = new Map();
1037
+ for (let index = directory === "." ? 1 : 2; index < argv.length; index += 1) {
1038
+ const arg = argv[index];
1039
+ if (!arg?.startsWith("--")) {
1040
+ continue;
1041
+ }
1042
+ const name = arg.slice(2);
1043
+ const next = argv[index + 1];
1044
+ if (!next || next.startsWith("--")) {
1045
+ flags.set(name, true);
1046
+ continue;
1047
+ }
1048
+ flags.set(name, next);
1049
+ index += 1;
1050
+ }
1051
+ for (const flagName of flags.keys()) {
1052
+ if (!VALID_DEPLOY_FLAGS.has(flagName)) {
1053
+ throw new Error(`Unknown deploy flag: --${flagName}. Run \`9pm deploy --help\` for usage.`);
1054
+ }
1055
+ }
1056
+ const check = flags.has("check");
1057
+ const token = readApiToken(flags);
1058
+ if (!check && !token) {
1059
+ throw new Error("Missing API token. Set NINEPM_API_KEY or pass --token.");
1060
+ }
1061
+ const options = {
1062
+ directory,
1063
+ apiUrl: readApiUrl(flags, DEFAULT_API_URL),
1064
+ token: token ?? "",
1065
+ // --with-auth implies a managed database (somewhere to store per-user data).
1066
+ withDatabase: flags.has("with-db") || flags.has("db") || flags.has("with-auth") || flags.has("auth"),
1067
+ withAuth: flags.has("with-auth") || flags.has("auth"),
1068
+ check,
1069
+ };
1070
+ const name = readStringFlag(flags, "name");
1071
+ const slug = readStringFlag(flags, "slug");
1072
+ const description = readStringFlag(flags, "description");
1073
+ const environment = readStringFlag(flags, "environment");
1074
+ const databaseName = readStringFlag(flags, "database-name");
1075
+ const bindingName = readStringFlag(flags, "binding");
1076
+ const runtime = readRuntimeFlag(flags);
1077
+ const entry = readStringFlag(flags, "entry");
1078
+ const port = readIntegerFlag(flags, "port");
1079
+ const healthPath = readStringFlag(flags, "health-path");
1080
+ const region = readContainerRegionFlag(flags);
1081
+ if (name) {
1082
+ options.name = name;
1083
+ }
1084
+ if (slug) {
1085
+ options.slug = slug;
1086
+ }
1087
+ let normalizedDescription;
1088
+ try {
1089
+ normalizedDescription = normalizeDeployDescription(description);
1090
+ }
1091
+ catch {
1092
+ throw new Error(`--description must be at most ${MAX_DEPLOY_DESCRIPTION_LENGTH} characters.`);
1093
+ }
1094
+ if (normalizedDescription !== null) {
1095
+ options.description = normalizedDescription;
1096
+ }
1097
+ if (environment) {
1098
+ options.environment = environment;
1099
+ }
1100
+ if (databaseName) {
1101
+ options.databaseName = databaseName;
1102
+ }
1103
+ if (bindingName) {
1104
+ options.bindingName = bindingName;
1105
+ }
1106
+ if (runtime) {
1107
+ options.runtime = runtime;
1108
+ }
1109
+ if (entry) {
1110
+ options.entry = entry;
1111
+ }
1112
+ if (port !== undefined) {
1113
+ if (port < 1 || port > 65535) {
1114
+ throw new Error("--port must be between 1 and 65535.");
1115
+ }
1116
+ options.port = port;
1117
+ }
1118
+ if (healthPath) {
1119
+ options.healthPath = healthPath;
1120
+ }
1121
+ if (region) {
1122
+ options.region = region;
1123
+ }
1124
+ if (flags.has("always-on")) {
1125
+ options.alwaysOn = true;
1126
+ }
1127
+ if (flags.has("allow-data-wipe")) {
1128
+ options.allowVolumeDestroy = true;
1129
+ }
1130
+ if (flags.has("allow-namespace-reset")) {
1131
+ options.allowNamespaceReset = true;
1132
+ }
1133
+ const volumeGb = readIntegerFlag(flags, "volume");
1134
+ if (volumeGb !== undefined) {
1135
+ if (volumeGb < 1 || volumeGb > 500) {
1136
+ throw new Error("--volume must be between 1 and 500 (GB).");
1137
+ }
1138
+ options.volumeGb = volumeGb;
1139
+ }
1140
+ // --no-volume is the explicit "this app has no persistent disk" intent. Omitting
1141
+ // both --volume and --no-volume leaves volumeGb unset, so a redeploy keeps the
1142
+ // app's existing disk; --no-volume sends an explicit 0 to drop it (which the
1143
+ // server refuses without --allow-data-wipe when a disk already exists).
1144
+ if (flags.has("no-volume")) {
1145
+ if (volumeGb !== undefined) {
1146
+ throw new Error("--volume and --no-volume cannot be combined.");
1147
+ }
1148
+ options.volumeGb = 0;
1149
+ }
1150
+ const cpus = readIntegerFlag(flags, "cpus");
1151
+ if (cpus !== undefined) {
1152
+ if (cpus < 1 || cpus > 64) {
1153
+ throw new Error("--cpus must be between 1 and 64.");
1154
+ }
1155
+ options.cpus = cpus;
1156
+ }
1157
+ const memory = readStringFlag(flags, "memory");
1158
+ if (memory !== undefined && memory !== "") {
1159
+ options.memoryMb = parseMemorySize(memory);
1160
+ }
1161
+ if (flags.has("no-build")) {
1162
+ options.noBuild = true;
1163
+ }
1164
+ const bundleDir = readStringFlag(flags, "bundle");
1165
+ if (bundleDir !== undefined && bundleDir !== "") {
1166
+ if (options.runtime !== undefined && options.runtime !== "worker") {
1167
+ throw new Error("--bundle deploys a pre-built Worker bundle; it cannot combine with --runtime " + options.runtime + ".");
1168
+ }
1169
+ options.runtime = "worker";
1170
+ options.bundleDir = bundleDir;
1171
+ }
1172
+ if (options.runtime === "container" && !options.port) {
1173
+ throw new Error("Container deploys require --port <port>.");
1174
+ }
1175
+ if ((options.alwaysOn ||
1176
+ options.volumeGb !== undefined ||
1177
+ options.allowVolumeDestroy ||
1178
+ options.allowNamespaceReset ||
1179
+ options.cpus !== undefined ||
1180
+ options.memoryMb !== undefined) &&
1181
+ options.runtime !== "container") {
1182
+ throw new Error("--always-on, --volume, --allow-data-wipe, --allow-namespace-reset, --cpus, and --memory require --runtime container.");
1183
+ }
1184
+ return { kind: "deploy", options };
1185
+ }
1186
+ function parseDeleteArgs(argv) {
1187
+ const flags = parseFlags(argv);
1188
+ const base = readBaseApiOptions(flags);
1189
+ const site = argv[0];
1190
+ if (!site || site.startsWith("--")) {
1191
+ throw new Error("Missing site id or slug.");
1192
+ }
1193
+ const confirmSlug = readStringFlag(flags, "confirm");
1194
+ if (!confirmSlug) {
1195
+ throw new Error("Missing delete confirmation. Pass --confirm <slug> matching the app slug.");
1196
+ }
1197
+ return { kind: "delete", ...base, site, confirmSlug };
1198
+ }
1199
+ function parseFilesArgs(argv) {
1200
+ const subcommand = argv[0];
1201
+ if (!subcommand) {
1202
+ throw new UsageError();
1203
+ }
1204
+ const flags = parseFlags(argv);
1205
+ const base = readBaseApiOptions(flags);
1206
+ const project = readStringFlag(flags, "project") ?? readStringFlag(flags, "site");
1207
+ const withProject = project ? { project } : {};
1208
+ if (subcommand === "list") {
1209
+ return { kind: "files", ...base, action: { kind: "list", ...withProject } };
1210
+ }
1211
+ if (subcommand === "put") {
1212
+ const localPath = argv[1];
1213
+ if (!localPath || localPath.startsWith("--")) {
1214
+ throw new Error("Missing local file path.");
1215
+ }
1216
+ const remotePath = readStringFlag(flags, "path") ?? readStringFlag(flags, "as");
1217
+ return { kind: "files", ...base, action: { kind: "put", localPath, ...(remotePath ? { remotePath } : {}), ...withProject } };
1218
+ }
1219
+ if (subcommand === "get") {
1220
+ const fileId = argv[1];
1221
+ if (!fileId || fileId.startsWith("--")) {
1222
+ throw new Error("Missing file id.");
1223
+ }
1224
+ const outputPath = readStringFlag(flags, "output") ?? readStringFlag(flags, "out");
1225
+ return { kind: "files", ...base, action: { kind: "get", fileId, ...(outputPath ? { outputPath } : {}), ...withProject } };
1226
+ }
1227
+ if (subcommand === "delete") {
1228
+ const fileId = argv[1];
1229
+ if (!fileId || fileId.startsWith("--")) {
1230
+ throw new Error("Missing file id.");
1231
+ }
1232
+ return { kind: "files", ...base, action: { kind: "delete", fileId, ...withProject } };
1233
+ }
1234
+ if (subcommand === "share") {
1235
+ const fileId = argv[1];
1236
+ if (!fileId || fileId.startsWith("--")) {
1237
+ throw new Error("Missing file id.");
1238
+ }
1239
+ const expires = readStringFlag(flags, "expires") ?? "24h";
1240
+ const mode = flags.has("login-required") ? "login_required" : "public";
1241
+ return { kind: "files", ...base, action: { kind: "share", fileId, mode, expiresInSeconds: parseDurationSeconds(expires), ...withProject } };
1242
+ }
1243
+ throw new Error(`Unknown files command: ${subcommand}`);
1244
+ }
1245
+ function parseEnvArgs(argv) {
1246
+ const subcommand = argv[0];
1247
+ if (!subcommand || subcommand === "help" || subcommand === "--help") {
1248
+ throw new UsageError();
1249
+ }
1250
+ const flags = parseFlags(argv);
1251
+ const base = readBaseApiOptions(flags);
1252
+ const site = argv[1];
1253
+ if (!site || site.startsWith("--")) {
1254
+ throw new Error("Missing app id or slug. Usage: 9pm env <set|ls|rm|request> <app> ...");
1255
+ }
1256
+ if (subcommand === "set") {
1257
+ const entries = [];
1258
+ const kind = flags.has("var") ? "var" : "secret";
1259
+ const useStdin = flags.has("stdin");
1260
+ const bareNames = [];
1261
+ const stdinFlag = flags.get("stdin");
1262
+ if (typeof stdinFlag === "string") {
1263
+ // `--stdin NAME` — parseFlags consumed NAME as the flag's value.
1264
+ bareNames.push(stdinFlag);
1265
+ }
1266
+ const rest = argv.slice(2);
1267
+ for (let index = 0; index < rest.length; index += 1) {
1268
+ const arg = rest[index] ?? "";
1269
+ if (arg.startsWith("--")) {
1270
+ // Skip the flag and, mirroring parseFlags, its value when present.
1271
+ const next = rest[index + 1];
1272
+ if (next && !next.startsWith("--") && !next.includes("=")) {
1273
+ index += 1;
1274
+ }
1275
+ continue;
1276
+ }
1277
+ const eq = arg.indexOf("=");
1278
+ if (eq < 1) {
1279
+ if (useStdin) {
1280
+ bareNames.push(arg);
1281
+ continue;
1282
+ }
1283
+ throw new Error(`Environment entries must be KEY=value (got "${arg}").`);
1284
+ }
1285
+ entries.push({ name: arg.slice(0, eq), value: arg.slice(eq + 1), kind });
1286
+ }
1287
+ if (flags.has("from-file")) {
1288
+ const fromFile = readStringFlag(flags, "from-file");
1289
+ if (!fromFile) {
1290
+ throw new Error("--from-file needs a path to a KEY=value file.");
1291
+ }
1292
+ entries.push(...parseEnvFileEntries(readFileSync(fromFile, "utf8"), kind));
1293
+ }
1294
+ if (useStdin) {
1295
+ if (bareNames.length !== 1 || !bareNames[0]) {
1296
+ throw new Error("--stdin reads one value: 9pm env set <app> <NAME> --stdin");
1297
+ }
1298
+ // Trim only the trailing newline most shells/files append.
1299
+ const value = readFileSync(0, "utf8").replace(/\r?\n$/, "");
1300
+ entries.push({ name: bareNames[0], value, kind });
1301
+ }
1302
+ if (entries.length === 0) {
1303
+ throw new Error("Provide at least one KEY=value entry.");
1304
+ }
1305
+ return { kind: "env", ...base, action: { kind: "set", site, entries } };
1306
+ }
1307
+ if (subcommand === "ls") {
1308
+ return { kind: "env", ...base, action: { kind: "ls", site } };
1309
+ }
1310
+ if (subcommand === "rm") {
1311
+ const name = argv[2];
1312
+ if (!name || name.startsWith("--")) {
1313
+ throw new Error("Usage: 9pm env rm <app> <NAME>");
1314
+ }
1315
+ return { kind: "env", ...base, action: { kind: "rm", site, name } };
1316
+ }
1317
+ if (subcommand === "request") {
1318
+ const name = argv[2];
1319
+ if (!name || name.startsWith("--")) {
1320
+ throw new Error("Usage: 9pm env request <app> <NAME> [--var]");
1321
+ }
1322
+ const varKind = flags.has("var") ? "var" : "secret";
1323
+ return { kind: "env", ...base, action: { kind: "request", site, name, varKind } };
1324
+ }
1325
+ throw new Error(`Unknown env subcommand: ${subcommand}`);
1326
+ }
1327
+ async function runEnvCommand(command) {
1328
+ const base = { apiUrl: command.apiUrl, token: command.token };
1329
+ const action = command.action;
1330
+ if (action.kind === "set") {
1331
+ const result = await apiRequest(base, `/v1/sites/${encodeURIComponent(action.site)}/env`, {
1332
+ method: "PUT",
1333
+ body: JSON.stringify({ entries: action.entries }),
1334
+ });
1335
+ console.log(`Saved ${result.saved.length} entr${result.saved.length === 1 ? "y" : "ies"} for ${action.site}: ${result.saved.join(", ")}`);
1336
+ if (result.hint) {
1337
+ console.log(result.hint);
1338
+ }
1339
+ return;
1340
+ }
1341
+ if (action.kind === "ls") {
1342
+ const result = await apiRequest(base, `/v1/sites/${encodeURIComponent(action.site)}/env`, { method: "GET" });
1343
+ if (result.env.length === 0) {
1344
+ console.log("No environment entries.");
1345
+ return;
1346
+ }
1347
+ for (const entry of result.env) {
1348
+ console.log(`${entry.name}\t${entry.kind}\t${entry.kind === "var" ? (entry.value ?? "") : "<write-only>"}\t${entry.updatedAt}`);
1349
+ }
1350
+ return;
1351
+ }
1352
+ if (action.kind === "request") {
1353
+ const result = await apiRequest(base, `/v1/sites/${encodeURIComponent(action.site)}/env/${encodeURIComponent(action.name)}/request`, { method: "POST", body: JSON.stringify({ kind: action.varKind }) });
1354
+ console.log(`Open this link in your browser and sign in as the app owner to set ${result.name} for ${action.site}:`);
1355
+ console.log(` ${result.url}`);
1356
+ console.log(`The link works once and expires in ${result.expiresInMinutes} minutes. You enter the value in the browser; it is never shown to your agent.`);
1357
+ return;
1358
+ }
1359
+ const removed = await apiRequest(base, `/v1/sites/${encodeURIComponent(action.site)}/env/${encodeURIComponent(action.name)}`, {
1360
+ method: "DELETE",
1361
+ });
1362
+ console.log(`Removed ${removed.removed} from ${action.site}.`);
1363
+ }
1364
+ export function parseEnvFileEntries(content, kind) {
1365
+ const entries = [];
1366
+ for (const rawLine of content.split("\n")) {
1367
+ const line = rawLine.replace(/\r$/, "");
1368
+ const trimmed = line.trim();
1369
+ if (!trimmed || trimmed.startsWith("#")) {
1370
+ continue;
1371
+ }
1372
+ const assignment = trimmed.startsWith("export ") ? trimmed.slice("export ".length).trimStart() : trimmed;
1373
+ const eq = assignment.indexOf("=");
1374
+ if (eq < 1) {
1375
+ throw new Error(`Env file lines must be KEY=value (got "${trimmed}").`);
1376
+ }
1377
+ const name = assignment.slice(0, eq).trim();
1378
+ let value = assignment.slice(eq + 1);
1379
+ // Strip one pair of matching surrounding quotes, dotenv-style.
1380
+ if (value.length >= 2 &&
1381
+ ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
1382
+ value = value.slice(1, -1);
1383
+ }
1384
+ entries.push({ name, value, kind });
1385
+ }
1386
+ if (entries.length === 0) {
1387
+ throw new Error("The env file has no KEY=value entries.");
1388
+ }
1389
+ return entries;
1390
+ }
1391
+ function parseDeploymentsArgs(argv) {
1392
+ const subcommand = argv[0];
1393
+ if (!subcommand || subcommand === "help" || subcommand === "--help") {
1394
+ throw new UsageError();
1395
+ }
1396
+ const flags = parseFlags(argv);
1397
+ const base = readBaseApiOptions(flags);
1398
+ if (subcommand === "ls" || subcommand === "list") {
1399
+ const site = argv[1];
1400
+ if (!site || site.startsWith("--")) {
1401
+ throw new Error("Usage: 9pm deployments ls <app> [--json]");
1402
+ }
1403
+ return { kind: "deployments", ...base, action: { kind: "ls", site, json: flags.has("json") } };
1404
+ }
1405
+ if (subcommand === "rm" || subcommand === "delete") {
1406
+ const deploymentId = argv[1];
1407
+ if (!deploymentId || deploymentId.startsWith("--")) {
1408
+ throw new Error("Usage: 9pm deployments rm <deployment-id>");
1409
+ }
1410
+ return { kind: "deployments", ...base, action: { kind: "rm", deploymentId } };
1411
+ }
1412
+ throw new Error(`Unknown deployments subcommand: ${subcommand}`);
1413
+ }
1414
+ async function runDeploymentsCommand(command) {
1415
+ const base = { apiUrl: command.apiUrl, token: command.token };
1416
+ const action = command.action;
1417
+ if (action.kind === "ls") {
1418
+ const result = await apiRequest(base, `/v1/sites/${encodeURIComponent(action.site)}/deployments`, { method: "GET" });
1419
+ if (action.json) {
1420
+ console.log(JSON.stringify(result, null, 2));
1421
+ return;
1422
+ }
1423
+ if (result.deployments.length === 0) {
1424
+ console.log("No deployments.");
1425
+ return;
1426
+ }
1427
+ for (const deployment of result.deployments) {
1428
+ console.log(`${deployment.active ? "*" : " "} ${deployment.id}\tv${deployment.version}\t${deployment.status}\t${deployment.createdAt}`);
1429
+ }
1430
+ return;
1431
+ }
1432
+ const result = await apiRequest(base, `/v1/deployments/${encodeURIComponent(action.deploymentId)}`, {
1433
+ method: "DELETE",
1434
+ });
1435
+ console.log(`Deleted deployment ${result.deleted}.`);
1436
+ }
1437
+ async function runLogsCommand(command) {
1438
+ const base = { apiUrl: command.apiUrl, token: command.token };
1439
+ const result = await apiRequest(base, `/v1/sites/${encodeURIComponent(command.site)}/logs`, { method: "GET" });
1440
+ if (command.json) {
1441
+ console.log(JSON.stringify(result, null, 2));
1442
+ return;
1443
+ }
1444
+ if (!result.deployment) {
1445
+ console.log("No container deployment found for this app. `9pm logs` reports container runtime diagnostics; static and worker apps have none.");
1446
+ return;
1447
+ }
1448
+ const deployment = result.deployment;
1449
+ console.log(`Deployment ${deployment.id}\t${deployment.status}\thealth: ${deployment.healthStatus}\t${deployment.finishedAt ?? deployment.createdAt}`);
1450
+ if (result.reason) {
1451
+ console.log(`Reason: ${result.reason}`);
1452
+ }
1453
+ if (result.logs) {
1454
+ let lines = result.logs.split("\n");
1455
+ if (command.lines !== undefined) {
1456
+ lines = lines.slice(-command.lines);
1457
+ }
1458
+ console.log("");
1459
+ for (const line of lines) {
1460
+ console.log(line);
1461
+ }
1462
+ return;
1463
+ }
1464
+ console.log(deployment.status === "failed"
1465
+ ? "No detailed logs were captured for this failure."
1466
+ : "No logs recorded. Logs are captured when a deployment fails to come up; a runtime failure on a deployment reported active is not yet captured here.");
1467
+ }
1468
+ function parseLogsArgs(argv) {
1469
+ const site = argv[0];
1470
+ if (!site || site.startsWith("--")) {
1471
+ throw new Error("Usage: 9pm logs <app> [--lines <n>] [--json]");
1472
+ }
1473
+ const flags = parseFlags(argv);
1474
+ const base = readBaseApiOptions(flags);
1475
+ const linesRaw = readStringFlag(flags, "lines");
1476
+ let lines;
1477
+ if (linesRaw !== undefined) {
1478
+ lines = Number.parseInt(linesRaw, 10);
1479
+ if (!Number.isFinite(lines) || lines <= 0) {
1480
+ throw new Error("--lines must be a positive integer.");
1481
+ }
1482
+ }
1483
+ return { kind: "logs", site, ...base, json: flags.has("json"), ...(lines !== undefined ? { lines } : {}) };
1484
+ }
1485
+ function parseAccessArgs(argv) {
1486
+ const subcommand = argv[0];
1487
+ if (!subcommand) {
1488
+ throw new UsageError();
1489
+ }
1490
+ const flags = parseFlags(argv);
1491
+ const base = readBaseApiOptions(flags);
1492
+ const siteFrom = (index) => {
1493
+ const site = argv[index];
1494
+ if (!site || site.startsWith("--")) {
1495
+ throw new Error("Missing site id or slug.");
1496
+ }
1497
+ return site;
1498
+ };
1499
+ if (subcommand === "show") {
1500
+ return { kind: "access", ...base, action: { kind: "show", site: siteFrom(1) } };
1501
+ }
1502
+ if (subcommand === "public") {
1503
+ return { kind: "access", ...base, action: { kind: "public", site: siteFrom(1) } };
1504
+ }
1505
+ if (subcommand === "otp") {
1506
+ return { kind: "access", ...base, action: { kind: "otp", site: siteFrom(1) } };
1507
+ }
1508
+ if (subcommand === "idp") {
1509
+ const site = siteFrom(1);
1510
+ const providers = readRepeatedStringFlag(argv, "provider").map(parseProviderKey);
1511
+ return { kind: "access", ...base, action: { kind: "idp", site, ...(providers.length ? { providers } : {}) } };
1512
+ }
1513
+ if (subcommand === "service") {
1514
+ const verb = argv[1];
1515
+ const site = siteFrom(2);
1516
+ if (verb === "create") {
1517
+ const name = readStringFlag(flags, "name");
1518
+ const duration = readStringFlag(flags, "duration");
1519
+ return { kind: "access", ...base, action: { kind: "service-create", site, ...(name ? { name } : {}), ...(duration ? { duration } : {}) } };
1520
+ }
1521
+ if (verb === "revoke") {
1522
+ const tokenId = argv[3];
1523
+ if (!tokenId || tokenId.startsWith("--")) {
1524
+ throw new Error("Missing service token id.");
1525
+ }
1526
+ return { kind: "access", ...base, action: { kind: "service-revoke", site, tokenId } };
1527
+ }
1528
+ throw new Error("Usage: 9pm access service create|revoke <site>");
1529
+ }
1530
+ if (subcommand === "allow" || subcommand === "revoke") {
1531
+ const grantType = parseGrantType(argv[1]);
1532
+ const site = siteFrom(2);
1533
+ const value = argv[3];
1534
+ if (!value || value.startsWith("--")) {
1535
+ throw new Error(`Missing ${grantType} value.`);
1536
+ }
1537
+ return { kind: "access", ...base, action: { kind: subcommand, site, grantType, value } };
1538
+ }
1539
+ if (subcommand === "token") {
1540
+ const verb = argv[1];
1541
+ const site = siteFrom(2);
1542
+ if (verb === "create") {
1543
+ const name = readStringFlag(flags, "name");
1544
+ const expiresAt = readStringFlag(flags, "expires-at");
1545
+ return { kind: "access", ...base, action: { kind: "token-create", site, ...(name ? { name } : {}), ...(expiresAt ? { expiresAt } : {}) } };
1546
+ }
1547
+ if (verb === "revoke") {
1548
+ const tokenId = argv[3];
1549
+ if (!tokenId || tokenId.startsWith("--")) {
1550
+ throw new Error("Missing app access token id.");
1551
+ }
1552
+ return { kind: "access", ...base, action: { kind: "token-revoke", site, tokenId } };
1553
+ }
1554
+ throw new Error("Usage: 9pm access token create|revoke <site>");
1555
+ }
1556
+ throw new Error(`Unknown access command: ${subcommand}`);
1557
+ }
1558
+ function parseFlags(argv) {
1559
+ const flags = new Map();
1560
+ for (let index = 0; index < argv.length; index += 1) {
1561
+ const arg = argv[index];
1562
+ if (!arg?.startsWith("--")) {
1563
+ continue;
1564
+ }
1565
+ const name = arg.slice(2);
1566
+ const next = argv[index + 1];
1567
+ if (!next || next.startsWith("--")) {
1568
+ flags.set(name, true);
1569
+ continue;
1570
+ }
1571
+ flags.set(name, next);
1572
+ index += 1;
1573
+ }
1574
+ return flags;
1575
+ }
1576
+ function readBaseApiOptions(flags) {
1577
+ const token = readApiToken(flags);
1578
+ if (!token) {
1579
+ throw new Error("Missing API token. Set NINEPM_API_KEY or pass --token.");
1580
+ }
1581
+ return {
1582
+ apiUrl: readApiUrl(flags, DEFAULT_API_URL),
1583
+ token,
1584
+ };
1585
+ }
1586
+ function readApiToken(flags) {
1587
+ return readStringFlag(flags, "token") ?? process.env.NINEPM_API_KEY ?? readStoredCredential();
1588
+ }
1589
+ function parseSupportArgs(argv) {
1590
+ // Normalize the -m short flag so parseFlags (which only handles --flags) sees it.
1591
+ const normalized = argv.map((arg) => (arg === "-m" ? "--message" : arg));
1592
+ const flags = parseFlags(normalized);
1593
+ const message = readStringFlag(flags, "message");
1594
+ if (!message) {
1595
+ throw new Error('Missing support message. Pass -m "<what happened>".');
1596
+ }
1597
+ const subject = readStringFlag(flags, "subject");
1598
+ const site = readStringFlag(flags, "site");
1599
+ return {
1600
+ kind: "support",
1601
+ message,
1602
+ ...(subject ? { subject } : {}),
1603
+ ...(site ? { site } : {}),
1604
+ ...readBaseApiOptions(flags),
1605
+ };
1606
+ }
1607
+ function readApiUrl(flags, fallback) {
1608
+ return validateApiUrl(readStringFlag(flags, "api-url") ?? process.env.NINEPM_API_URL ?? fallback);
1609
+ }
1610
+ // The stored credential is auto-attached to every request, so the endpoint must
1611
+ // be trusted transport. Require https for any non-loopback host: this preserves
1612
+ // self-hosting over TLS while refusing to send the bearer token in cleartext
1613
+ // (or to a host set by a poisoned shell profile / repo env file over http).
1614
+ function validateApiUrl(value) {
1615
+ let url;
1616
+ try {
1617
+ url = new URL(value);
1618
+ }
1619
+ catch {
1620
+ throw new Error(`Invalid API URL: ${value}`);
1621
+ }
1622
+ // WHATWG URL always returns IPv6 hosts bracketed, so `[::1]` is the only
1623
+ // form that appears here (`new URL("http://[::1]/").hostname === "[::1]"`).
1624
+ // Accept the whole 127.0.0.0/8 loopback block, not just 127.0.0.1, so a dev
1625
+ // pointing at another loopback address isn't needlessly rejected.
1626
+ const isLoopback = url.hostname === "localhost" ||
1627
+ /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(url.hostname) ||
1628
+ url.hostname === "[::1]";
1629
+ if (url.protocol === "https:") {
1630
+ return value;
1631
+ }
1632
+ if (url.protocol === "http:" && isLoopback) {
1633
+ return value;
1634
+ }
1635
+ throw new Error(`API URL must use https (got ${url.protocol}//${url.hostname}). ` +
1636
+ `Only http on a loopback host (localhost, 127.0.0.0/8, or [::1]) is allowed for local development. ` +
1637
+ `The stored credential is sent with every request and must not travel over cleartext.`);
1638
+ }
1639
+ function parseGrantType(value) {
1640
+ if (value === "email" || value === "domain") {
1641
+ return value;
1642
+ }
1643
+ throw new Error("Grant type must be email or domain.");
1644
+ }
1645
+ function parseProviderKey(value) {
1646
+ if (value === "google" || value === "github" || value === "entra") {
1647
+ return value;
1648
+ }
1649
+ throw new Error("Provider must be google, github, or entra.");
1650
+ }
1651
+ function readStringFlag(flags, name) {
1652
+ const value = flags.get(name);
1653
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
1654
+ }
1655
+ function readIntegerFlag(flags, name) {
1656
+ const value = readStringFlag(flags, name);
1657
+ if (!value) {
1658
+ return undefined;
1659
+ }
1660
+ const parsed = Number(value);
1661
+ if (!Number.isInteger(parsed)) {
1662
+ throw new Error(`--${name} must be an integer.`);
1663
+ }
1664
+ return parsed;
1665
+ }
1666
+ const BUNDLE_MODULE_EXTENSIONS = [".js", ".mjs", ".wasm", ".cjs", ".json", ".txt", ".html", ".bin"];
1667
+ // Shape a pre-built framework bundle (OpenNext-style: entry worker.js +
1668
+ // assets/ directory) into the deployment's bundle metadata. Errors name the
1669
+ // expectation precisely so a deploying agent can correct the layout.
1670
+ function bundleMetadataFromManifest(manifest, contentRoot) {
1671
+ const modules = manifest
1672
+ .map((file) => file.path)
1673
+ .filter((filePath) => !filePath.startsWith("assets/") && BUNDLE_MODULE_EXTENSIONS.some((extension) => filePath.endsWith(extension)));
1674
+ const entry = modules.includes("worker.js") ? "worker.js" : modules.includes("index.js") ? "index.js" : null;
1675
+ if (!entry) {
1676
+ throw new Error(`Bundle directory ${contentRoot} has no worker.js (or index.js) entry module. ` +
1677
+ `Expected a Worker-runtime adapter build output (for Next.js: npx @opennextjs/cloudflare build, then deploy with --bundle .open-next).`);
1678
+ }
1679
+ return {
1680
+ entry,
1681
+ modules,
1682
+ assetsPrefix: "assets/",
1683
+ compatibilityFlags: ["nodejs_compat"],
1684
+ };
1685
+ }
1686
+ function parseMemorySize(value) {
1687
+ const match = value.trim().toLowerCase().match(/^(\d+)\s*(g|gb|m|mb)?$/);
1688
+ if (!match) {
1689
+ throw new Error("--memory must look like 512m, 2048, or 4g.");
1690
+ }
1691
+ const amount = Number(match[1]);
1692
+ const unit = match[2] ?? "mb";
1693
+ const memoryMb = unit.startsWith("g") ? amount * 1024 : amount;
1694
+ if (memoryMb < 256) {
1695
+ throw new Error("--memory must be at least 256m.");
1696
+ }
1697
+ return memoryMb;
1698
+ }
1699
+ function readRuntimeFlag(flags) {
1700
+ const value = readStringFlag(flags, "runtime");
1701
+ if (!value) {
1702
+ return undefined;
1703
+ }
1704
+ if (value === "static" || value === "worker" || value === "container") {
1705
+ return value;
1706
+ }
1707
+ throw new Error("--runtime must be static, worker, or container.");
1708
+ }
1709
+ function readContainerRegionFlag(flags) {
1710
+ const value = readStringFlag(flags, "region");
1711
+ if (!value) {
1712
+ return undefined;
1713
+ }
1714
+ if (value === "auto" ||
1715
+ value === "us-east" ||
1716
+ value === "us-west" ||
1717
+ value === "europe" ||
1718
+ value === "asia" ||
1719
+ value === "south-america" ||
1720
+ value === "middle-east" ||
1721
+ value === "oceania" ||
1722
+ value === "africa") {
1723
+ return value;
1724
+ }
1725
+ throw new Error("--region must be auto, us-east, us-west, europe, asia, south-america, middle-east, oceania, or africa.");
1726
+ }
1727
+ function parseDurationSeconds(value) {
1728
+ const match = value.trim().match(/^(\d+)([smhd])?$/);
1729
+ if (!match) {
1730
+ throw new Error("Duration must look like 30m, 24h, or 7d.");
1731
+ }
1732
+ const amount = Number(match[1]);
1733
+ const unit = match[2] ?? "s";
1734
+ const seconds = unit === "d" ? amount * 24 * 60 * 60 : unit === "h" ? amount * 60 * 60 : unit === "m" ? amount * 60 : amount;
1735
+ return Math.min(seconds, 7 * 24 * 60 * 60);
1736
+ }
1737
+ function normalizeServerEntry(value) {
1738
+ const normalized = value.trim().replaceAll("\\", "/").replace(/^\.\/+/, "");
1739
+ if (!normalized || normalized.startsWith("/") || normalized.split("/").includes("..")) {
1740
+ throw new Error(`Invalid worker runtime entry path: ${value}`);
1741
+ }
1742
+ return normalized;
1743
+ }
1744
+ async function validateWorkerEntrySource(source, filePath) {
1745
+ const warnings = [];
1746
+ const errors = [];
1747
+ if (/\bfrom\s+["'][^"']+["']/.test(source) || /\bimport\s*\(/.test(source) || /(^|\n)\s*import\s+["'][^"']+["']/.test(source)) {
1748
+ errors.push("Worker runtime entry must be bundled into one module with no external imports.");
1749
+ }
1750
+ const hasLiteralDefault = /\bexport\s+default\b/.test(source);
1751
+ const hasDefaultReexport = /export\s*\{\s*([A-Za-z_$][\w$]*)\s+as\s+default\s*,?\s*\}\s*;?\s*$/.test(source);
1752
+ if (!hasLiteralDefault && !hasDefaultReexport) {
1753
+ errors.push(`Worker runtime entry ${filePath} must export a default fetch handler.`);
1754
+ }
1755
+ if (errors.length === 0) {
1756
+ const transformed = hasLiteralDefault
1757
+ ? source
1758
+ : `${source.replace(/export\s*\{\s*([A-Za-z_$][\w$]*)\s+as\s+default\s*,?\s*\}\s*;?\s*$/, "export default $1;")}`;
1759
+ try {
1760
+ const module = await import(`data:text/javascript;base64,${Buffer.from(transformed).toString("base64")}`);
1761
+ if (typeof module.default?.fetch !== "function" && typeof module.default !== "function") {
1762
+ errors.push("Default export must be a function or an object with fetch(request, env, ctx).");
1763
+ }
1764
+ }
1765
+ catch {
1766
+ errors.push("Worker module could not be loaded locally. Bundle unsupported imports and syntax before deploying.");
1767
+ }
1768
+ }
1769
+ return { warnings, errors };
1770
+ }
1771
+ function credentialConfigDir() {
1772
+ if (process.platform === "win32") {
1773
+ return path.join(process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"), "9pm");
1774
+ }
1775
+ return path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "9pm");
1776
+ }
1777
+ function legacyCredentialPath() {
1778
+ return path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"), "9pm", CREDENTIAL_METADATA_FILE);
1779
+ }
1780
+ function credentialMetadataPath() {
1781
+ return path.join(credentialConfigDir(), CREDENTIAL_METADATA_FILE);
1782
+ }
1783
+ function windowsCredentialPath() {
1784
+ return path.join(credentialConfigDir(), WINDOWS_CREDENTIAL_FILE);
1785
+ }
1786
+ function plaintextCredentialPath() {
1787
+ return path.join(credentialConfigDir(), PLAINTEXT_CREDENTIAL_FILE);
1788
+ }
1789
+ function readStoredCredential() {
1790
+ const secure = readSecureCredential();
1791
+ if (secure) {
1792
+ return secure;
1793
+ }
1794
+ const plaintext = readPlaintextCredential();
1795
+ if (!plaintext) {
1796
+ return undefined;
1797
+ }
1798
+ if (plaintext.legacy) {
1799
+ migrateLegacyPlaintextCredential(plaintext);
1800
+ }
1801
+ return plaintext.token;
1802
+ }
1803
+ function storeCredential(token, options = {}) {
1804
+ const secure = storeSecureCredential(token);
1805
+ if (secure) {
1806
+ return secure;
1807
+ }
1808
+ if (!isFileCredentialStoreAllowed(options.allowFileFallback)) {
1809
+ throw new Error(`Secure credential storage is unavailable. Re-run with --allow-file-credential-store or set ${ALLOW_FILE_CREDENTIAL_STORE_ENV}=1 to store a plaintext fallback file.`);
1810
+ }
1811
+ return storePlaintextCredential(token);
1812
+ }
1813
+ function deleteStoredCredential() {
1814
+ const removed = [
1815
+ deleteMacKeychainCredential(),
1816
+ deleteWindowsDpapiCredential(),
1817
+ deleteLinuxSecretServiceCredential(),
1818
+ removeFileIfExists(credentialMetadataPath()),
1819
+ removeFileIfExists(windowsCredentialPath()),
1820
+ removeFileIfExists(plaintextCredentialPath()),
1821
+ removeFileIfExists(legacyCredentialPath()),
1822
+ ];
1823
+ return removed.some(Boolean);
1824
+ }
1825
+ export function credentialDiagnostics() {
1826
+ const secure = readSecureCredential();
1827
+ const plaintext = readPlaintextCredential();
1828
+ const preferredBackend = preferredCredentialBackend();
1829
+ const storedBackend = secure ? preferredBackend : plaintext ? "file-plaintext" : "none";
1830
+ const warnings = [];
1831
+ const fileFallbackAllowed = isFileCredentialStoreAllowed();
1832
+ const nativeStoreDisabled = isNativeCredentialStoreDisabled();
1833
+ if (nativeStoreDisabled) {
1834
+ warnings.push(`${DISABLE_NATIVE_CREDENTIAL_STORE_ENV} is set; native credential storage is disabled for this process.`);
1835
+ }
1836
+ if (preferredBackend === "none" && !fileFallbackAllowed) {
1837
+ warnings.push(`Secure credential storage is unavailable. Login and signup require --allow-file-credential-store or ${ALLOW_FILE_CREDENTIAL_STORE_ENV}=1.`);
1838
+ }
1839
+ if (fileFallbackAllowed) {
1840
+ warnings.push("Plaintext file credential fallback is enabled for this process.");
1841
+ }
1842
+ if (plaintext) {
1843
+ warnings.push(plaintext.legacy
1844
+ ? "A legacy plaintext credential file exists and will migrate when secure storage is available."
1845
+ : "Credential is stored in a plaintext fallback file.");
1846
+ }
1847
+ return {
1848
+ platform: process.platform,
1849
+ preferredBackend,
1850
+ storedBackend,
1851
+ secure: storedBackend !== "file-plaintext" && storedBackend !== "none",
1852
+ stored: storedBackend !== "none",
1853
+ metadataPath: credentialMetadataPath(),
1854
+ ...(preferredBackend === "windows-dpapi" || existsSync(windowsCredentialPath()) ? { secretPath: windowsCredentialPath() } : {}),
1855
+ plaintextPath: plaintextCredentialPath(),
1856
+ legacyPlaintextPath: legacyCredentialPath(),
1857
+ fileFallbackAllowed,
1858
+ nativeStoreDisabled,
1859
+ warnings,
1860
+ };
1861
+ }
1862
+ function readSecureCredential() {
1863
+ if (isNativeCredentialStoreDisabled()) {
1864
+ return undefined;
1865
+ }
1866
+ if (process.platform === "darwin") {
1867
+ return readMacKeychainCredential();
1868
+ }
1869
+ if (process.platform === "win32") {
1870
+ return readWindowsDpapiCredential();
1871
+ }
1872
+ if (process.platform === "linux") {
1873
+ return readLinuxSecretServiceCredential();
1874
+ }
1875
+ return undefined;
1876
+ }
1877
+ function storeSecureCredential(token) {
1878
+ if (isNativeCredentialStoreDisabled()) {
1879
+ return undefined;
1880
+ }
1881
+ if (process.platform === "darwin" && storeMacKeychainCredential(token)) {
1882
+ writeCredentialMetadata("macos-keychain");
1883
+ return "macOS Keychain";
1884
+ }
1885
+ if (process.platform === "win32" && storeWindowsDpapiCredential(token)) {
1886
+ writeCredentialMetadata("windows-dpapi", { secretPath: windowsCredentialPath() });
1887
+ return "Windows DPAPI";
1888
+ }
1889
+ if (process.platform === "linux" && storeLinuxSecretServiceCredential(token)) {
1890
+ writeCredentialMetadata("linux-secret-service");
1891
+ return "Linux Secret Service";
1892
+ }
1893
+ return undefined;
1894
+ }
1895
+ function preferredCredentialBackend() {
1896
+ if (isNativeCredentialStoreDisabled()) {
1897
+ return "none";
1898
+ }
1899
+ if (process.platform === "darwin") {
1900
+ return "macos-keychain";
1901
+ }
1902
+ if (process.platform === "win32") {
1903
+ return "windows-dpapi";
1904
+ }
1905
+ if (process.platform === "linux" && hasLinuxSecretTool()) {
1906
+ return "linux-secret-service";
1907
+ }
1908
+ return "none";
1909
+ }
1910
+ function writeCredentialMetadata(backend, extra = {}) {
1911
+ const filePath = credentialMetadataPath();
1912
+ mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
1913
+ writeFileSync(filePath, `${JSON.stringify({
1914
+ backend,
1915
+ service: TOKEN_SERVICE,
1916
+ account: TOKEN_ACCOUNT,
1917
+ updatedAt: new Date().toISOString(),
1918
+ ...extra,
1919
+ }, null, 2)}\n`, { mode: 0o600 });
1920
+ chmodIfSupported(filePath, 0o600);
1921
+ }
1922
+ function storePlaintextCredential(token) {
1923
+ const filePath = plaintextCredentialPath();
1924
+ mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
1925
+ writeFileSync(filePath, `${JSON.stringify({
1926
+ token,
1927
+ warning: "Plaintext fallback credential. Prefer OS-protected credential storage.",
1928
+ }, null, 2)}\n`, { mode: 0o600 });
1929
+ chmodIfSupported(filePath, 0o600);
1930
+ writeCredentialMetadata("file-plaintext", { plaintextPath: filePath });
1931
+ console.warn(`Warning: stored credential in plaintext fallback file ${filePath} with mode 0600 where supported.`);
1932
+ return filePath;
1933
+ }
1934
+ function readPlaintextCredential() {
1935
+ const candidates = [plaintextCredentialPath(), legacyCredentialPath()].filter((candidate, index, list) => list.indexOf(candidate) === index);
1936
+ for (const filePath of candidates) {
1937
+ try {
1938
+ const parsed = JSON.parse(readFileSync(filePath, "utf8"));
1939
+ if (typeof parsed.token === "string" && parsed.token) {
1940
+ return { token: parsed.token, path: filePath, legacy: filePath === legacyCredentialPath() };
1941
+ }
1942
+ }
1943
+ catch {
1944
+ continue;
1945
+ }
1946
+ }
1947
+ return undefined;
1948
+ }
1949
+ function migrateLegacyPlaintextCredential(credential) {
1950
+ const secure = storeSecureCredential(credential.token);
1951
+ if (!secure) {
1952
+ return;
1953
+ }
1954
+ removeFileIfExists(credential.path);
1955
+ console.warn(`Migrated stored credential to ${secure}. Removed legacy plaintext credential file.`);
1956
+ }
1957
+ function isFileCredentialStoreAllowed(override = false) {
1958
+ return override || isTruthyEnv(process.env[ALLOW_FILE_CREDENTIAL_STORE_ENV]);
1959
+ }
1960
+ function isNativeCredentialStoreDisabled() {
1961
+ return isTruthyEnv(process.env[DISABLE_NATIVE_CREDENTIAL_STORE_ENV]);
1962
+ }
1963
+ function isTruthyEnv(value) {
1964
+ return value === "1" || value === "true" || value === "yes";
1965
+ }
1966
+ function chmodIfSupported(filePath, mode) {
1967
+ try {
1968
+ chmodSync(filePath, mode);
1969
+ }
1970
+ catch {
1971
+ // Windows ACLs do not map cleanly to POSIX modes.
1972
+ }
1973
+ }
1974
+ function removeFileIfExists(filePath) {
1975
+ if (!existsSync(filePath)) {
1976
+ return false;
1977
+ }
1978
+ rmSync(filePath, { force: true });
1979
+ return true;
1980
+ }
1981
+ function readMacKeychainCredential() {
1982
+ if (process.platform !== "darwin") {
1983
+ return undefined;
1984
+ }
1985
+ const result = spawnSync("security", ["find-generic-password", "-a", TOKEN_ACCOUNT, "-s", TOKEN_SERVICE, "-w"], { encoding: "utf8" });
1986
+ return result.status === 0 ? result.stdout.trim() || undefined : undefined;
1987
+ }
1988
+ function storeMacKeychainCredential(token) {
1989
+ if (process.platform !== "darwin") {
1990
+ return false;
1991
+ }
1992
+ const result = spawnSync("security", ["add-generic-password", "-a", TOKEN_ACCOUNT, "-s", TOKEN_SERVICE, "-w", token, "-U"], { encoding: "utf8" });
1993
+ return result.status === 0;
1994
+ }
1995
+ function deleteMacKeychainCredential() {
1996
+ if (process.platform !== "darwin") {
1997
+ return false;
1998
+ }
1999
+ const result = spawnSync("security", ["delete-generic-password", "-a", TOKEN_ACCOUNT, "-s", TOKEN_SERVICE], { encoding: "utf8" });
2000
+ return result.status === 0;
2001
+ }
2002
+ function readWindowsDpapiCredential() {
2003
+ if (process.platform !== "win32") {
2004
+ return undefined;
2005
+ }
2006
+ try {
2007
+ const encrypted = readFileSync(windowsCredentialPath(), "utf8").trim();
2008
+ if (!encrypted) {
2009
+ return undefined;
2010
+ }
2011
+ const result = runPowerShell(`
2012
+ $ErrorActionPreference = 'Stop'
2013
+ Add-Type -AssemblyName System.Security
2014
+ $blob = [Console]::In.ReadToEnd().Trim()
2015
+ $bytes = [Convert]::FromBase64String($blob)
2016
+ $plain = [Security.Cryptography.ProtectedData]::Unprotect($bytes, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)
2017
+ [Console]::Out.Write([Text.Encoding]::UTF8.GetString($plain))
2018
+ `, encrypted);
2019
+ return result.status === 0 ? result.stdout.trim() || undefined : undefined;
2020
+ }
2021
+ catch {
2022
+ return undefined;
2023
+ }
2024
+ }
2025
+ function storeWindowsDpapiCredential(token) {
2026
+ if (process.platform !== "win32") {
2027
+ return false;
2028
+ }
2029
+ const result = runPowerShell(`
2030
+ $ErrorActionPreference = 'Stop'
2031
+ Add-Type -AssemblyName System.Security
2032
+ $plain = [Console]::In.ReadToEnd()
2033
+ $bytes = [Text.Encoding]::UTF8.GetBytes($plain)
2034
+ $protected = [Security.Cryptography.ProtectedData]::Protect($bytes, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser)
2035
+ [Console]::Out.Write([Convert]::ToBase64String($protected))
2036
+ `, token);
2037
+ if (result.status !== 0 || !result.stdout.trim()) {
2038
+ return false;
2039
+ }
2040
+ const filePath = windowsCredentialPath();
2041
+ mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
2042
+ writeFileSync(filePath, `${result.stdout.trim()}\n`, { mode: 0o600 });
2043
+ chmodIfSupported(filePath, 0o600);
2044
+ return true;
2045
+ }
2046
+ function deleteWindowsDpapiCredential() {
2047
+ if (process.platform !== "win32") {
2048
+ return false;
2049
+ }
2050
+ return removeFileIfExists(windowsCredentialPath());
2051
+ }
2052
+ function runPowerShell(script, input) {
2053
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
2054
+ for (const command of ["powershell.exe", "pwsh.exe", "powershell", "pwsh"]) {
2055
+ const result = spawnSync(command, ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded], {
2056
+ input,
2057
+ encoding: "utf8",
2058
+ windowsHide: true,
2059
+ });
2060
+ if (result.error && result.error.code === "ENOENT") {
2061
+ continue;
2062
+ }
2063
+ return { status: result.status, stdout: result.stdout, stderr: result.stderr };
2064
+ }
2065
+ return { status: 127, stdout: "", stderr: "PowerShell was not found." };
2066
+ }
2067
+ function hasLinuxSecretTool() {
2068
+ if (process.platform !== "linux") {
2069
+ return false;
2070
+ }
2071
+ const result = spawnSync("secret-tool", ["--version"], { encoding: "utf8" });
2072
+ return !result.error && result.status === 0;
2073
+ }
2074
+ function readLinuxSecretServiceCredential() {
2075
+ if (process.platform !== "linux" || !hasLinuxSecretTool()) {
2076
+ return undefined;
2077
+ }
2078
+ const result = spawnSync("secret-tool", ["lookup", "service", TOKEN_SERVICE, "account", TOKEN_ACCOUNT], { encoding: "utf8" });
2079
+ return result.status === 0 ? result.stdout.trim() || undefined : undefined;
2080
+ }
2081
+ function storeLinuxSecretServiceCredential(token) {
2082
+ if (process.platform !== "linux" || !hasLinuxSecretTool()) {
2083
+ return false;
2084
+ }
2085
+ const result = spawnSync("secret-tool", ["store", "--label", "9pm.ai API token", "service", TOKEN_SERVICE, "account", TOKEN_ACCOUNT], {
2086
+ input: token,
2087
+ encoding: "utf8",
2088
+ });
2089
+ return result.status === 0;
2090
+ }
2091
+ function deleteLinuxSecretServiceCredential() {
2092
+ if (process.platform !== "linux" || !hasLinuxSecretTool()) {
2093
+ return false;
2094
+ }
2095
+ const result = spawnSync("secret-tool", ["clear", "service", TOKEN_SERVICE, "account", TOKEN_ACCOUNT], { encoding: "utf8" });
2096
+ return result.status === 0;
2097
+ }
2098
+ function openBrowser(url) {
2099
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
2100
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
2101
+ spawnSync(command, args, { stdio: "ignore" });
2102
+ }
2103
+ function sleep(ms) {
2104
+ return new Promise((resolve) => setTimeout(resolve, ms));
2105
+ }
2106
+ function readRepeatedStringFlag(argv, name) {
2107
+ const values = [];
2108
+ for (let index = 0; index < argv.length; index += 1) {
2109
+ if (argv[index] !== `--${name}`) {
2110
+ continue;
2111
+ }
2112
+ const next = argv[index + 1];
2113
+ if (next && !next.startsWith("--")) {
2114
+ values.push(next);
2115
+ index += 1;
2116
+ }
2117
+ }
2118
+ return values;
2119
+ }
2120
+ function arrayBufferFromBytes(bytes) {
2121
+ if (bytes.buffer instanceof ArrayBuffer) {
2122
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
2123
+ }
2124
+ return new Uint8Array(bytes).buffer;
2125
+ }
2126
+ function formatBytesForCli(value) {
2127
+ const bytes = Math.max(0, Math.floor(value));
2128
+ if (bytes < 1024 * 1024) {
2129
+ return `${Math.round(bytes / 1024)}KB`;
2130
+ }
2131
+ if (bytes < 1024 * 1024 * 1024) {
2132
+ return `${Math.round(bytes / 1024 / 1024)}MB`;
2133
+ }
2134
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}GB`;
2135
+ }
2136
+ export class UsageError extends Error {
2137
+ }
2138
+ async function main() {
2139
+ try {
2140
+ const command = parseArgs(process.argv.slice(2));
2141
+ if (command.kind === "version") {
2142
+ console.log(CLI_VERSION);
2143
+ return;
2144
+ }
2145
+ if (command.kind === "deploy") {
2146
+ if (command.options.check) {
2147
+ const check = await checkDeployDirectory(command.options);
2148
+ for (const warning of check.warnings) {
2149
+ console.warn(`Warning: ${warning}`);
2150
+ }
2151
+ if (!check.ok) {
2152
+ for (const error of check.errors) {
2153
+ console.error(`Error: ${error}`);
2154
+ }
2155
+ process.exitCode = 1;
2156
+ return;
2157
+ }
2158
+ if (command.options.runtime === "container") {
2159
+ console.log("Deploy check passed: Dockerfile found and Docker is available.");
2160
+ }
2161
+ else {
2162
+ console.log(`Deploy check passed: ${check.files} file(s), ${check.bytes} byte(s).`);
2163
+ }
2164
+ return;
2165
+ }
2166
+ let result;
2167
+ try {
2168
+ result = await deployDirectory(command.options);
2169
+ }
2170
+ catch (error) {
2171
+ // Use the server-resolved slug (DeployError carries it once the project
2172
+ // exists) so `9pm support --site <slug>` resolves and /v1/me/support can
2173
+ // attach the failed deploy's id/status/error. Bare form when no slug is
2174
+ // known yet (failure before the project was created).
2175
+ const slug = error instanceof DeployError ? error.slug : command.options.slug;
2176
+ const siteFlag = slug ? ` --site ${quoteSiteArg(slug)}` : "";
2177
+ const base = error instanceof Error ? error.message : String(error);
2178
+ throw new Error(`${base}\n\nStuck? Report it with context: 9pm support${siteFlag} -m "<what happened>"`);
2179
+ }
2180
+ const databaseLine = result.database ? `database ${result.database.bindingName}:${result.database.name}` : "no database";
2181
+ const queued = result.deployment.status === "queued" || result.deployment.status === "publishing";
2182
+ console.log(`${queued ? "Queued" : "Deployed"} ${result.project.slug} -> ${result.url}`);
2183
+ console.log(`Deployment ${result.deployment.id} ${result.containerImageBytes ? `uploaded ${formatBytesForCli(result.containerImageBytes)} image` : `uploaded ${result.uploaded} file(s)`}, ${databaseLine}.`);
2184
+ if (queued) {
2185
+ console.log(`Status: ${result.deployment.status}. Run 9pm apps to check when it becomes active.`);
2186
+ }
2187
+ console.log(`Manage: ${PUBLIC_DASHBOARD_URL}`);
2188
+ return;
2189
+ }
2190
+ if (command.kind === "login") {
2191
+ const result = await loginDevice({ apiUrl: command.apiUrl, allowFileCredentialStore: command.allowFileCredentialStore });
2192
+ console.log(`Logged in${result.email ? ` as ${result.email}` : ""}. Credential stored in ${result.storage}.`);
2193
+ return;
2194
+ }
2195
+ if (command.kind === "signup") {
2196
+ const result = await signupDevice({ apiUrl: command.apiUrl, allowFileCredentialStore: command.allowFileCredentialStore });
2197
+ console.log(`Signed up${result.email ? ` as ${result.email}` : ""}. Credential stored in ${result.storage}.`);
2198
+ return;
2199
+ }
2200
+ if (command.kind === "logout") {
2201
+ const removed = deleteStoredCredential();
2202
+ console.log(removed ? "Logged out." : "No stored credential found.");
2203
+ return;
2204
+ }
2205
+ if (command.kind === "doctor") {
2206
+ await doctor({ apiUrl: command.apiUrl, json: command.json });
2207
+ return;
2208
+ }
2209
+ if (command.kind === "whoami") {
2210
+ const me = await whoami({ apiUrl: command.apiUrl, token: command.token });
2211
+ const publicMe = publicWhoamiResult(me);
2212
+ if (command.json) {
2213
+ console.log(JSON.stringify(publicMe, null, 2));
2214
+ }
2215
+ else {
2216
+ console.log(`User: ${publicMe.user.email}`);
2217
+ console.log(`Plan: ${publicMe.account?.plan ?? "unknown"}`);
2218
+ console.log(`Sites: ${publicMe.account?.sitesUsed ?? "unknown"} / ${publicMe.account?.sitesQuota ?? "unknown"}`);
2219
+ console.log(`Storage: ${formatBytesForCli(publicMe.account?.storageUsedBytes ?? publicMe.account?.fileStorageUsedBytes ?? 0)} / ${formatBytesForCli(publicMe.account?.storageQuotaBytes ?? publicMe.account?.fileStorageQuotaBytes ?? 0)}`);
2220
+ console.log(`Credential: ${publicMe.account?.credentialExpiresAt ? `expires ${publicMe.account.credentialExpiresAt}` : "active"}`);
2221
+ }
2222
+ return;
2223
+ }
2224
+ if (command.kind === "delete") {
2225
+ const deleted = await deleteSite({
2226
+ apiUrl: command.apiUrl,
2227
+ token: command.token,
2228
+ site: command.site,
2229
+ confirmSlug: command.confirmSlug,
2230
+ });
2231
+ console.log(`Deleted ${deleted.slug}.`);
2232
+ console.log(`Removed ${deleted.workers} Worker(s), ${deleted.databases} database(s), ${deleted.files} file(s), ${deleted.customDomains} custom domain(s), ${deleted.containerApplications ?? 0} container app(s), ${deleted.containerImages ?? 0} container image(s), ${deleted.accessApps} access app(s), and ${deleted.serviceTokens} service token(s).`);
2233
+ return;
2234
+ }
2235
+ if (command.kind === "latest") {
2236
+ await runLatestCommand(command);
2237
+ return;
2238
+ }
2239
+ if (command.kind === "apps") {
2240
+ const result = await listSites({ apiUrl: command.apiUrl, token: command.token });
2241
+ if (command.json) {
2242
+ console.log(JSON.stringify(result, null, 2));
2243
+ return;
2244
+ }
2245
+ if (result.projects.length === 0) {
2246
+ console.log("No apps. Run `9pm deploy` to create one.");
2247
+ return;
2248
+ }
2249
+ for (const project of result.projects) {
2250
+ const status = project.activeDeploymentId ? "active" : "no-deploy";
2251
+ console.log(`${project.slug}\t${project.name}\t${status}\t${project.createdAt}\t${project.id}`);
2252
+ }
2253
+ return;
2254
+ }
2255
+ if (command.kind === "files") {
2256
+ await runFilesCommand(command);
2257
+ return;
2258
+ }
2259
+ if (command.kind === "env") {
2260
+ await runEnvCommand(command);
2261
+ return;
2262
+ }
2263
+ if (command.kind === "deployments") {
2264
+ await runDeploymentsCommand(command);
2265
+ return;
2266
+ }
2267
+ if (command.kind === "logs") {
2268
+ await runLogsCommand(command);
2269
+ return;
2270
+ }
2271
+ if (command.kind === "support") {
2272
+ await runSupportCommand(command);
2273
+ return;
2274
+ }
2275
+ await runAccessCommand(command);
2276
+ }
2277
+ catch (error) {
2278
+ if (error instanceof UsageError) {
2279
+ printUsage();
2280
+ process.exitCode = 0;
2281
+ return;
2282
+ }
2283
+ reportCliError(error);
2284
+ process.exitCode = 1;
2285
+ }
2286
+ }
2287
+ async function runSupportCommand(command) {
2288
+ const subject = command.subject ?? (command.message.split("\n")[0] ?? command.message).slice(0, 200);
2289
+ const body = {
2290
+ subject,
2291
+ message: command.message,
2292
+ kind: "help",
2293
+ ...(command.site ? { site: command.site } : {}),
2294
+ client: { os: process.platform, node: process.version },
2295
+ };
2296
+ await apiRequest({ apiUrl: command.apiUrl, token: command.token }, "/v1/me/support", {
2297
+ method: "POST",
2298
+ body: JSON.stringify(body),
2299
+ });
2300
+ console.log("Support request sent. A human will reply to your account email.");
2301
+ }
2302
+ async function runAccessCommand(command) {
2303
+ const base = { apiUrl: command.apiUrl, token: command.token };
2304
+ const action = command.action;
2305
+ if (action.kind === "show") {
2306
+ console.log(JSON.stringify(await getSiteAccess({ ...base, site: action.site }), null, 2));
2307
+ return;
2308
+ }
2309
+ if (action.kind === "public") {
2310
+ const access = await setSiteAccessPublic({ ...base, site: action.site });
2311
+ console.log(`Access for ${action.site} set to ${access.mode}.`);
2312
+ return;
2313
+ }
2314
+ if (action.kind === "otp") {
2315
+ const access = await setSiteAccessOtp({ ...base, site: action.site });
2316
+ console.log(`Access for ${action.site} set to ${access.mode}.`);
2317
+ return;
2318
+ }
2319
+ if (action.kind === "idp") {
2320
+ const access = await setSiteAccessIdp({ ...base, site: action.site, ...(action.providers ? { providers: action.providers } : {}) });
2321
+ console.log(`Access for ${action.site} set to ${access.mode}.`);
2322
+ return;
2323
+ }
2324
+ if (action.kind === "allow") {
2325
+ const grant = await addAccessGrant({ ...base, site: action.site, type: action.grantType, value: action.value });
2326
+ console.log(`Allowed ${grant.type} ${grant.value} for ${action.site}.`);
2327
+ return;
2328
+ }
2329
+ if (action.kind === "revoke") {
2330
+ const grant = await revokeAccessGrant({ ...base, site: action.site, type: action.grantType, value: action.value });
2331
+ console.log(`Revoked ${grant.type} ${grant.value} for ${action.site}.`);
2332
+ return;
2333
+ }
2334
+ if (action.kind === "service-create") {
2335
+ const created = await createAccessServiceToken({
2336
+ ...base,
2337
+ site: action.site,
2338
+ ...(action.name ? { name: action.name } : {}),
2339
+ ...(action.duration ? { duration: action.duration } : {}),
2340
+ });
2341
+ console.log(`Created service token ${created.serviceToken.id} for ${action.site}.`);
2342
+ console.log(`CF-Access-Client-Id: ${created.serviceToken.clientId}`);
2343
+ console.log(`CF-Access-Client-Secret: ${created.clientSecret}`);
2344
+ return;
2345
+ }
2346
+ if (action.kind === "service-revoke") {
2347
+ const token = await revokeAccessServiceToken({ ...base, site: action.site, tokenId: action.tokenId });
2348
+ console.log(`Revoked service token ${token.id} for ${action.site}.`);
2349
+ return;
2350
+ }
2351
+ if (action.kind === "token-create") {
2352
+ const created = await createRuntimeAccessToken({
2353
+ ...base,
2354
+ site: action.site,
2355
+ ...(action.name ? { name: action.name } : {}),
2356
+ ...(action.expiresAt ? { expiresAt: action.expiresAt } : {}),
2357
+ });
2358
+ console.log(`Created app access token ${created.appAccessToken.id} for ${action.site}.`);
2359
+ console.log(created.token);
2360
+ return;
2361
+ }
2362
+ if (action.kind === "token-revoke") {
2363
+ const token = await revokeRuntimeAccessToken({ ...base, site: action.site, tokenId: action.tokenId });
2364
+ console.log(`Revoked app access token ${token.id} for ${action.site}.`);
2365
+ }
2366
+ }
2367
+ async function runLatestCommand(command) {
2368
+ let check;
2369
+ try {
2370
+ check = await checkCliVersion({ apiUrl: command.apiUrl });
2371
+ }
2372
+ catch (error) {
2373
+ if (command.json) {
2374
+ console.log(JSON.stringify({ current: CLI_VERSION, error: error instanceof Error ? error.message : String(error) }, null, 2));
2375
+ return;
2376
+ }
2377
+ throw error;
2378
+ }
2379
+ if (command.json) {
2380
+ console.log(JSON.stringify(check, null, 2));
2381
+ return;
2382
+ }
2383
+ console.log(`Installed: ${check.current}`);
2384
+ console.log(`Latest: ${check.latest.version}`);
2385
+ if (check.comparison === "behind") {
2386
+ console.log("");
2387
+ console.log("A newer 9pm CLI is available. Update with:");
2388
+ console.log(" npm i -g ninepm@latest (or run ad-hoc via: npx ninepm <args>)");
2389
+ }
2390
+ else if (check.comparison === "ahead") {
2391
+ console.log("Installed version is ahead of the published latest.");
2392
+ }
2393
+ else {
2394
+ console.log("Up to date.");
2395
+ }
2396
+ }
2397
+ async function runFilesCommand(command) {
2398
+ const base = { apiUrl: command.apiUrl, token: command.token };
2399
+ const project = "project" in command.action && command.action.project ? { project: command.action.project } : {};
2400
+ if (command.action.kind === "list") {
2401
+ const listed = await listManagedFiles({ ...base, ...project });
2402
+ for (const file of listed.files) {
2403
+ console.log(`${file.id}\t${file.path}\t${formatBytesForCli(file.size)}\t${file.scope}`);
2404
+ }
2405
+ console.log(`Usage: ${formatBytesForCli(listed.usage.usedBytes)} / ${formatBytesForCli(listed.usage.quotaBytes)}`);
2406
+ return;
2407
+ }
2408
+ if (command.action.kind === "put") {
2409
+ const result = await putManagedFile({ ...base, ...project, localPath: command.action.localPath, ...(command.action.remotePath ? { remotePath: command.action.remotePath } : {}) });
2410
+ console.log(`Stored ${result.file.path} (${formatBytesForCli(result.file.size)}).`);
2411
+ console.log(`File ID: ${result.file.id}`);
2412
+ console.log(`Usage: ${formatBytesForCli(result.usage.usedBytes)} / ${formatBytesForCli(result.usage.quotaBytes)}`);
2413
+ return;
2414
+ }
2415
+ if (command.action.kind === "get") {
2416
+ const result = await getManagedFile({ ...base, ...project, fileId: command.action.fileId, ...(command.action.outputPath ? { outputPath: command.action.outputPath } : {}) });
2417
+ if (command.action.outputPath) {
2418
+ console.log(`Saved ${result.fileId} to ${command.action.outputPath}.`);
2419
+ }
2420
+ else {
2421
+ process.stdout.write(result.bytes);
2422
+ }
2423
+ return;
2424
+ }
2425
+ if (command.action.kind === "delete") {
2426
+ const result = await deleteManagedFile({ ...base, ...project, fileId: command.action.fileId });
2427
+ console.log(`Deleted ${result.file.path}.`);
2428
+ console.log(`Usage: ${formatBytesForCli(result.usage.usedBytes)} / ${formatBytesForCli(result.usage.quotaBytes)}`);
2429
+ return;
2430
+ }
2431
+ if (command.action.kind === "share") {
2432
+ const result = await shareManagedFile({
2433
+ ...base,
2434
+ ...project,
2435
+ fileId: command.action.fileId,
2436
+ mode: command.action.mode,
2437
+ expiresInSeconds: command.action.expiresInSeconds,
2438
+ });
2439
+ console.log(result.url);
2440
+ console.log(`Share mode: ${result.share.mode === "login_required" ? "owner-only" : "public"}, expires ${result.share.expiresAt}.`);
2441
+ }
2442
+ }
2443
+ function printUsage() {
2444
+ console.log(`Usage:
2445
+ 9pm --version
2446
+ 9pm deploy [directory] --name <site-name> [--slug <slug>] [--description "<note>"] [--with-db] [--with-auth]
2447
+ 9pm deploy [directory] --runtime worker --entry src/index.js [--with-db]
2448
+ 9pm deploy [directory] --runtime container --port 8080 [--health-path /health] [--region auto] [--with-db] [--always-on] [--volume <gb> | --no-volume] [--cpus <n>] [--memory <size>]
2449
+ 9pm deploy [directory] framework projects (Next.js, Vite) are detected and built automatically; --no-build skips
2450
+ 9pm deploy [directory] --bundle <dir> deploy a pre-built Cloudflare framework bundle (e.g. .open-next)
2451
+ 9pm deploy [directory] --description "<note>" optional short note shown next to the version in the dashboard
2452
+ 9pm deploy [directory] --check
2453
+ 9pm signup
2454
+ 9pm login
2455
+ 9pm logout
2456
+ 9pm doctor [--json]
2457
+ 9pm whoami [--json]
2458
+ 9pm apps [--json]
2459
+ 9pm latest [--json]
2460
+ 9pm support -m "<what happened>" [--subject <subject>] [--site <app>]
2461
+ 9pm files list [--project <site>]
2462
+ 9pm files put <local-path> [--path <remote-path>] [--project <site>]
2463
+ 9pm files get <file-id> [--output <path>] [--project <site>]
2464
+ 9pm files share <file-id> [--expires 24h] [--login-required] [--project <site>]
2465
+ 9pm files delete <file-id> [--project <site>]
2466
+ 9pm deployments ls <app> [--json]
2467
+ 9pm deployments rm <deployment-id>
2468
+ 9pm logs <app> [--lines <n>] [--json]
2469
+ 9pm delete <site> --confirm <slug>
2470
+ 9pm access show <site>
2471
+ 9pm access public <site>
2472
+ 9pm access otp <site>
2473
+ 9pm access idp <site> [--provider google|github|entra]
2474
+ 9pm access allow email <site> <email>
2475
+ 9pm access allow domain <site> <domain>
2476
+ 9pm access revoke email <site> <email>
2477
+ 9pm access revoke domain <site> <domain>
2478
+ 9pm access service create <site> [--name <name>] [--duration 8760h]
2479
+ 9pm access service revoke <site> <service-token-id>
2480
+ 9pm access token create <site> [--name <name>]
2481
+ 9pm access token revoke <site> <token-id>
2482
+
2483
+ Environment:
2484
+ NINEPM_API_URL ${API_URL_HELP}
2485
+ NINEPM_API_KEY ${API_KEY_HELP}
2486
+ ${ALLOW_FILE_CREDENTIAL_STORE_ENV}
2487
+ Allow plaintext credential file fallback when native storage is unavailable
2488
+
2489
+ Container deploys:
2490
+ Requires Docker locally. The CLI builds linux/amd64, exports the image, uploads it through a short-lived session, then finalizes a queued deployment.
2491
+ The app must listen on 0.0.0.0:$PORT or the selected --port. Use --health-path for a cheap readiness endpoint.
2492
+ With --with-db, containers receive NINEPM_SQL_URL and NINEPM_DB_BINDINGS for the managed SQL bridge.
2493
+ With --with-auth, the app gets 9pm-managed end-user accounts (email one-time codes); it implies --with-db and is one-way (enable-only).
2494
+ 9pm env set <app> KEY=value [KEY2=value2 ...] [--var] (secret by default; --var = readable config)
2495
+ 9pm env set <app> --from-file <path> [--var] (KEY=value lines; # comments, export prefix, quotes ok)
2496
+ 9pm env set <app> <NAME> --stdin [--var] (value read verbatim from stdin)
2497
+ 9pm env request <app> <NAME> [--var] (prints a one-time browser link to enter the value; never shown to the agent)
2498
+ 9pm env ls <app>
2499
+ 9pm env rm <app> <NAME>
2500
+ Environment changes apply on the next deploy.
2501
+
2502
+ With --always-on, the container never sleeps between requests. With --volume <gb>, a persistent
2503
+ disk is mounted and its path is provided via NINEPM_DATA_DIR. Declare what the app needs with
2504
+ --cpus <n> and --memory <size> (512m, 2048, 4g); the platform picks a placement that fits.
2505
+ All of these may require platform enablement.
2506
+ A redeploy KEEPS the app's existing disk automatically: omit --volume and the current disk is
2507
+ carried forward. To remove the disk on purpose pass --no-volume (and --allow-data-wipe, since it
2508
+ destroys the data). If a disk was previously detached by an old CLI, redeploy with --volume <same
2509
+ gb> --region <same region> to reattach the existing disk (it is reused, not recreated empty).
2510
+ --allow-data-wipe acknowledges that dropping a disk, or switching to a placement that can't keep
2511
+ it, destroys the app's existing persistent state; without it such a deploy is refused.
2512
+ --allow-namespace-reset acknowledges that a redeploy may start the app from a fresh, empty
2513
+ persistent store when its recorded one can no longer be found; without it such a deploy is refused.
2514
+ Regions are provider-neutral: auto, us-east, us-west, europe, asia, south-america, middle-east, oceania, africa.
2515
+ `);
2516
+ }
2517
+ if (isDirectRun()) {
2518
+ void main();
2519
+ }
2520
+ function isDirectRun() {
2521
+ if (!process.argv[1]) {
2522
+ return false;
2523
+ }
2524
+ try {
2525
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
2526
+ }
2527
+ catch {
2528
+ return false;
2529
+ }
2530
+ }