run402 4.66.0 → 4.67.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 (41) hide show
  1. package/README.md +4 -2
  2. package/gitvault-surface.json +10 -2
  3. package/lib/cold-start.mjs +77 -0
  4. package/lib/command-manifest.mjs +6 -1
  5. package/lib/doctor.mjs +10 -0
  6. package/lib/gitvault-capabilities.mjs +12 -0
  7. package/lib/org-context.mjs +26 -0
  8. package/lib/path-lookup.mjs +24 -0
  9. package/lib/path-lookup.test.mjs +66 -0
  10. package/lib/repos.mjs +307 -10
  11. package/lib/rooms-context.mjs +17 -1
  12. package/package.json +1 -1
  13. package/sdk/dist/errors.d.ts +1 -1
  14. package/sdk/dist/errors.d.ts.map +1 -1
  15. package/sdk/dist/errors.js.map +1 -1
  16. package/sdk/dist/namespaces/gitvault.d.ts +131 -4
  17. package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
  18. package/sdk/dist/namespaces/gitvault.js +268 -8
  19. package/sdk/dist/namespaces/gitvault.js.map +1 -1
  20. package/sdk/dist/node/gitvault-address.d.ts +2 -0
  21. package/sdk/dist/node/gitvault-address.d.ts.map +1 -1
  22. package/sdk/dist/node/gitvault-address.js +13 -2
  23. package/sdk/dist/node/gitvault-address.js.map +1 -1
  24. package/sdk/dist/node/gitvault-handoff.d.ts +111 -0
  25. package/sdk/dist/node/gitvault-handoff.d.ts.map +1 -0
  26. package/sdk/dist/node/gitvault-handoff.js +277 -0
  27. package/sdk/dist/node/gitvault-handoff.js.map +1 -0
  28. package/sdk/dist/node/gitvault-keystore.d.ts +1 -1
  29. package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
  30. package/sdk/dist/node/gitvault-restore.d.ts +46 -0
  31. package/sdk/dist/node/gitvault-restore.d.ts.map +1 -0
  32. package/sdk/dist/node/gitvault-restore.js +123 -0
  33. package/sdk/dist/node/gitvault-restore.js.map +1 -0
  34. package/sdk/dist/node/gitvault-snapshot.d.ts +87 -0
  35. package/sdk/dist/node/gitvault-snapshot.d.ts.map +1 -1
  36. package/sdk/dist/node/gitvault-snapshot.js +216 -11
  37. package/sdk/dist/node/gitvault-snapshot.js.map +1 -1
  38. package/sdk/dist/node/index.d.ts +6 -2
  39. package/sdk/dist/node/index.d.ts.map +1 -1
  40. package/sdk/dist/node/index.js +8 -1
  41. package/sdk/dist/node/index.js.map +1 -1
package/README.md CHANGED
@@ -54,7 +54,7 @@ For typed `run402.deploy.ts` configs, pass `--manifest` explicitly because TypeS
54
54
 
55
55
  ### repos — host-blind encrypted git repos (zero deploy ceremony)
56
56
 
57
- One noun, twelve verbs (`repo` singular resolves identically). `run402 gitvault <verb>` answers `COMMAND_MOVED`/`COMMAND_REMOVED`.
57
+ One noun, fourteen verbs (`repo` singular resolves identically). `run402 gitvault <verb>` answers `COMMAND_MOVED`/`COMMAND_REMOVED`.
58
58
 
59
59
  ```bash
60
60
  run402 init # once per machine
@@ -68,7 +68,9 @@ run402 repos list --org org_1a2b3c
68
68
  run402 repos delete --project prj_xyz --force # refuses without --force while the vault holds generations
69
69
  ```
70
70
 
71
- A hosted git remote, encrypted before it leaves the machine — no deploy, no manifest, no app. `origin` is claimed additively — an existing `origin` is never touched, the run402 remote falls back to `run402` instead. Every mutating `repos` verb (`create`, `rename`, `delete`, `snapshot`, `policy`, `mirror`, `gc`) is CLI/OpenClaw-only by design — no MCP tool exists or will exist for them (one-shot recovery receipts, immutable generations, irreversible delete). Three READ-ONLY tools do exist — `repos_view`, `repos_list_heads`, `repos_fsck` — teaching only `repos` spellings. See `run402 repos --help` for the full tiered surface (common: `create`/`view`/`list`; occasional: `snapshot`/`mirror`/`recover`; lifecycle: `rename`/`delete`; maintenance: `fsck`/`gc`/`access`/`policy`), and the CLI reference's `repos` section for the terminal-loss statement and the progressive backup warning.
71
+ A hosted git remote, encrypted before it leaves the machine — no deploy, no manifest, no app. `origin` is claimed additively — an existing `origin` is never touched, the run402 remote falls back to `run402` instead. Every mutating `repos` verb (`create`, `rename`, `delete`, `snapshot`, `policy`, `mirror`, `gc`, `handoff`, `resume`) is CLI/OpenClaw-only by design — no MCP tool exists or will exist for them (one-shot recovery receipts, immutable generations, irreversible delete, single-use bearer keys). Three READ-ONLY tools do exist — `repos_view`, `repos_list_heads`, `repos_fsck` — teaching only `repos` spellings. See `run402 repos --help` for the full tiered surface (common: `create`/`view`/`list`; occasional: `snapshot`/`mirror`/`recover`/`handoff`/`resume`; lifecycle: `rename`/`delete`; maintenance: `fsck`/`gc`/`access`/`policy`), and the CLI reference's `repos` section for the terminal-loss statement and the progressive backup warning.
72
+
73
+ **Handoff / resume.** `run402 repos handoff [--note-file <path>]` captures the actual working tree (staged/unstaged/untracked, like `git stash push -u`) into the vault and mints a single-use bearer key (`kgh1_…`, printed to stdout exactly once); `run402 repos resume <kgh1_…>` claims it on another machine, clones fresh, and reapplies the exact dirty state with `git stash apply --index`. No shared keystore, no shared allowance — the key is the whole handoff. Sensitive untracked files (`.env`, `*.pem`, SSH/AWS/GPG dirs, …) are excluded from capture by default (`--include-sensitive <glob>` re-admits one). See the reference `run402.com/llms-cli.txt` for the full note-schema and flag reference.
72
74
 
73
75
  **Named addressing.** `run402 org slug <slug>` (owner-only, small one-time fee) claims an org's globally-unique, address-form slug, after which `run402::<slug>/<name>` addresses any repo under it — `git push` to a name that doesn't exist yet push-to-creates it. `run402 repos rename <name> [--project <id>]` claims the per-org-unique `<name>` half explicitly (no fee); `repos create` claims one automatically, best-effort, when the org already has a slug. Also CLI/SDK-only — no MCP tool.
74
76
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "surface_version": "4.66.0",
2
+ "surface_version": "4.67.0",
3
3
  "verbs": [
4
4
  "repos create",
5
5
  "repos list",
@@ -7,6 +7,8 @@
7
7
  "repos rename",
8
8
  "repos delete",
9
9
  "repos snapshot",
10
+ "repos handoff",
11
+ "repos resume",
10
12
  "repos policy",
11
13
  "repos mirror",
12
14
  "repos fsck",
@@ -71,6 +73,12 @@
71
73
  "byo_live": true,
72
74
  "recover_live": true,
73
75
  "allocation": "create_immediate",
74
- "snapshot_dirty_default": "refuse"
76
+ "snapshot_dirty_default": "refuse",
77
+ "handoff_live": true,
78
+ "handoff_dirty_default": "capture",
79
+ "remote_schemes": [
80
+ "run402",
81
+ "kygit"
82
+ ]
75
83
  }
76
84
  }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `cold-start.mjs` — the minimal x402/Base-Sepolia cold-start chain
3
+ * (kygit-handoff design D5): allowance → faucet → one x402 prototype
4
+ * payment. `repos create` (and so `kygit create`) folds this in when it is
5
+ * refused `NO_ACTIVE_TIER`, so `kygit create` on a genuinely fresh machine
6
+ * ends with a vault, no human signup, no cloud dashboard.
7
+ *
8
+ * This is the SAME shape `run402 init` already walks (allowance create →
9
+ * balance check → faucet → tier), factored down to the x402/Base-Sepolia
10
+ * default rail only (MPP/Tempo is `init`'s own separate concern — an agent
11
+ * hitting NO_ACTIVE_TIER mid-`repos create` gets the default rail, not a
12
+ * rail-selection prompt). `init.mjs`'s own richer flow (voucher redemption,
13
+ * rail switching, astro scaffolding, its own help/exit-code contract) is
14
+ * untouched; this module does not replace it, only the one path `repos
15
+ * create` needs when it discovers there is no tier and nothing else has
16
+ * set one up yet.
17
+ */
18
+ import { readAllowance, saveAllowance } from "./config.mjs";
19
+ import { getSdk } from "./sdk.mjs";
20
+
21
+ const USDC_ABI = [{ name: "balanceOf", type: "function", stateMutability: "view", inputs: [{ name: "account", type: "address" }], outputs: [{ name: "", type: "uint256" }] }];
22
+ const USDC_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
23
+
24
+ /**
25
+ * @param {(line: string) => void} announce Called once per step, so the
26
+ * caller can print each one it took (client-surface spec: "announcing
27
+ * each step").
28
+ * @returns {Promise<{allowance_created: boolean, faucet_requested: boolean, tier: object|null}>}
29
+ */
30
+ export async function foldColdStartChain(announce = () => {}) {
31
+ const out = { allowance_created: false, faucet_requested: false, tier: null };
32
+
33
+ let allowance = readAllowance();
34
+ if (!allowance) {
35
+ const { generatePrivateKey, privateKeyToAccount } = await import("viem/accounts");
36
+ const privateKey = generatePrivateKey();
37
+ const account = privateKeyToAccount(privateKey);
38
+ allowance = { address: account.address, privateKey, created: new Date().toISOString(), funded: false, rail: "x402" };
39
+ saveAllowance(allowance);
40
+ out.allowance_created = true;
41
+ announce(`allowance created: ${allowance.address}`);
42
+ }
43
+
44
+ const { createPublicClient, http } = await import("viem");
45
+ const { baseSepolia } = await import("viem/chains");
46
+ const client = createPublicClient({ chain: baseSepolia, transport: http() });
47
+ let balance = 0;
48
+ try {
49
+ balance = Number(await client.readContract({ address: USDC_SEPOLIA, abi: USDC_ABI, functionName: "balanceOf", args: [allowance.address] }));
50
+ } catch {
51
+ /* an RPC hiccup here is not fatal — the tier purchase below will surface a real payment failure if the balance really is zero */
52
+ }
53
+ if (balance === 0) {
54
+ announce("balance is 0 — requesting the testnet faucet");
55
+ // The faucet is throttled (0.25 USDC / 24h): a throttle refusal is
56
+ // surfaced with its wait and NEVER retried silently (client-surface
57
+ // spec) — this call is not wrapped in a swallowing catch.
58
+ await getSdk().allowance.faucet(allowance.address);
59
+ out.faucet_requested = true;
60
+ announce("faucet requested — waiting briefly for on-chain confirmation");
61
+ for (let i = 0; i < 30; i++) {
62
+ await new Promise((r) => setTimeout(r, 1000));
63
+ try {
64
+ balance = Number(await client.readContract({ address: USDC_SEPOLIA, abi: USDC_ABI, functionName: "balanceOf", args: [allowance.address] }));
65
+ if (balance > 0) break;
66
+ } catch {
67
+ /* keep polling */
68
+ }
69
+ }
70
+ saveAllowance({ ...allowance, funded: true, lastFaucet: new Date().toISOString() });
71
+ }
72
+
73
+ announce("subscribing to the prototype tier (one x402 testnet payment, perpetual)");
74
+ out.tier = await getSdk().tier.set("prototype");
75
+ announce(`prototype tier active${out.tier?.status === "already_active" ? " (already active)" : ""}`);
76
+ return out;
77
+ }
@@ -261,7 +261,7 @@ export const COMMAND_MANIFEST = [
261
261
  // COMMAND_REMOVED) redirect that dispatches nothing — see
262
262
  // RESERVED_SUBCOMMANDS below, and "gitvault" in SKIPPED_FAMILIES.
263
263
 
264
- // ── repos (the consolidated 13-verb family) ─
264
+ // ── repos (the consolidated 15-verb family) ─
265
265
  // Every verb needs a real principal keystore and, for most, an allocated
266
266
  // repo and a local git working tree, so the gate runs structural checks
267
267
  // only — an in-process behavioral run would either no-op against the
@@ -274,6 +274,11 @@ export const COMMAND_MANIFEST = [
274
274
  { path: ["repos", "rename"], positionals: [p("new_name")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["my-notes"], runStyle: "sub", skipBehavioral: "claims a per-org-unique repo name against a live project" },
275
275
  { path: ["repos", "delete"], positionals: [], projectScoped: true, legacyPositionalProject: true, minimalArgs: [], runStyle: "sub", skipBehavioral: "irreversibly deletes a project after reading its live non-repo-resource state and vault generation count" },
276
276
  { path: ["repos", "snapshot"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "captures the cwd git working tree and publishes a signed head" },
277
+ // kygit-handoff: `handoff` captures a stash-shaped checkpoint and mints a
278
+ // single-use bearer key through a live gateway call; `resume` claims one
279
+ // (a real membership mutation) and writes into a fresh working tree.
280
+ { path: ["repos", "handoff"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [], runStyle: "sub", skipBehavioral: "captures the cwd git working tree and mints a single-use bearer key against a live vault — never run against the gate's own checkout" },
281
+ { path: ["repos", "resume"], positionals: [p("key")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["kgh1_0000000000000000000000000000000000000000000000000000000000000000"], runStyle: "sub", skipBehavioral: "claims a live handoff (a real org-membership mutation) and clones a fresh working tree" },
277
282
  { path: ["repos", "policy"], positionals: [p("repos_policy")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["required"], runStyle: "sub", skipBehavioral: "owner + step-up mutation of the live project's activation policy" },
278
283
  // ONE flag-driven verb; `<destination>` is a real attribute (not a
279
284
  // sub-verb literal), so this stays a single manifest entry.
package/lib/doctor.mjs CHANGED
@@ -705,6 +705,16 @@ export async function run(sub, args = []) {
705
705
  if (gv.remote && gv.remote.matches === false) {
706
706
  gaps.push(`the '${gv.remote.name}' git remote points at a different project than ${value.project_id} (${gv.remote.url})`);
707
707
  }
708
+ // kygit-handoff design D8: the mirror of the OLD `npm i -g @kychee/kygit`
709
+ // bug, pointing the other way — a `kygit::` remote with no
710
+ // `git-remote-kygit` helper on PATH means every push/clone/fetch
711
+ // in this checkout fails inside git with an opaque error.
712
+ if (gv.remote?.url?.startsWith("kygit::")) {
713
+ const { isExecutableOnPath } = await import("./path-lookup.mjs");
714
+ if (!isExecutableOnPath("git-remote-kygit")) {
715
+ gaps.push("this checkout's remote is kygit:: but git-remote-kygit is not on PATH — run `npm i -g @kychee/kygit`");
716
+ }
717
+ }
708
718
  // Echoed exactly as the SDK reported them — including the
709
719
  // doctor-persistent `grandfathered` advisory it owns.
710
720
  for (const w of gv.warnings ?? []) gaps.push(`${w.kind}: ${w.message}`);
@@ -45,4 +45,16 @@ export const GITVAULT_CAPABILITIES = {
45
45
  // `repos snapshot` refuses to capture a dirty working tree
46
46
  // (`SNAPSHOT_DIRTY_TREE`) by default; `--allow-dirty` opts in explicitly.
47
47
  snapshot_dirty_default: "refuse",
48
+ // kygit-handoff (design D10): `repos handoff` / `repos resume` mint and
49
+ // claim a real single-use bearer key against a live gateway route —
50
+ // shipped, not aspirational.
51
+ handoff_live: true,
52
+ // `repos handoff` captures dirty work by DEFAULT (the opposite default
53
+ // from `snapshot_dirty_default` above) — two verbs, two defaults, one
54
+ // capture path (design D1).
55
+ handoff_dirty_default: "capture",
56
+ // Both remote door spellings resolve identically (design D8):
57
+ // `run402::` (canonical, plumbing) and `kygit::` (rendered by
58
+ // `@kychee/kygit` once it sets `RUN402_REMOTE_SCHEME=kygit`).
59
+ remote_schemes: ["run402", "kygit"],
48
60
  };
@@ -93,6 +93,23 @@ function orgFromRoomEnv(env) {
93
93
  return raw.slice(0, slash);
94
94
  }
95
95
 
96
+ /**
97
+ * `r402.orgId` from `cwd`'s LOCAL git config (kygit-handoff design D10) —
98
+ * best-effort, gracefully degrading like `findBindingKey`: no repository,
99
+ * no pin, or a shape-invalid value all answer `null` rather than throwing,
100
+ * so a bare directory or a checkout with no gitvault remote costs nothing.
101
+ */
102
+ async function readGitvaultPinnedOrgId(cwd) {
103
+ try {
104
+ const { readPinnedGitvaultRepo } = await import("#sdk/node");
105
+ const pinned = await readPinnedGitvaultRepo(cwd);
106
+ const orgId = trimmed(pinned?.org_id);
107
+ return orgId && ORG_ID_RE.test(orgId) ? orgId : null;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
96
113
  function listOrgsAction() {
97
114
  return nextAction("edit_request", {
98
115
  command: "run402 org list",
@@ -231,6 +248,15 @@ export async function resolveOrg(input = {}, opts = {}) {
231
248
  }
232
249
  if (bindingOrg) return { orgId: bindingOrg, source: "binding", sourceDetail: bindingHit.file };
233
250
 
251
+ // --- Class 3.5: gitvault local pin (kygit-handoff design D10) ------------
252
+ // `r402.orgId` in this checkout's LOCAL git config — written by every
253
+ // gitvault pin site (`repos create`, `resume`, address resolution), a
254
+ // rung below env/binding and above profile state: a checkout that IS a
255
+ // resumed/pinned vault should resolve its own org with zero configuration,
256
+ // but an explicit env var or a committed `.run402.json` binding still wins.
257
+ const pinnedOrg = await readGitvaultPinnedOrgId(cwd);
258
+ if (pinnedOrg) return { orgId: pinnedOrg, source: "gitvault_pin", sourceDetail: "r402.orgId (local git config)" };
259
+
234
260
  // --- Class 4: profile state ----------------------------------------------
235
261
  const selected = trimmed(coreGetActiveOrgId());
236
262
  if (selected) {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `path-lookup.mjs` — the tiny, dependency-free "is this executable on
3
+ * PATH" check kygit-handoff design D8 needs twice (`run402 doctor` and
4
+ * `repos view`, both naming `npm i -g @kychee/kygit` when a `kygit::`
5
+ * remote has no `git-remote-kygit` helper installed). No shell, no `which`
6
+ * subprocess — a plain directory scan mirrors what the OS loader itself
7
+ * does to resolve an unqualified command name.
8
+ */
9
+ import { accessSync, constants } from "node:fs";
10
+ import { delimiter, join } from "node:path";
11
+
12
+ /** True iff `name` resolves to an executable file somewhere on `PATH`. Windows `PATHEXT` is out of scope — this CLI targets POSIX (`engines.node` + the hardened-git doc comments assume it) — so no `.exe`/`.cmd` suffix search. */
13
+ export function isExecutableOnPath(name, env = process.env) {
14
+ const dirs = (env.PATH ?? "").split(delimiter).filter(Boolean);
15
+ for (const dir of dirs) {
16
+ try {
17
+ accessSync(join(dir, name), constants.X_OK);
18
+ return true;
19
+ } catch {
20
+ // not here — keep looking
21
+ }
22
+ }
23
+ return false;
24
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `path-lookup.mjs` — the PATH-executable probe `doctor.mjs`/`repos.mjs`'s
3
+ * `view()` both use to detect a `kygit::` remote with no `git-remote-kygit`
4
+ * helper installed (kygit-handoff design D8, mirroring
5
+ * `kygit/kygit.mjs`'s own `findRemoteHelper`).
6
+ */
7
+ import { describe, it } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { mkdtempSync, chmodSync, writeFileSync, rmSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { isExecutableOnPath } from "./path-lookup.mjs";
13
+
14
+ describe("isExecutableOnPath", () => {
15
+ it("finds an executable file on PATH", () => {
16
+ const dir = mkdtempSync(join(tmpdir(), "path-lookup-"));
17
+ try {
18
+ const bin = join(dir, "git-remote-kygit");
19
+ writeFileSync(bin, "#!/bin/sh\n");
20
+ chmodSync(bin, 0o755);
21
+ assert.equal(isExecutableOnPath("git-remote-kygit", { PATH: dir }), true);
22
+ } finally {
23
+ rmSync(dir, { recursive: true, force: true });
24
+ }
25
+ });
26
+
27
+ it("returns false when the name exists but is not executable", () => {
28
+ const dir = mkdtempSync(join(tmpdir(), "path-lookup-"));
29
+ try {
30
+ const bin = join(dir, "git-remote-kygit");
31
+ writeFileSync(bin, "not executable");
32
+ chmodSync(bin, 0o644);
33
+ assert.equal(isExecutableOnPath("git-remote-kygit", { PATH: dir }), false);
34
+ } finally {
35
+ rmSync(dir, { recursive: true, force: true });
36
+ }
37
+ });
38
+
39
+ it("returns false when the name is nowhere on PATH", () => {
40
+ const dir = mkdtempSync(join(tmpdir(), "path-lookup-"));
41
+ try {
42
+ assert.equal(isExecutableOnPath("git-remote-kygit", { PATH: dir }), false);
43
+ } finally {
44
+ rmSync(dir, { recursive: true, force: true });
45
+ }
46
+ });
47
+
48
+ it("checks every directory on a multi-entry PATH, in order", () => {
49
+ const dirA = mkdtempSync(join(tmpdir(), "path-lookup-a-"));
50
+ const dirB = mkdtempSync(join(tmpdir(), "path-lookup-b-"));
51
+ try {
52
+ const bin = join(dirB, "git-remote-kygit");
53
+ writeFileSync(bin, "#!/bin/sh\n");
54
+ chmodSync(bin, 0o755);
55
+ assert.equal(isExecutableOnPath("git-remote-kygit", { PATH: `${dirA}:${dirB}` }), true);
56
+ } finally {
57
+ rmSync(dirA, { recursive: true, force: true });
58
+ rmSync(dirB, { recursive: true, force: true });
59
+ }
60
+ });
61
+
62
+ it("returns false for an empty or missing PATH rather than throwing", () => {
63
+ assert.equal(isExecutableOnPath("git-remote-kygit", {}), false);
64
+ assert.equal(isExecutableOnPath("git-remote-kygit", { PATH: "" }), false);
65
+ });
66
+ });