run402 4.131.0 → 4.133.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.
Files changed (43) hide show
  1. package/cli.mjs +6 -0
  2. package/lib/app-repos.mjs +444 -0
  3. package/lib/billing.mjs +155 -1
  4. package/lib/command-manifest.mjs +16 -0
  5. package/lib/path-lookup.mjs +9 -4
  6. package/package.json +1 -1
  7. package/sdk/dist/index.d.ts +11 -0
  8. package/sdk/dist/index.d.ts.map +1 -1
  9. package/sdk/dist/index.js +11 -0
  10. package/sdk/dist/index.js.map +1 -1
  11. package/sdk/dist/namespaces/app-repos.d.ts +69 -0
  12. package/sdk/dist/namespaces/app-repos.d.ts.map +1 -0
  13. package/sdk/dist/namespaces/app-repos.js +312 -0
  14. package/sdk/dist/namespaces/app-repos.js.map +1 -0
  15. package/sdk/dist/namespaces/app-repos.types.d.ts +188 -0
  16. package/sdk/dist/namespaces/app-repos.types.d.ts.map +1 -0
  17. package/sdk/dist/namespaces/app-repos.types.js +12 -0
  18. package/sdk/dist/namespaces/app-repos.types.js.map +1 -0
  19. package/sdk/dist/namespaces/billing.d.ts +131 -0
  20. package/sdk/dist/namespaces/billing.d.ts.map +1 -1
  21. package/sdk/dist/namespaces/billing.js +83 -1
  22. package/sdk/dist/namespaces/billing.js.map +1 -1
  23. package/sdk/dist/namespaces/deploy.d.ts.map +1 -1
  24. package/sdk/dist/namespaces/deploy.js +68 -3
  25. package/sdk/dist/namespaces/deploy.js.map +1 -1
  26. package/sdk/dist/namespaces/deploy.types.d.ts +34 -2
  27. package/sdk/dist/namespaces/deploy.types.d.ts.map +1 -1
  28. package/sdk/dist/namespaces/deploy.types.js.map +1 -1
  29. package/sdk/dist/namespaces/projects.types.d.ts +18 -0
  30. package/sdk/dist/namespaces/projects.types.d.ts.map +1 -1
  31. package/sdk/dist/namespaces/snapshots.types.d.ts +2 -0
  32. package/sdk/dist/namespaces/snapshots.types.d.ts.map +1 -1
  33. package/sdk/dist/namespaces/tier.d.ts +17 -0
  34. package/sdk/dist/namespaces/tier.d.ts.map +1 -1
  35. package/sdk/dist/namespaces/tier.js.map +1 -1
  36. package/sdk/dist/node/deploy-manifest.d.ts.map +1 -1
  37. package/sdk/dist/node/deploy-manifest.js +28 -2
  38. package/sdk/dist/node/deploy-manifest.js.map +1 -1
  39. package/sdk/dist/scoped.d.ts +28 -0
  40. package/sdk/dist/scoped.d.ts.map +1 -1
  41. package/sdk/dist/scoped.js +51 -0
  42. package/sdk/dist/scoped.js.map +1 -1
  43. package/vault-surface.json +1 -1
package/cli.mjs CHANGED
@@ -42,6 +42,7 @@ BUILD & SHIP — the app and everything it serves
42
42
  up Provision/link/deploy the current app with SDK orchestration
43
43
  projects Manage projects (provision, list, get, sql, delete)
44
44
  repos Host-blind encrypted git repos: create/view/list, snapshot/mirror/recover, fsck/gc/access/policy (repo aliases repos)
45
+ app-repos Plain git repos the app owns (list, create, log, cat, ls, clone); not encrypted, not a vault
45
46
  deploy Deploy a release; subcommands inspect and steer deploys (requires active tier)
46
47
  functions Manage serverless functions (deploy, invoke, logs, list, delete)
47
48
  secrets Manage project secrets (set, list, delete)
@@ -301,6 +302,11 @@ switch (cmd) {
301
302
  await run(sub, rest);
302
303
  break;
303
304
  }
305
+ case "app-repos": {
306
+ const { run } = await import("./lib/app-repos.mjs");
307
+ await run(sub, rest);
308
+ break;
309
+ }
304
310
  case "messages": {
305
311
  const { run } = await import("./lib/messages.mjs");
306
312
  await run(sub, rest);
@@ -0,0 +1,444 @@
1
+ /**
2
+ * run402 app-repos — plain git repositories owned by a project and written by
3
+ * its app. A deployed function reads and commits through `repo` / `repos`
4
+ * from @run402/functions; this command lists, creates, deletes, reads and
5
+ * clones them. An app repo is unencrypted and is NOT a KyGit vault (that is
6
+ * `run402 repos`).
7
+ *
8
+ * JSON to stdout (pipe contract); thin shim over `r.appRepos.*`. `clone` runs
9
+ * `git clone` with a URL-scoped credential helper (`run402 app-repos
10
+ * credential`, git's credential-helper protocol) that hands git the project's
11
+ * locally stored service key, so no secret lands in the remote URL or the
12
+ * clone's `.git/config`.
13
+ */
14
+
15
+ import { spawnSync } from "node:child_process";
16
+ import { existsSync, realpathSync } from "node:fs";
17
+ import { resolve as resolvePath } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { apiBase, activeProfile, findProject, getProject, resolveProjectId } from "./config.mjs";
20
+ import { getSdk } from "./sdk.mjs";
21
+ import { reportSdkError, fail } from "./sdk-errors.mjs";
22
+ import { assertKnownFlags, flagValue, normalizeArgv, positionalArgs, parseIntegerFlag, failUnknownSubcommand } from "./argparse.mjs";
23
+ import { resolveOnPath } from "./path-lookup.mjs";
24
+
25
+ const DEFAULT_REPO = "default";
26
+ const CLI_ENTRY = fileURLToPath(new URL("../cli.mjs", import.meta.url));
27
+
28
+ const HELP = `run402 app-repos — plain git repositories your app owns (not a KyGit vault)
29
+
30
+ Usage:
31
+ run402 app-repos list [--project <project_id>]
32
+ run402 app-repos create <name> [--from <name>] [--project <project_id>]
33
+ run402 app-repos delete <name> --confirm <name> [--project <project_id>]
34
+ run402 app-repos log [<name>] [--path <path>] [--cursor <cursor>] [--limit <n>] [--project <project_id>]
35
+ run402 app-repos cat [<name>] <path> [--at <commit>] [--project <project_id>]
36
+ run402 app-repos ls [<name>] [<dir>] [--at <commit>] [--project <project_id>]
37
+ run402 app-repos clone [<name>] [<dir>] [--project <project_id>]
38
+
39
+ <name> defaults to "default", the repo every project has without creating it.
40
+ --repo <name> may replace the leading <name> wherever it is optional; --path
41
+ <path> replaces the <dir> of ls, and --dir <dir> the <dir> of clone.
42
+
43
+ Options:
44
+ --project <project_id> Project (defaults to the active project)
45
+ --from <name> create: start from another repo of the project (shares
46
+ its history and objects; no bytes are copied)
47
+ --confirm <name> delete: the repo name again (default cannot be deleted)
48
+ --path <path> log: only commits that changed this path
49
+ --cursor <cursor> log: next_cursor from the previous page
50
+ --limit <n> log: 1-100 commits (default 50)
51
+ --at <commit> cat / ls: read at this commit instead of the head
52
+
53
+ Prints:
54
+ list { app_repos: [{ repo_name, head, seq, commit_count, size_bytes, size,
55
+ clone_url, created_at, updated_at }] }
56
+ log { commits: [{ commit, parent, seq, author, committer, message,
57
+ committed_at, paths, paths_total, actor_user_id }], head, has_more,
58
+ next_cursor }, newest first
59
+ cat { repo_name, path, commit, oid, content_type, size_bytes, text } for
60
+ UTF-8, or base64 in place of text for other bytes; a missing file is
61
+ APP_REPO_PATH_NOT_FOUND (exit 1)
62
+ ls { commit, path, entries: [{ name, path, type: "file" | "dir", size, oid }] }
63
+ clone { project_id, repo_name, dir, clone_url, credential_helper }
64
+
65
+ clone runs git clone with a credential helper (run402 app-repos credential) that
66
+ gives git the project's service key from this machine's keystore, or
67
+ RUN402_GRANT_KEY when it is set; the key is never written into the remote URL
68
+ or .git/config, and the helper is kept in the clone so git pull and git push
69
+ keep working. main is the only branch and only fast-forward pushes land: undo a
70
+ commit with a revert. Each file is at most 4 MiB: put media in assets and
71
+ commit a reference to it.
72
+
73
+ Auth: the project's service_key, your wallet, a sign-in session, or a grant
74
+ key. Reads need project.app_repos.read (viewer); create, delete and push need
75
+ project.app_repos.write (developer, or a grant with deploy).
76
+
77
+ Examples:
78
+ run402 app-repos list
79
+ run402 app-repos create user-42 --from template
80
+ run402 app-repos log --path people/p-x.json
81
+ run402 app-repos cat site.json
82
+ run402 app-repos ls default people
83
+ run402 app-repos clone default ./content
84
+ `;
85
+
86
+ export async function run(sub, args) {
87
+ const rest = Array.isArray(args) ? args : [];
88
+ // git's credential-helper protocol: git passes only the operation, so it is
89
+ // dispatched before the --help check (nothing a person types).
90
+ if (sub === "credential") return credential(rest);
91
+ if (sub === undefined || sub === null || sub === "--help" || sub === "-h" || rest.includes("--help") || rest.includes("-h")) {
92
+ console.log(HELP);
93
+ process.exit(0);
94
+ }
95
+ switch (sub) {
96
+ case "list": return list(rest);
97
+ case "create": return create(rest);
98
+ case "delete": return remove(rest);
99
+ case "log": return log(rest);
100
+ case "cat": return cat(rest);
101
+ case "ls": return ls(rest);
102
+ case "clone": return clone(rest);
103
+ case "credential": return credential(rest);
104
+ default:
105
+ failUnknownSubcommand("app-repos", sub);
106
+ }
107
+ }
108
+
109
+ /** Parse flags, refuse unknown ones, and return the normalized argv with its positionals. */
110
+ function parse(args, valueFlags, boolFlags = []) {
111
+ const a = normalizeArgv(args);
112
+ assertKnownFlags(a, [...valueFlags, ...boolFlags, "--help", "-h"], valueFlags);
113
+ return { a, pos: positionalArgs(a, valueFlags) };
114
+ }
115
+
116
+ function tooMany(command, extra) {
117
+ fail({
118
+ code: "BAD_USAGE",
119
+ message: `Unexpected argument for app-repos ${command}: ${extra}`,
120
+ hint: "Run `run402 app-repos --help` for usage.",
121
+ });
122
+ }
123
+
124
+ /** The repo name from a leading positional or --repo, never both. */
125
+ function repoNameFrom(a, positional, command) {
126
+ const flag = flagValue(a, "--repo");
127
+ if (flag && positional && flag !== positional) {
128
+ fail({
129
+ code: "BAD_USAGE",
130
+ message: `app-repos ${command}: --repo ${flag} disagrees with the positional repo name ${positional}`,
131
+ hint: "Name the repo once.",
132
+ });
133
+ }
134
+ return flag || positional || DEFAULT_REPO;
135
+ }
136
+
137
+ async function call(fn) {
138
+ try {
139
+ const result = await fn(getSdk().appRepos);
140
+ console.log(JSON.stringify(result, null, 2));
141
+ } catch (err) {
142
+ reportSdkError(err);
143
+ }
144
+ }
145
+
146
+ async function list(args) {
147
+ const { a, pos } = parse(args, ["--project"]);
148
+ if (pos.length > 0) tooMany("list", pos[0]);
149
+ const projectId = resolveProjectId(flagValue(a, "--project"));
150
+ await call((ar) => ar.list(projectId));
151
+ }
152
+
153
+ async function create(args) {
154
+ const { a, pos } = parse(args, ["--project", "--from"]);
155
+ if (pos.length === 0) {
156
+ fail({ code: "BAD_USAGE", message: "Missing <name>.", hint: "run402 app-repos create <name> [--from <name>]" });
157
+ }
158
+ if (pos.length > 1) tooMany("create", pos[1]);
159
+ const projectId = resolveProjectId(flagValue(a, "--project"));
160
+ const from = flagValue(a, "--from");
161
+ await call((ar) => ar.create(projectId, { repo_name: pos[0], ...(from ? { from } : {}) }));
162
+ }
163
+
164
+ async function remove(args) {
165
+ const { a, pos } = parse(args, ["--project", "--confirm"]);
166
+ if (pos.length === 0) {
167
+ fail({ code: "BAD_USAGE", message: "Missing <name>.", hint: "run402 app-repos delete <name> --confirm <name>" });
168
+ }
169
+ if (pos.length > 1) tooMany("delete", pos[1]);
170
+ const name = pos[0];
171
+ const confirm = flagValue(a, "--confirm");
172
+ if (confirm !== name) {
173
+ fail({
174
+ code: "CONFIRMATION_REQUIRED",
175
+ message: `Deleting app repo ${name} needs --confirm ${name}.`,
176
+ hint: "Its history is gone once no other repo of the project shares its objects.",
177
+ details: { repo_name: name },
178
+ next_actions: [{ type: "run_command", command: `run402 app-repos delete ${name} --confirm ${name}`, why: "confirm the delete by naming the repo again" }],
179
+ });
180
+ }
181
+ const projectId = resolveProjectId(flagValue(a, "--project"));
182
+ await call((ar) => ar.delete(projectId, name, { confirm }));
183
+ }
184
+
185
+ async function log(args) {
186
+ const { a, pos } = parse(args, ["--project", "--repo", "--path", "--cursor", "--limit"]);
187
+ if (pos.length > 1) tooMany("log", pos[1]);
188
+ const repoName = repoNameFrom(a, pos[0], "log");
189
+ const projectId = resolveProjectId(flagValue(a, "--project"));
190
+ const path = flagValue(a, "--path");
191
+ const cursor = flagValue(a, "--cursor");
192
+ const rawLimit = flagValue(a, "--limit");
193
+ const limit = rawLimit === null || rawLimit === undefined ? undefined : parseIntegerFlag("--limit", rawLimit, { min: 1, max: 100 });
194
+ await call((ar) => ar.log(projectId, repoName, {
195
+ ...(path ? { path } : {}),
196
+ ...(cursor ? { cursor } : {}),
197
+ ...(limit !== undefined ? { limit } : {}),
198
+ }));
199
+ }
200
+
201
+ async function cat(args) {
202
+ const { a, pos } = parse(args, ["--project", "--repo", "--at"]);
203
+ if (pos.length === 0) {
204
+ fail({ code: "BAD_USAGE", message: "Missing <path>.", hint: "run402 app-repos cat [<name>] <path> [--at <commit>]" });
205
+ }
206
+ if (pos.length > 2) tooMany("cat", pos[2]);
207
+ const [namePos, path] = pos.length === 2 ? pos : [undefined, pos[0]];
208
+ const repoName = repoNameFrom(a, namePos, "cat");
209
+ const projectId = resolveProjectId(flagValue(a, "--project"));
210
+ const at = flagValue(a, "--at");
211
+ let file;
212
+ try {
213
+ file = await getSdk().appRepos.readFile(projectId, repoName, path, at ? { at } : {});
214
+ } catch (err) {
215
+ reportSdkError(err);
216
+ return;
217
+ }
218
+ if (!file) {
219
+ fail({
220
+ code: "APP_REPO_PATH_NOT_FOUND",
221
+ message: `No file at ${path} in app repo ${repoName}${at ? ` at ${at}` : ""}.`,
222
+ details: { repo_name: repoName, path, ...(at ? { at } : {}) },
223
+ next_actions: [{ type: "run_command", command: `run402 app-repos ls ${repoName}`, why: "list what the repo holds" }],
224
+ });
225
+ }
226
+ const { bytes, text, ...rest } = file;
227
+ const out = text === null
228
+ ? { ...rest, encoding: "base64", base64: Buffer.from(bytes).toString("base64") }
229
+ : { ...rest, text };
230
+ console.log(JSON.stringify(out, null, 2));
231
+ }
232
+
233
+ async function ls(args) {
234
+ const { a, pos } = parse(args, ["--project", "--repo", "--path", "--at"]);
235
+ if (pos.length > 2) tooMany("ls", pos[2]);
236
+ const repoName = repoNameFrom(a, pos[0], "ls");
237
+ const dirFlag = flagValue(a, "--path");
238
+ if (dirFlag && pos[1] && dirFlag !== pos[1]) {
239
+ fail({ code: "BAD_USAGE", message: `app-repos ls: --path ${dirFlag} disagrees with the positional directory ${pos[1]}`, hint: "Name the directory once." });
240
+ }
241
+ const dir = dirFlag || pos[1];
242
+ const projectId = resolveProjectId(flagValue(a, "--project"));
243
+ const at = flagValue(a, "--at");
244
+ await call((ar) => ar.tree(projectId, repoName, dir || undefined, at ? { at } : {}));
245
+ }
246
+
247
+ /** The git URL of one app repo on the configured API base. */
248
+ export function appRepoGitUrl(base, projectId, repoName) {
249
+ return `${base.replace(/\/+$/, "")}/projects/v1/${encodeURIComponent(projectId)}/app-repos/${encodeURIComponent(repoName)}.git`;
250
+ }
251
+
252
+ /**
253
+ * True when `run402` on PATH is this very CLI. The helper must run the CLI
254
+ * that ran the clone: another install on PATH may be older and lack
255
+ * `app-repos`, and git would get "Unknown command" instead of a key.
256
+ */
257
+ function pathRun402IsThisCli() {
258
+ const found = resolveOnPath("run402");
259
+ if (!found) return false;
260
+ try {
261
+ return realpathSync(found) === realpathSync(CLI_ENTRY);
262
+ } catch {
263
+ return false;
264
+ }
265
+ }
266
+
267
+ /** The `!…` credential-helper command persisted in the clone. */
268
+ function helperCommand(byName) {
269
+ const parts = [];
270
+ if (process.env.RUN402_API_BASE) parts.push(`RUN402_API_BASE=${shellQuote(process.env.RUN402_API_BASE)}`);
271
+ if (process.env.RUN402_CONFIG_DIR) parts.push(`RUN402_CONFIG_DIR=${shellQuote(process.env.RUN402_CONFIG_DIR)}`);
272
+ parts.push(byName ? "run402" : `${shellQuote(process.execPath)} ${shellQuote(CLI_ENTRY)}`);
273
+ const profile = activeProfile();
274
+ if (profile && profile !== "default") parts.push("--wallet", shellQuote(profile));
275
+ parts.push("app-repos", "credential");
276
+ return `!${parts.join(" ")}`;
277
+ }
278
+
279
+ function shellQuote(value) {
280
+ return /^[A-Za-z0-9_./:@%+=-]+$/.test(value) ? value : `'${String(value).replace(/'/g, `'\\''`)}'`;
281
+ }
282
+
283
+ async function clone(args) {
284
+ const { a, pos } = parse(args, ["--project", "--repo", "--dir"]);
285
+ if (pos.length > 2) tooMany("clone", pos[2]);
286
+ const repoName = repoNameFrom(a, pos[0], "clone");
287
+ const dirFlag = flagValue(a, "--dir");
288
+ if (dirFlag && pos[1] && dirFlag !== pos[1]) {
289
+ fail({ code: "BAD_USAGE", message: `app-repos clone: --dir ${dirFlag} disagrees with the positional directory ${pos[1]}`, hint: "Name the directory once." });
290
+ }
291
+ const dir = resolvePath(dirFlag || pos[1] || repoName);
292
+ const projectId = resolveProjectId(flagValue(a, "--project"));
293
+
294
+ // The helper needs a key on this machine: a grant key in the environment,
295
+ // or the project's service key in the keystore (findProject fails with the
296
+ // import next action when it is missing).
297
+ if (!grantKeyFromEnv()) findProject(projectId);
298
+
299
+ if (existsSync(dir)) {
300
+ fail({
301
+ code: "BAD_USAGE",
302
+ message: `Destination ${dir} already exists.`,
303
+ hint: "Clone into a new directory: run402 app-repos clone [<name>] <dir>.",
304
+ details: { dir },
305
+ });
306
+ }
307
+ const git = spawnSync("git", ["--version"], { encoding: "utf8" });
308
+ if (git.error || git.status !== 0) {
309
+ fail({
310
+ code: "GIT_NOT_FOUND",
311
+ message: "app-repos clone needs git on PATH.",
312
+ hint: "Install git, then run the clone again.",
313
+ });
314
+ }
315
+
316
+ // Resolve the repo first, so a missing repo is APP_REPO_NOT_FOUND with its
317
+ // create next action rather than a bare git 404.
318
+ try {
319
+ await getSdk().appRepos.get(projectId, repoName);
320
+ } catch (err) {
321
+ reportSdkError(err);
322
+ return;
323
+ }
324
+
325
+ const url = appRepoGitUrl(apiBase(), projectId, repoName);
326
+ const byName = pathRun402IsThisCli();
327
+ const helper = helperCommand(byName);
328
+ // URL-scoped: the empty value clears any helper a global config would ask
329
+ // (so no keychain ever stores the key), then ours answers; useHttpPath
330
+ // hands it the path so it can name the project. `git clone --config`
331
+ // applies these before the first fetch and keeps them in the clone.
332
+ const scope = `credential.${url}`;
333
+ const result = spawnSync("git", [
334
+ "clone",
335
+ "--config", `${scope}.helper=`,
336
+ "--config", `${scope}.helper=${helper}`,
337
+ "--config", `${scope}.useHttpPath=true`,
338
+ url,
339
+ dir,
340
+ ], { stdio: ["ignore", 2, 2], env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
341
+ if (result.error || result.status !== 0) {
342
+ fail({
343
+ code: "GIT_CLONE_FAILED",
344
+ message: `git clone of app repo ${repoName} failed${result.status !== null ? ` (exit ${result.status})` : ""}.`,
345
+ hint: "git's own message is above on stderr.",
346
+ details: { project_id: projectId, repo_name: repoName, clone_url: url, dir },
347
+ });
348
+ }
349
+ const out = { project_id: projectId, repo_name: repoName, dir, clone_url: url, credential_helper: "run402 app-repos credential" };
350
+ if (!byName) {
351
+ out.next_actions = [{
352
+ type: "install_cli",
353
+ command: "npm i -g run402",
354
+ why: "the clone's credential helper runs this CLI by its current path, because run402 on PATH is missing or a different install; install this run402 globally so git pull and git push keep finding it",
355
+ }];
356
+ }
357
+ console.log(JSON.stringify(out, null, 2));
358
+ }
359
+
360
+ function grantKeyFromEnv() {
361
+ const v = process.env.RUN402_GRANT_KEY;
362
+ return typeof v === "string" && v.trim() ? v.trim() : null;
363
+ }
364
+
365
+ async function readStdin() {
366
+ if (process.stdin.isTTY) return "";
367
+ const chunks = [];
368
+ for await (const chunk of process.stdin) chunks.push(chunk);
369
+ return Buffer.concat(chunks).toString("utf8");
370
+ }
371
+
372
+ /** Parse git's credential-helper input: `key=value` lines up to a blank line. */
373
+ export function parseCredentialInput(text) {
374
+ const out = {};
375
+ for (const line of String(text).split(/\r?\n/)) {
376
+ if (line === "") break;
377
+ const eq = line.indexOf("=");
378
+ if (eq <= 0) continue;
379
+ out[line.slice(0, eq)] = line.slice(eq + 1);
380
+ }
381
+ return out;
382
+ }
383
+
384
+ const APP_REPO_PATH = /^\/?projects\/v1\/([^/]+)\/app-repos\/([^/]+?)\.git(?:\/.*)?$/;
385
+
386
+ /**
387
+ * The project an app-repos credential request is for, or null when the
388
+ * request is not for an app repo on the configured API base.
389
+ */
390
+ export function credentialTarget(input, base) {
391
+ let api;
392
+ try {
393
+ api = new URL(base);
394
+ } catch {
395
+ return null;
396
+ }
397
+ if (input.protocol && `${input.protocol}:` !== api.protocol) return null;
398
+ if (input.host && input.host.toLowerCase() !== api.host.toLowerCase()) return null;
399
+ if (!input.path) return null;
400
+ let path = input.path;
401
+ const basePath = api.pathname.replace(/^\/+|\/+$/g, "");
402
+ if (basePath) {
403
+ if (!path.replace(/^\/+/, "").startsWith(`${basePath}/`)) return null;
404
+ path = path.replace(/^\/+/, "").slice(basePath.length + 1);
405
+ }
406
+ const m = APP_REPO_PATH.exec(path);
407
+ if (!m) return null;
408
+ try {
409
+ return { projectId: decodeURIComponent(m[1]), repoName: decodeURIComponent(m[2]) };
410
+ } catch {
411
+ return null;
412
+ }
413
+ }
414
+
415
+ /**
416
+ * `run402 app-repos credential <get|store|erase>` — git's credential-helper
417
+ * protocol (gitcredentials(7)). Not for people: `clone` installs it. On `get`
418
+ * for an app repo on the configured API base it prints `username` and
419
+ * `password` (RUN402_GRANT_KEY when set, else the project's service key from
420
+ * the local keystore) and nothing else; it prints nothing for any other URL.
421
+ * `store` and `erase` are no-ops. The key is written only to git, on stdout.
422
+ */
423
+ async function credential(args) {
424
+ const op = args[0];
425
+ const input = parseCredentialInput(await readStdin());
426
+ if (op === "store" || op === "erase") return;
427
+ if (op !== "get") {
428
+ fail({
429
+ code: "BAD_USAGE",
430
+ message: "app-repos credential is git's credential helper: get, store or erase, with git's key=value lines on stdin.",
431
+ hint: "run402 app-repos clone installs it; you do not run it yourself.",
432
+ });
433
+ }
434
+ const target = credentialTarget(input, apiBase());
435
+ if (!target) return;
436
+ const password = grantKeyFromEnv() ?? getProject(target.projectId)?.service_key ?? null;
437
+ if (!password) {
438
+ process.stderr.write(
439
+ `run402: no key for project ${target.projectId} on this machine; import one with \`run402 credentials project-keys import --project ${target.projectId} --service-key-stdin\`, or set RUN402_GRANT_KEY.\n`,
440
+ );
441
+ return;
442
+ }
443
+ process.stdout.write(`username=run402\npassword=${password}\n`);
444
+ }
package/lib/billing.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { getSdk } from "./sdk.mjs";
2
2
  import { reportSdkError, fail } from "./sdk-errors.mjs";
3
- import { assertKnownFlags, flagValue, normalizeArgv, parseIntegerFlag, positionalArgs, failUnknownSubcommand } from "./argparse.mjs";
3
+ import { assertAllowedValue, assertKnownFlags, flagValue, normalizeArgv, parseIntegerFlag, positionalArgs, failUnknownSubcommand } from "./argparse.mjs";
4
4
  import { takeOrgPositional, requireRest } from "./org-context.mjs";
5
5
 
6
6
  const HELP = `run402 billing — Email organizations and org checkouts
@@ -14,6 +14,9 @@ Subcommands:
14
14
  checkout <identifier> --product <p> Create an org checkout
15
15
  topup <identifier> --sats <n> [--wait] [--qr <file.png>] Top up the allowance over Lightning (a bolt11 invoice; --qr also writes a scannable PNG)
16
16
  auto-recharge [<org_id>] <on|off> [--threshold <n>]
17
+ metering [<org_id>] [--enable --cap <usd> | --disable] [--standing-order <on|off>] [--threshold <usd>] [--recharge <usd>]
18
+ Metered usage under one monthly cap (no flags: show it)
19
+ card setup [<org_id>] [--return-url <url>] A one-time link to save a card for the standing order
17
20
  balance <identifier> The allowance, by organization id (UUID), wallet (0x...), or email
18
21
  history <identifier> [--limit <n>] Ledger history by organization id (UUID), wallet, or email
19
22
 
@@ -25,6 +28,8 @@ Examples:
25
28
  run402 billing topup 0x1234... --sats 2000 --wait
26
29
  run402 billing topup 0x1234... --sats 2000 --qr /tmp/invoice.png # also write the invoice as a scannable PNG
27
30
  run402 billing auto-recharge org_abc on --threshold 2000
31
+ run402 billing metering --enable --cap 50 # metered usage, at most $50 this month
32
+ run402 billing card setup # then: metering --standing-order on
28
33
  run402 billing balance user@example.com
29
34
  `;
30
35
 
@@ -63,6 +68,62 @@ Options:
63
68
  Examples:
64
69
  run402 billing auto-recharge org_abc on --threshold 2000
65
70
  run402 billing auto-recharge org_abc off
71
+ `,
72
+ metering: `run402 billing metering — Metered usage under one monthly cap
73
+
74
+ Usage:
75
+ run402 billing metering [<org_id>] Show the metering block
76
+ run402 billing metering [<org_id>] --enable --cap <usd> [options] Turn metered usage on
77
+ run402 billing metering [<org_id>] --disable Turn it off (keeps the cap and the card)
78
+
79
+ Arguments:
80
+ <org_id> Organization ID (optional; else --org, RUN402_ORG, the .run402.json binding, orgs use)
81
+
82
+ Options:
83
+ --enable Turn metered usage on (needs a live hobby or team lease)
84
+ --disable Turn metered usage off
85
+ --cap <usd> The monthly cap in US dollars (at least 1; required the first time)
86
+ --standing-order <on|off> The card standing order that refills the allowance under the same cap
87
+ --threshold <usd> Refill when the allowance falls below this (at least 5; default 10)
88
+ --recharge <usd> How much each refill adds (at least 5; default 20)
89
+
90
+ Notes:
91
+ - Storage and vault bytes above the lease's included pools cost a published price
92
+ per GB-month (prorated by the UTC day), API calls above the included amount a
93
+ price per million, charged daily from the allowance and never past the cap in a
94
+ UTC month. Prices: run402 projects quote (the "metered" block).
95
+ - When the allowance or the cap cannot cover a day, metering suspends: nothing is
96
+ deleted, everything keeps serving, the flat pool applies again. Fund the
97
+ allowance, raise the cap, or save a card, and it resumes.
98
+ - --standing-order on needs a saved card: run402 billing card setup.
99
+ - Dollar flags take whole or decimal dollars (--cap 50, --threshold 12.50).
100
+
101
+ Examples:
102
+ run402 billing metering
103
+ run402 billing metering --enable --cap 50
104
+ run402 billing metering --standing-order on --threshold 10 --recharge 20
105
+ run402 billing metering 00000000-0000-4000-8000-000000000001 --disable
106
+ `,
107
+ card: `run402 billing card — The saved card behind the standing order
108
+
109
+ Usage:
110
+ run402 billing card setup [<org_id>] [--return-url <url>]
111
+
112
+ Arguments:
113
+ <org_id> Organization ID (optional; else --org, RUN402_ORG, the .run402.json binding, orgs use)
114
+
115
+ Options:
116
+ --return-url <url> Where the browser lands after the card is saved (a run402.com or localhost URL)
117
+
118
+ Notes:
119
+ - Prints { org_id, setup_url, expires_at }. A person opens setup_url in a browser
120
+ and saves a card; the card is stored by the payment processor and never reaches
121
+ Run402 or this CLI. Then: run402 billing metering --standing-order on.
122
+ - Billing role or higher on the organization; a grant key is refused.
123
+
124
+ Examples:
125
+ run402 billing card setup
126
+ run402 billing card setup 00000000-0000-4000-8000-000000000001 --return-url http://localhost:3000/billing
66
127
  `,
67
128
  history: `run402 billing history — Show ledger history for a organization
68
129
 
@@ -362,6 +423,97 @@ async function autoRecharge(args) {
362
423
  }
363
424
  }
364
425
 
426
+ // prepaid-metered-usage: whole or decimal US dollars → micro-dollars, exactly
427
+ // (string arithmetic, so `--cap 0.1` is 100000 and never 99999.999…).
428
+ function parseUsdFlag(name, raw) {
429
+ if (raw === null || raw === undefined) {
430
+ fail({ code: "BAD_FLAG", message: `${name} requires a US-dollar amount (for example ${name} 50 or ${name} 12.50)`, details: { flag: name } });
431
+ }
432
+ const text = String(raw).trim().replace(/^\$/, "");
433
+ const m = /^(\d+)(?:\.(\d{1,6}))?$/.exec(text);
434
+ if (!m) {
435
+ fail({ code: "BAD_FLAG", message: `${name} must be whole or decimal US dollars (up to 6 decimals), got: ${raw}`, details: { flag: name, value: String(raw) } });
436
+ }
437
+ const micros = Number(m[1]) * 1_000_000 + Number((m[2] ?? "").padEnd(6, "0"));
438
+ if (!Number.isSafeInteger(micros) || micros <= 0) {
439
+ fail({ code: "BAD_FLAG", message: `${name} must be a positive US-dollar amount, got: ${raw}`, details: { flag: name, value: String(raw) } });
440
+ }
441
+ return micros;
442
+ }
443
+
444
+ async function metering(args) {
445
+ const parsedArgs = normalizeArgv(args);
446
+ const valueFlags = ["--org", "--cap", "--standing-order", "--threshold", "--recharge"];
447
+ assertKnownFlags(parsedArgs, [...valueFlags, "--enable", "--disable", "--help", "-h"], valueFlags);
448
+ const enable = parsedArgs.includes("--enable");
449
+ const disable = parsedArgs.includes("--disable");
450
+ if (enable && disable) {
451
+ fail({ code: "BAD_USAGE", message: "Pass --enable or --disable, not both.", hint: "run402 billing metering [<org_id>] [--enable --cap <usd> | --disable]" });
452
+ }
453
+ // Local validation FIRST — a malformed dollar amount is refused before any
454
+ // org resolution or network hop.
455
+ const capUsdMicros = parsedArgs.includes("--cap") ? parseUsdFlag("--cap", flagValue(parsedArgs, "--cap")) : undefined;
456
+ const thresholdUsdMicros = parsedArgs.includes("--threshold") ? parseUsdFlag("--threshold", flagValue(parsedArgs, "--threshold")) : undefined;
457
+ const rechargeUsdMicros = parsedArgs.includes("--recharge") ? parseUsdFlag("--recharge", flagValue(parsedArgs, "--recharge")) : undefined;
458
+ const standingRaw = flagValue(parsedArgs, "--standing-order");
459
+ if (standingRaw !== null) assertAllowedValue(standingRaw, ["on", "off"], "--standing-order");
460
+ const { orgId: organizationId, rest } = await takeOrgPositional(parsedArgs, valueFlags, { cmd: "billing" });
461
+ requireRest(rest, {
462
+ min: 0, max: 0,
463
+ command: "run402 billing metering [<org_id>] [--enable --cap <usd> | --disable] [--standing-order <on|off>] [--threshold <usd>] [--recharge <usd>]",
464
+ });
465
+ const touchesStandingOrder = standingRaw !== null || thresholdUsdMicros !== undefined || rechargeUsdMicros !== undefined;
466
+ const writes = enable || disable || capUsdMicros !== undefined || touchesStandingOrder;
467
+ try {
468
+ const sdk = getSdk();
469
+ // A setting flag without --enable/--disable (or a standing-order amount
470
+ // without on|off) keeps the current state for the part it does not name,
471
+ // so `--standing-order on` alone never turns metering off.
472
+ const needsCurrent = !writes || !(enable || disable) || (touchesStandingOrder && standingRaw === null);
473
+ const current = needsCurrent ? await sdk.billing.getMetering(organizationId) : null;
474
+ if (!writes) {
475
+ console.log(JSON.stringify({ org_id: organizationId, metering: current }, null, 2));
476
+ return;
477
+ }
478
+ const enabled = enable ? true : disable ? false : (current?.enabled ?? false);
479
+ const standingOrder = touchesStandingOrder
480
+ ? {
481
+ enabled: standingRaw !== null ? standingRaw === "on" : (current?.standing_order?.enabled ?? false),
482
+ thresholdUsdMicros,
483
+ rechargeUsdMicros,
484
+ }
485
+ : undefined;
486
+ const data = await sdk.billing.setMetering({ organizationId, enabled, capUsdMicros, standingOrder });
487
+ console.log(JSON.stringify(data, null, 2));
488
+ if (data?.metering?.suspended_reason) {
489
+ console.error(`Metering is suspended (${data.metering.suspended_reason}): fund the allowance, raise the cap, or save a card; it resumes on the next daily pass.`);
490
+ }
491
+ } catch (err) {
492
+ reportSdkError(err);
493
+ }
494
+ }
495
+
496
+ // prepaid-metered-usage: `billing card <action>`; the one action is `setup`.
497
+ async function card(args) {
498
+ const action = Array.isArray(args) ? args[0] : undefined;
499
+ if (action !== "setup") {
500
+ failUnknownSubcommand("billing card", action, { label: "billing card" });
501
+ }
502
+ const parsedArgs = normalizeArgv(args.slice(1));
503
+ const valueFlags = ["--org", "--return-url"];
504
+ assertKnownFlags(parsedArgs, [...valueFlags, "--help", "-h"], valueFlags);
505
+ const returnUrl = flagValue(parsedArgs, "--return-url");
506
+ const { orgId: organizationId, rest } = await takeOrgPositional(parsedArgs, valueFlags, { cmd: "billing" });
507
+ requireRest(rest, { min: 0, max: 0, command: "run402 billing card setup [<org_id>] [--return-url <url>]" });
508
+ try {
509
+ const data = await getSdk().billing.createCardSetup(organizationId, { returnUrl: returnUrl ?? undefined });
510
+ console.log(JSON.stringify(data, null, 2));
511
+ console.error(`Open setup_url in a browser to save a card for this organization (the link expires ${data.expires_at}); then run402 billing metering --standing-order on.`);
512
+ } catch (err) {
513
+ reportSdkError(err);
514
+ }
515
+ }
516
+
365
517
  async function balance(args) {
366
518
  const parsedArgs = normalizeArgv(args);
367
519
  assertKnownFlags(parsedArgs, ["--help", "-h"]);
@@ -422,6 +574,8 @@ export async function run(sub, args) {
422
574
  case "checkout": await checkout(args); break;
423
575
  case "topup": await topup(args); break;
424
576
  case "auto-recharge": await autoRecharge(args); break;
577
+ case "metering": await metering(args); break;
578
+ case "card": await card(args); break;
425
579
  case "balance": await balance(args); break;
426
580
  case "history": await history(args); break;
427
581
  default: