run402 4.66.1 → 4.67.1
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 +4 -2
- package/gitvault-surface.json +10 -2
- package/lib/cold-start.mjs +77 -0
- package/lib/command-manifest.mjs +6 -1
- package/lib/doctor.mjs +10 -0
- package/lib/gitvault-capabilities.mjs +12 -0
- package/lib/org-context.mjs +26 -0
- package/lib/path-lookup.mjs +24 -0
- package/lib/path-lookup.test.mjs +66 -0
- package/lib/repos.mjs +307 -10
- package/lib/rooms-context.mjs +17 -1
- package/package.json +1 -1
- package/sdk/dist/errors.d.ts +1 -1
- package/sdk/dist/errors.d.ts.map +1 -1
- package/sdk/dist/errors.js.map +1 -1
- package/sdk/dist/index.d.ts +1 -1
- package/sdk/dist/index.d.ts.map +1 -1
- package/sdk/dist/index.js +1 -1
- package/sdk/dist/index.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.d.ts +152 -4
- package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.js +296 -8
- package/sdk/dist/namespaces/gitvault.js.map +1 -1
- package/sdk/dist/node/gitvault-address.d.ts +2 -0
- package/sdk/dist/node/gitvault-address.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-address.js +13 -2
- package/sdk/dist/node/gitvault-address.js.map +1 -1
- package/sdk/dist/node/gitvault-handoff.d.ts +111 -0
- package/sdk/dist/node/gitvault-handoff.d.ts.map +1 -0
- package/sdk/dist/node/gitvault-handoff.js +277 -0
- package/sdk/dist/node/gitvault-handoff.js.map +1 -0
- package/sdk/dist/node/gitvault-keystore.d.ts +1 -1
- package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-restore.d.ts +46 -0
- package/sdk/dist/node/gitvault-restore.d.ts.map +1 -0
- package/sdk/dist/node/gitvault-restore.js +123 -0
- package/sdk/dist/node/gitvault-restore.js.map +1 -0
- package/sdk/dist/node/gitvault-snapshot.d.ts +87 -0
- package/sdk/dist/node/gitvault-snapshot.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-snapshot.js +216 -11
- package/sdk/dist/node/gitvault-snapshot.js.map +1 -1
- package/sdk/dist/node/index.d.ts +6 -2
- package/sdk/dist/node/index.d.ts.map +1 -1
- package/sdk/dist/node/index.js +8 -1
- 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,
|
|
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
|
|
package/gitvault-surface.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"surface_version": "4.
|
|
2
|
+
"surface_version": "4.67.1",
|
|
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
|
+
}
|
package/lib/command-manifest.mjs
CHANGED
|
@@ -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
|
|
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
|
};
|
package/lib/org-context.mjs
CHANGED
|
@@ -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
|
+
});
|