ninepm 0.53.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -110,7 +110,7 @@ Deploy a container app from a local Docker build:
110
110
 
111
111
  Container deploys require Docker locally. The CLI builds a `linux/amd64` image, exports it, checks image size against the per-app limit and account retained-image quota, uploads through a short-lived session, and finalizes a queued deployment. The container must listen on `0.0.0.0:$PORT` - always read the `PORT` env var rather than hardcoding the selected `--port`, since on some placements the platform holds the selected port and hands the app a different one; set `--health-path` when the app has a cheap readiness endpoint.
112
112
 
113
- Use `--with-db` when the container needs managed SQL. The app receives `NINEPM_SQL_URL` and `NINEPM_DB_BINDINGS`; send `POST` requests to `NINEPM_SQL_URL` with `{ "binding": "DB", "sql": "SELECT 1", "params": [] }`. For local development, use the app's normal local database adapter behind a repository layer and keep deployed SQL compatible with SQLite.
113
+ Use `--with-db` when the container needs managed SQL. The app receives `NINEPM_SQL_URL` and `NINEPM_DB_BINDINGS`; send `POST` requests to `NINEPM_SQL_URL` with `{ "binding": "DB", "sql": "SELECT 1", "params": [] }`. Some placements also set `NINEPM_SQL_TOKEN` — when it is present, send `Authorization: Bearer <that token>` on every request, or the bridge refuses them all with `401 {"error":"unauthorized"}`. Read the URL and the token from the environment on each call rather than hardcoding either: the URL differs between placements, and the token is reissued on every deploy. For local development, use the app's normal local database adapter behind a repository layer and keep deployed SQL compatible with SQLite.
114
114
 
115
115
  Regions are provider-neutral: `auto`, `us-east`, `us-west`, `europe`, `asia`, `south-america`, `middle-east`, `oceania`, `africa`.
116
116
 
@@ -16,6 +16,13 @@ export interface Project {
16
16
  createdAt: string;
17
17
  updatedAt: string;
18
18
  }
19
+ export interface AppUserFilesUsage {
20
+ usedBytes: number;
21
+ pendingBytes: number;
22
+ ceilingBytes: number;
23
+ ceilingIsDefault: boolean;
24
+ fileCount: number;
25
+ }
19
26
  export interface FileManifestEntry {
20
27
  path: string;
21
28
  size: number;
@@ -33,7 +33,14 @@ export function formatKilobytes(bytes) {
33
33
  return Number.isInteger(kb) ? `${kb} kB` : `${kb.toFixed(1)} kB`;
34
34
  }
35
35
  export function assetFileTooLargeMessage(filePath, size) {
36
- return `File "${filePath}" is ${formatMebibytes(size)}; individual files are limited to ${formatMebibytes(MAX_STATIC_ASSET_FILE_BYTES)}.`;
36
+ // Carries the remedy as well as the ceiling, and only a remedy that holds for EVERY
37
+ // oversized file: this runs over the whole manifest, so the file may be the configured
38
+ // worker entry, where "leave it out" produces a missing-entry failure instead. Shrinking
39
+ // is the one option that is always available. Deliberately silent on HOW — that depends
40
+ // on the file, and "compress the image" reads as advice from something that has looked
41
+ // at it.
42
+ return (`File "${filePath}" is ${formatMebibytes(size)}; individual files are limited to ${formatMebibytes(MAX_STATIC_ASSET_FILE_BYTES)}. ` +
43
+ "Shrink the file before deploying again.");
37
44
  }
38
45
  // Manifest entries that exceed the per-file limit. A structural type keeps this
39
46
  // module free of cross-tree type imports so it copies cleanly into the CLI.
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { Agent } from "undici";
3
- import type { AccessGrant, AccessGrantType, AccessProviderKey, AppAccessSettings, AppAccessServiceToken, AppAccessToken, AppDatabase, AppRuntime, ContainerImageUploadInstruction, ContainerRegion, Deployment, ManagedFileObject, ManagedFileShare, ManagedFileUsage, Project } from "./cli-types.js";
3
+ import type { AccessGrant, AccessGrantType, AccessProviderKey, AppAccessSettings, AppAccessServiceToken, AppAccessToken, AppDatabase, AppRuntime, AppUserFilesUsage, ContainerImageUploadInstruction, ContainerRegion, Deployment, ManagedFileObject, ManagedFileShare, ManagedFileUsage, Project } from "./cli-types.js";
4
4
  export interface DeployOptions {
5
5
  directory: string;
6
6
  apiUrl: string;
@@ -114,7 +114,7 @@ export interface CreatedServiceToken {
114
114
  clientSecret: string;
115
115
  }
116
116
  export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
117
- export declare const CLI_VERSION = "0.53.0";
117
+ export declare const CLI_VERSION = "0.56.0";
118
118
  type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
119
119
  export interface StoredCredentialEntry {
120
120
  token: string;
@@ -351,6 +351,9 @@ export declare function signupDevice(options: {
351
351
  }>;
352
352
  export declare function expiredDeviceAuthMessage(purpose: string, userCode: string, lifetimeMs: number, verificationUri: string): string;
353
353
  export declare function listManagedFiles(options: FileCommandOptions): Promise<ManagedFileListResult>;
354
+ export declare function getAppUserFilesUsage(options: ApiOptions & {
355
+ site: string;
356
+ }): Promise<AppUserFilesUsage>;
354
357
  export declare function putManagedFile(options: FilePutOptions): Promise<ManagedFilePutResult>;
355
358
  export declare function getManagedFile(options: FileGetOptions): Promise<{
356
359
  fileId: string;
@@ -450,6 +453,10 @@ type CliCommand = {
450
453
  action: {
451
454
  kind: "list";
452
455
  project?: string;
456
+ } | {
457
+ kind: "usage";
458
+ project?: string;
459
+ json: boolean;
453
460
  } | {
454
461
  kind: "put";
455
462
  localPath: string;
@@ -589,6 +596,7 @@ export declare function classifyBrowserOpen(input: {
589
596
  error?: unknown;
590
597
  }): BrowserOpenResult;
591
598
  export declare function hasNoGraphicalDisplay(platform: string, env: Record<string, string | undefined>): boolean;
599
+ export declare function formatAppUserFilesUsageLine(slug: string, usage: AppUserFilesUsage): string;
592
600
  export declare class UsageError extends Error {
593
601
  }
594
602
  export {};
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ import { MAX_DEPLOY_DESCRIPTION_LENGTH, normalizeDeployDescription } from "./dep
14
14
  import { MAX_STATIC_ASSET_FILE_BYTES, MAX_WORKER_ENTRY_COMPRESSED_BYTES, MAX_WORKER_SCRIPT_COMPRESSED_BYTES, WORKER_ENTRY_COMPRESSED_WARN_BYTES, assetFileTooLargeMessage, formatMebibytes, formatMegabytes, oversizedAssets, workerBundleNearLimitMessage, workerBundleTooLargeMessage, workerEntryNearLimitMessage, workerEntryTooLargeMessage, } from "./deploy-limits.js";
15
15
  import { buildFileManifest, readManifestFile } from "./manifest.js";
16
16
  import { API_KEY_HELP, API_URL_HELP, CLI_CLIENT_ID, DEFAULT_API_URL, reportCliError } from "./cli-config.js";
17
- export const CLI_VERSION = "0.53.0";
17
+ export const CLI_VERSION = "0.56.0";
18
18
  const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
19
19
  const TOKEN_SERVICE = "9pm";
20
20
  const TOKEN_ACCOUNT = "default";
@@ -1450,6 +1450,11 @@ async function apiRequest(options, route, init) {
1450
1450
  export async function listManagedFiles(options) {
1451
1451
  return apiRequest(options, filesRoute(options), { method: "GET" });
1452
1452
  }
1453
+ // Per-app user-file usage + ceiling (files an app's signed-in end users uploaded through
1454
+ // ctx.files) — a different endpoint from the owner's own managed-file library above.
1455
+ export async function getAppUserFilesUsage(options) {
1456
+ return apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}/files/usage`, { method: "GET" });
1457
+ }
1453
1458
  export async function putManagedFile(options) {
1454
1459
  const fetchFn = options.fetchFn ?? fetch;
1455
1460
  const apiUrl = normalizeApiUrl(options.apiUrl);
@@ -2426,6 +2431,15 @@ function parseFilesArgs(argv) {
2426
2431
  if (subcommand === "list") {
2427
2432
  return { kind: "files", ...base, action: { kind: "list", ...withProject } };
2428
2433
  }
2434
+ if (subcommand === "usage") {
2435
+ // Per-app user-file usage. --project scopes to one app; omitting it entirely is the
2436
+ // account-wide overview. But a present-but-empty --project/--site is a typo, not the
2437
+ // overview: reject it rather than silently fanning out over every app.
2438
+ if ((flags.has("project") || flags.has("site")) && !project) {
2439
+ throw new Error("--project needs an app slug, e.g. `9pm files usage --project my-app`.");
2440
+ }
2441
+ return { kind: "files", ...base, action: { kind: "usage", ...withProject, json: flags.has("json") } };
2442
+ }
2429
2443
  if (subcommand === "put") {
2430
2444
  const localPath = argv[1];
2431
2445
  if (!localPath || localPath.startsWith("--")) {
@@ -3831,6 +3845,19 @@ function formatBytesForCli(value) {
3831
3845
  }
3832
3846
  return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}GB`;
3833
3847
  }
3848
+ // One `9pm files usage` row: "<slug>\t<used> / <ceiling> (<pct>%)\t<n> files", mirroring
3849
+ // the `used of total (percent%)` disk-usage style.
3850
+ export function formatAppUserFilesUsageLine(slug, usage) {
3851
+ const ceiling = usage.ceilingIsDefault ? `${formatBytesForCli(usage.ceilingBytes)} (default)` : formatBytesForCli(usage.ceilingBytes);
3852
+ // Used-over-ceiling has no meaningful percent — a zero-byte ceiling with files, or a
3853
+ // ceiling lowered below current usage — so say "over limit" instead of a misleading 0%.
3854
+ const status = usage.usedBytes > usage.ceilingBytes
3855
+ ? "over limit"
3856
+ : usage.ceilingBytes > 0
3857
+ ? `${Math.round((usage.usedBytes / usage.ceilingBytes) * 100)}%`
3858
+ : "0%";
3859
+ return `${slug}\t${formatBytesForCli(usage.usedBytes)} / ${ceiling} (${status})\t${usage.fileCount} files`;
3860
+ }
3834
3861
  // Human remaining time for the device-auth wait (#509). Ceil so a window with
3835
3862
  // any time left never reads "0s", and clamp so a passed deadline reads "0s"
3836
3863
  // rather than a negative.
@@ -4285,6 +4312,44 @@ async function runFilesCommand(command) {
4285
4312
  console.log(`Usage: ${formatBytesForCli(listed.usage.usedBytes)} / ${formatBytesForCli(listed.usage.quotaBytes)}`);
4286
4313
  return;
4287
4314
  }
4315
+ if (command.action.kind === "usage") {
4316
+ const action = command.action;
4317
+ if (action.project) {
4318
+ const usage = await getAppUserFilesUsage({ ...base, site: action.project });
4319
+ if (action.json) {
4320
+ console.log(JSON.stringify({ project: action.project, ...usage }, null, 2));
4321
+ return;
4322
+ }
4323
+ console.log(formatAppUserFilesUsageLine(action.project, usage));
4324
+ return;
4325
+ }
4326
+ // No --project: usage across every app the account owns. A dedicated overview, so the
4327
+ // per-app fan-out (one usage call each, in parallel) lives here rather than slowing the
4328
+ // plain `9pm apps` inventory.
4329
+ const { projects } = await listSites(base);
4330
+ // Isolate per-app failures: one app erroring (a transient 5xx, or a project deleted
4331
+ // between listSites and here) must not blank the rest of the table.
4332
+ const rows = await Promise.all(projects.map(async (p) => {
4333
+ try {
4334
+ return { slug: p.slug, usage: await getAppUserFilesUsage({ ...base, site: p.id }) };
4335
+ }
4336
+ catch {
4337
+ return { slug: p.slug, usage: null };
4338
+ }
4339
+ }));
4340
+ if (action.json) {
4341
+ console.log(JSON.stringify(rows.map((r) => (r.usage ? { project: r.slug, ...r.usage } : { project: r.slug, unavailable: true })), null, 2));
4342
+ return;
4343
+ }
4344
+ if (rows.length === 0) {
4345
+ console.log("No apps. Run `9pm deploy` to create one.");
4346
+ return;
4347
+ }
4348
+ for (const row of rows) {
4349
+ console.log(row.usage ? formatAppUserFilesUsageLine(row.slug, row.usage) : `${row.slug}\t(usage unavailable)`);
4350
+ }
4351
+ return;
4352
+ }
4288
4353
  if (command.action.kind === "put") {
4289
4354
  const result = await putManagedFile({ ...base, ...project, localPath: command.action.localPath, ...(command.action.remotePath ? { remotePath: command.action.remotePath } : {}) });
4290
4355
  console.log(`Stored ${result.file.path} (${formatBytesForCli(result.file.size)}).`);
@@ -4346,6 +4411,7 @@ function printUsage() {
4346
4411
  9pm latest [--json]
4347
4412
  9pm support -m "<what happened>" [--subject <subject>] [--site <app>]
4348
4413
  9pm files list [--project <site>]
4414
+ 9pm files usage [--project <site>] [--json]
4349
4415
  9pm files put <local-path> [--path <remote-path>] [--project <site>]
4350
4416
  9pm files get <file-id> [--output <path>] [--project <site>]
4351
4417
  9pm files share <file-id> [--expires 24h] [--public] [--project <site>]
@@ -4385,7 +4451,7 @@ Size limits:
4385
4451
  Container deploys:
4386
4452
  Requires Docker locally. The CLI builds linux/amd64, exports the image, uploads it through a short-lived session, then finalizes a queued deployment.
4387
4453
  The app must listen on 0.0.0.0:$PORT - always read the PORT env var; on some placements the platform holds the selected --port and hands the app a different one. Use --health-path for a cheap readiness endpoint.
4388
- With --with-db, containers receive NINEPM_SQL_URL and NINEPM_DB_BINDINGS for the managed SQL bridge.
4454
+ With --with-db, containers receive NINEPM_SQL_URL and NINEPM_DB_BINDINGS for the managed SQL bridge, plus NINEPM_SQL_TOKEN where that bridge requires one - send it as "Authorization: Bearer <token>" or every query is refused.
4389
4455
  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).
4390
4456
  With --with-email, a signed-in end user can send app invitation emails; it implies --with-auth and works on public apps only.
4391
4457
  9pm env set <app> KEY=value [KEY2=value2 ...] [--var] (secret by default; --var = readable config)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ninepm",
3
- "version": "0.53.0",
3
+ "version": "0.56.0",
4
4
  "description": "CLI for deploying built apps to 9pm.ai.",
5
5
  "homepage": "https://9pm.ai",
6
6
  "keywords": [