agentic-relay 3.8.5 → 4.1.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/CLAUDE.md +1 -1
- package/README.md +11 -1
- package/bin/cli.mjs +5 -2
- package/bin/install.mjs +52 -90
- package/bin/lib/cache.mjs +8 -14
- package/bin/lib/commands.mjs +41 -126
- package/bin/lib/doctor.mjs +222 -0
- package/bin/lib/fetch.mjs +14 -79
- package/bin/lib/install-args.mjs +67 -0
- package/bin/lib/parse.mjs +0 -65
- package/bin/lib/sysenv.mjs +152 -0
- package/bin/lib/update-check.mjs +121 -0
- package/bin/lib/zip.mjs +139 -0
- package/bin/relay-core.mjs +50 -85
- package/bin/test/cache.test.mjs +30 -2
- package/bin/test/doctor.test.mjs +76 -0
- package/bin/test/fetch.test.mjs +0 -15
- package/bin/test/install-args.test.mjs +68 -0
- package/bin/test/parse.test.mjs +1 -27
- package/bin/test/session.test.mjs +5 -1
- package/bin/test/update-check.test.mjs +56 -0
- package/bin/test/zip.test.mjs +212 -0
- package/package.json +3 -3
- package/skill/SKILL.md +3 -2
- package/bin/lib/deliverable.mjs +0 -70
- package/bin/lib/driver.mjs +0 -165
- package/bin/lib/pool.mjs +0 -26
- package/bin/test/driver.test.mjs +0 -49
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agentrelay doctor` — diagnose the install/runtime environment.
|
|
3
|
+
*
|
|
4
|
+
* Every check maps to a failure mode seen in the field: stale global versions,
|
|
5
|
+
* EACCES on the npm prefix, root-owned ~/.npm (kills npx AND npm i -g), the
|
|
6
|
+
* global bin dir missing from PATH, a dead/misconfigured API key. Output is
|
|
7
|
+
* one line per check (✓ ok / ! warn / ✗ fail) or a --json envelope; exit 1
|
|
8
|
+
* only when a required check fails.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, existsSync } from "fs";
|
|
12
|
+
import { homedir } from "os";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
import { resolveApiKey, CREDENTIALS_PATH, LEGACY_CREDENTIALS_PATH } from "./config.mjs";
|
|
15
|
+
import { search as apiSearch } from "./api.mjs";
|
|
16
|
+
import { compareSemver } from "./update-check.mjs";
|
|
17
|
+
import {
|
|
18
|
+
npmGlobalPrefix,
|
|
19
|
+
npmGlobalBinDir,
|
|
20
|
+
globalBinShimPath,
|
|
21
|
+
globalPrefixWritable,
|
|
22
|
+
npmCacheOwnedByOther,
|
|
23
|
+
dirOnPath,
|
|
24
|
+
isDirWritable,
|
|
25
|
+
USER_PREFIX_RECIPE,
|
|
26
|
+
CACHE_CHOWN_RECIPE,
|
|
27
|
+
} from "./sysenv.mjs";
|
|
28
|
+
|
|
29
|
+
export const MIN_NODE_MAJOR = 18;
|
|
30
|
+
|
|
31
|
+
/** Pure: ok/fail verdict for a Node version string. */
|
|
32
|
+
export function checkNodeVersion(versionString) {
|
|
33
|
+
const major = Number(String(versionString).split(".")[0]);
|
|
34
|
+
return {
|
|
35
|
+
id: "node",
|
|
36
|
+
label: "Node.js version",
|
|
37
|
+
status: major >= MIN_NODE_MAJOR ? "ok" : "fail",
|
|
38
|
+
detail:
|
|
39
|
+
major >= MIN_NODE_MAJOR
|
|
40
|
+
? `v${versionString}`
|
|
41
|
+
: `v${versionString} — agentrelay needs Node >= ${MIN_NODE_MAJOR}`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Pure: aggregate checks → overall ok + exit code. */
|
|
46
|
+
export function summarize(checks) {
|
|
47
|
+
const failed = checks.filter((c) => c.status === "fail");
|
|
48
|
+
return { ok: failed.length === 0, exitCode: failed.length === 0 ? 0 : 1 };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function fetchLatestVersion(timeoutMs = 3000) {
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch("https://registry.npmjs.org/agentic-relay/latest", {
|
|
56
|
+
signal: controller.signal,
|
|
57
|
+
});
|
|
58
|
+
if (!res.ok) return null;
|
|
59
|
+
const data = await res.json();
|
|
60
|
+
return data.version || null;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
} finally {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function apiKeySource(flagKey) {
|
|
69
|
+
if (flagKey && flagKey.trim()) return "--api-key flag";
|
|
70
|
+
if (process.env.AGENT_RELAY_API_KEY?.trim()) return "$AGENT_RELAY_API_KEY";
|
|
71
|
+
try {
|
|
72
|
+
const creds = JSON.parse(readFileSync(CREDENTIALS_PATH, "utf-8"));
|
|
73
|
+
if (creds.api_key) return CREDENTIALS_PATH;
|
|
74
|
+
} catch {
|
|
75
|
+
/* fall through */
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const creds = JSON.parse(readFileSync(LEGACY_CREDENTIALS_PATH, "utf-8"));
|
|
79
|
+
if (creds.api_key) return `${LEGACY_CREDENTIALS_PATH} (legacy)`;
|
|
80
|
+
} catch {
|
|
81
|
+
/* fall through */
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function cmdDoctor(opts) {
|
|
87
|
+
const checks = [];
|
|
88
|
+
|
|
89
|
+
// 1. Node version
|
|
90
|
+
checks.push(checkNodeVersion(process.versions.node));
|
|
91
|
+
|
|
92
|
+
// 2. CLI version vs registry
|
|
93
|
+
let currentVersion = "unknown";
|
|
94
|
+
try {
|
|
95
|
+
currentVersion = JSON.parse(
|
|
96
|
+
readFileSync(new URL("../../package.json", import.meta.url), "utf-8"),
|
|
97
|
+
).version;
|
|
98
|
+
} catch {
|
|
99
|
+
/* keep "unknown" */
|
|
100
|
+
}
|
|
101
|
+
const latest = await fetchLatestVersion();
|
|
102
|
+
const behind = latest !== null && compareSemver(latest, currentVersion) > 0;
|
|
103
|
+
checks.push({
|
|
104
|
+
id: "version",
|
|
105
|
+
label: "CLI version",
|
|
106
|
+
status: latest === null ? "warn" : behind ? "warn" : "ok",
|
|
107
|
+
detail:
|
|
108
|
+
latest === null
|
|
109
|
+
? `${currentVersion} (couldn't reach the npm registry to compare)`
|
|
110
|
+
: behind
|
|
111
|
+
? `${currentVersion} — ${latest} is available: npm i -g agentic-relay`
|
|
112
|
+
: `${currentVersion} (latest is ${latest})`,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// 3. API key presence
|
|
116
|
+
const key = resolveApiKey(opts.apiKey || null);
|
|
117
|
+
const source = key ? apiKeySource(opts.apiKey || null) : null;
|
|
118
|
+
checks.push({
|
|
119
|
+
id: "api_key",
|
|
120
|
+
label: "API key",
|
|
121
|
+
status: key ? "ok" : "warn",
|
|
122
|
+
detail: key
|
|
123
|
+
? `configured via ${source}`
|
|
124
|
+
: `not found — set --api-key, $AGENT_RELAY_API_KEY, or ${CREDENTIALS_PATH}`,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// 4. API key live verification (skipped without a key)
|
|
128
|
+
if (key) {
|
|
129
|
+
try {
|
|
130
|
+
await apiSearch("doctor connectivity check", {
|
|
131
|
+
apiKey: key,
|
|
132
|
+
maxResults: 1,
|
|
133
|
+
timeoutMs: Math.min(opts.timeoutMs || 10000, 10000),
|
|
134
|
+
});
|
|
135
|
+
checks.push({ id: "api", label: "API connectivity", status: "ok", detail: "search responds" });
|
|
136
|
+
} catch (err) {
|
|
137
|
+
checks.push({
|
|
138
|
+
id: "api",
|
|
139
|
+
label: "API connectivity",
|
|
140
|
+
status: "fail",
|
|
141
|
+
detail: `search failed: ${err.message}`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
checks.push({ id: "api", label: "API connectivity", status: "warn", detail: "skipped (no key)" });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 5. npm global prefix + writability
|
|
149
|
+
const prefix = npmGlobalPrefix();
|
|
150
|
+
if (!prefix) {
|
|
151
|
+
checks.push({ id: "prefix", label: "npm global prefix", status: "warn", detail: "npm not found on PATH" });
|
|
152
|
+
} else if (globalPrefixWritable(prefix)) {
|
|
153
|
+
checks.push({ id: "prefix", label: "npm global prefix", status: "ok", detail: `${prefix} (writable)` });
|
|
154
|
+
} else {
|
|
155
|
+
checks.push({
|
|
156
|
+
id: "prefix",
|
|
157
|
+
label: "npm global prefix",
|
|
158
|
+
status: "warn",
|
|
159
|
+
detail: `${prefix} is not writable — npm i -g will EACCES. Fix: ${USER_PREFIX_RECIPE.join("; ")}`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 6. ~/.npm cache ownership
|
|
164
|
+
checks.push(
|
|
165
|
+
npmCacheOwnedByOther()
|
|
166
|
+
? {
|
|
167
|
+
id: "npm_cache",
|
|
168
|
+
label: "npm cache (~/.npm)",
|
|
169
|
+
status: "warn",
|
|
170
|
+
detail: `owned by another user — breaks npx AND npm i -g. Fix: ${CACHE_CHOWN_RECIPE}`,
|
|
171
|
+
}
|
|
172
|
+
: { id: "npm_cache", label: "npm cache (~/.npm)", status: "ok", detail: "owned by you" },
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// 7 + 8. global bin shim exists + its dir is on PATH
|
|
176
|
+
const shim = globalBinShimPath(prefix, "agentrelay");
|
|
177
|
+
const binDir = npmGlobalBinDir(prefix);
|
|
178
|
+
const shimExists = Boolean(shim) && existsSync(shim);
|
|
179
|
+
checks.push({
|
|
180
|
+
id: "global_bin",
|
|
181
|
+
label: "global `agentrelay` bin",
|
|
182
|
+
status: shimExists ? "ok" : "warn",
|
|
183
|
+
detail: shimExists ? shim : `not found in ${binDir || "npm's global bin dir"} — run: npm i -g agentic-relay`,
|
|
184
|
+
});
|
|
185
|
+
checks.push({
|
|
186
|
+
id: "path",
|
|
187
|
+
label: "global bin dir on PATH",
|
|
188
|
+
status: dirOnPath(binDir) ? "ok" : "warn",
|
|
189
|
+
detail: dirOnPath(binDir)
|
|
190
|
+
? binDir
|
|
191
|
+
: `${binDir || "npm's global bin dir"} is not on PATH — add it to your shell profile`,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// 9. working directory writable (cache + deliverables land here)
|
|
195
|
+
checks.push({
|
|
196
|
+
id: "cwd",
|
|
197
|
+
label: "working dir writable",
|
|
198
|
+
status: isDirWritable(process.cwd()) ? "ok" : "warn",
|
|
199
|
+
detail: process.cwd(),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// 10. zip capability (informational — pure-JS, no system unzip needed)
|
|
203
|
+
checks.push({
|
|
204
|
+
id: "zip",
|
|
205
|
+
label: "deliverable unzip",
|
|
206
|
+
status: "ok",
|
|
207
|
+
detail: "pure-JS extractor (no system `unzip` required)",
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const { ok, exitCode } = summarize(checks);
|
|
211
|
+
return { checks, doctor_ok: ok, exit_code: exitCode, config_dir: join(homedir(), ".config", "agent-relay") };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const ICONS = { ok: "✓", warn: "!", fail: "✗" };
|
|
215
|
+
|
|
216
|
+
export function fmtDoctor(envelope) {
|
|
217
|
+
const lines = (envelope.checks || []).map(
|
|
218
|
+
(c) => `${ICONS[c.status] || "?"} ${c.label.padEnd(26)} ${c.detail}`,
|
|
219
|
+
);
|
|
220
|
+
lines.push(envelope.doctor_ok ? "doctor: no blocking problems found" : "doctor: blocking problems found (✗)");
|
|
221
|
+
return lines.join("\n");
|
|
222
|
+
}
|
package/bin/lib/fetch.mjs
CHANGED
|
@@ -3,27 +3,23 @@
|
|
|
3
3
|
* subfolder of the working directory.
|
|
4
4
|
*
|
|
5
5
|
* v1 is deliberately dumb: the artifact is an OPAQUE blob. If it's a zip we
|
|
6
|
-
* extract it
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* extract it (pure-JS extractor — works on Windows, no system `unzip` needed);
|
|
7
|
+
* otherwise we drop the file in as-is. We NEVER execute anything from the
|
|
8
|
+
* bundle (no install scripts, no running code) — there is no content scanning
|
|
9
|
+
* yet, so contents are inert files for the user/agent to inspect.
|
|
9
10
|
*
|
|
10
11
|
* Security: verify sha256 before unpacking; detect zip by magic bytes (not
|
|
11
12
|
* extension); reject any archive entry that escapes the destination (zip-slip);
|
|
12
13
|
* never extract into the project root.
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
mkdirSync,
|
|
18
|
-
existsSync,
|
|
19
|
-
rmSync,
|
|
20
|
-
readdirSync,
|
|
21
|
-
statSync,
|
|
22
|
-
} from "fs";
|
|
23
|
-
import { join, resolve, basename, sep } from "path";
|
|
24
|
-
import { tmpdir } from "os";
|
|
16
|
+
import { writeFileSync, mkdirSync, existsSync } from "fs";
|
|
17
|
+
import { join, resolve, basename } from "path";
|
|
25
18
|
import { createHash } from "crypto";
|
|
26
|
-
import {
|
|
19
|
+
import { extractZip, assertNoZipSlip } from "./zip.mjs";
|
|
20
|
+
|
|
21
|
+
// Re-exported for callers/tests that import the slip guard from here.
|
|
22
|
+
export { assertNoZipSlip };
|
|
27
23
|
|
|
28
24
|
const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]); // "PK\x03\x04"
|
|
29
25
|
|
|
@@ -73,44 +69,6 @@ export function uniqueDir(base) {
|
|
|
73
69
|
throw new Error(`could not find a free destination near ${base}`);
|
|
74
70
|
}
|
|
75
71
|
|
|
76
|
-
/**
|
|
77
|
-
* Validate that an `unzip -l` listing contains no zip-slip entries (absolute
|
|
78
|
-
* paths or `..` traversal that escapes dest). Returns the list of entry names.
|
|
79
|
-
* Pure-ish: takes the raw listing text so it's unit-testable.
|
|
80
|
-
*/
|
|
81
|
-
export function zipEntriesFromListing(listingText) {
|
|
82
|
-
// `unzip -l` columns: Length Date Time Name — name is everything after
|
|
83
|
-
// the 4th whitespace-delimited column. Skip header/footer lines.
|
|
84
|
-
const names = [];
|
|
85
|
-
for (const line of listingText.split("\n")) {
|
|
86
|
-
const m = line.match(/^\s*\d+\s+\S+\s+\S+\s+(.+)$/);
|
|
87
|
-
if (m && m[1] && m[1] !== "Name") names.push(m[1].trim());
|
|
88
|
-
}
|
|
89
|
-
return names;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export function assertNoZipSlip(entryNames, destDir) {
|
|
93
|
-
const destRoot = resolve(destDir) + sep;
|
|
94
|
-
for (const name of entryNames) {
|
|
95
|
-
if (name.startsWith("/") || name.includes("\0")) {
|
|
96
|
-
throw new Error(`unsafe archive entry (absolute path): ${name}`);
|
|
97
|
-
}
|
|
98
|
-
const target = resolve(destDir, name);
|
|
99
|
-
if (target !== resolve(destDir) && !target.startsWith(destRoot)) {
|
|
100
|
-
throw new Error(`unsafe archive entry (path traversal / zip-slip): ${name}`);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function hasUnzip() {
|
|
106
|
-
try {
|
|
107
|
-
execFileSync("unzip", ["-v"], { stdio: "ignore" });
|
|
108
|
-
return true;
|
|
109
|
-
} catch {
|
|
110
|
-
return false;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
72
|
/**
|
|
115
73
|
* Download + unpack. opts: { url, dest?, checksum?, filename?, fetchImpl? }.
|
|
116
74
|
* Returns { ok, dest, kind, filename, checksum_sha256, placed: [...] }.
|
|
@@ -138,29 +96,15 @@ export async function cmdFetch(opts) {
|
|
|
138
96
|
const dest = uniqueDir(opts.dest || `./${slug}`);
|
|
139
97
|
|
|
140
98
|
if (isZip(buf)) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const tmpZip = join(tmpdir(), `agent-relay-fetch-${digest.slice(0, 12)}.zip`);
|
|
145
|
-
writeFileSync(tmpZip, buf);
|
|
146
|
-
try {
|
|
147
|
-
// Zip-slip guard: inspect entries before extracting.
|
|
148
|
-
const listing = execFileSync("unzip", ["-l", tmpZip], { encoding: "utf-8" });
|
|
149
|
-
const entries = zipEntriesFromListing(listing);
|
|
150
|
-
assertNoZipSlip(entries, dest);
|
|
151
|
-
|
|
152
|
-
mkdirSync(dest, { recursive: true });
|
|
153
|
-
// -o overwrite within the freshly-created dest only; -d targets dest.
|
|
154
|
-
execFileSync("unzip", ["-o", "-q", tmpZip, "-d", dest], { stdio: "ignore" });
|
|
155
|
-
} finally {
|
|
156
|
-
try { rmSync(tmpZip, { force: true }); } catch { /* ignore */ }
|
|
157
|
-
}
|
|
99
|
+
// Pure-JS extraction straight from the in-memory buffer — zip-slip is
|
|
100
|
+
// validated against all entry names inside extractZip before any write.
|
|
101
|
+
const placed = extractZip(buf, dest);
|
|
158
102
|
return {
|
|
159
103
|
ok: true,
|
|
160
104
|
kind: "zip",
|
|
161
105
|
dest,
|
|
162
106
|
checksum_sha256: digest,
|
|
163
|
-
placed
|
|
107
|
+
placed,
|
|
164
108
|
};
|
|
165
109
|
}
|
|
166
110
|
|
|
@@ -178,12 +122,3 @@ export async function cmdFetch(opts) {
|
|
|
178
122
|
placed: [outPath],
|
|
179
123
|
};
|
|
180
124
|
}
|
|
181
|
-
|
|
182
|
-
function listPlaced(dir, acc = []) {
|
|
183
|
-
for (const name of readdirSync(dir)) {
|
|
184
|
-
const p = join(dir, name);
|
|
185
|
-
if (statSync(p).isDirectory()) listPlaced(p, acc);
|
|
186
|
-
else acc.push(p);
|
|
187
|
-
}
|
|
188
|
-
return acc;
|
|
189
|
-
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installer argument parsing — extracted from install.mjs so it's unit-testable
|
|
3
|
+
* (install.mjs self-runs main() on import).
|
|
4
|
+
*
|
|
5
|
+
* Field-observed footgun this guards against: `--api-key= am_live_x` (a space
|
|
6
|
+
* after the `=`). The old parser captured an empty key and silently dropped
|
|
7
|
+
* the real one as an ignored positional; on a non-TTY stdin the installer then
|
|
8
|
+
* hung on the interactive prompt. Now a bare `am_live_*` positional is rescued
|
|
9
|
+
* as the key (with a warning), and an empty `--api-key=` is reported clearly.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export function parseInstallArgs(args) {
|
|
13
|
+
const flags = {
|
|
14
|
+
targets: null,
|
|
15
|
+
all: args.includes("--all"),
|
|
16
|
+
project: args.includes("--project"),
|
|
17
|
+
apiKey: null,
|
|
18
|
+
librechat: null,
|
|
19
|
+
// Legacy single-flag shortcuts (kept for backward compat)
|
|
20
|
+
legacyClaude: args.includes("--claude"),
|
|
21
|
+
legacyCodex: args.includes("--codex"),
|
|
22
|
+
legacyCursor: args.includes("--cursor"),
|
|
23
|
+
};
|
|
24
|
+
const warnings = [];
|
|
25
|
+
let sawEmptyApiKeyFlag = false;
|
|
26
|
+
|
|
27
|
+
for (const arg of args) {
|
|
28
|
+
if (arg.startsWith("--target=")) {
|
|
29
|
+
flags.targets = arg
|
|
30
|
+
.slice("--target=".length)
|
|
31
|
+
.split(",")
|
|
32
|
+
.map((s) => s.trim())
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
} else if (arg.startsWith("--api-key=")) {
|
|
35
|
+
flags.apiKey = arg.slice("--api-key=".length);
|
|
36
|
+
if (!flags.apiKey) sawEmptyApiKeyFlag = true;
|
|
37
|
+
} else if (arg.startsWith("--librechat=")) {
|
|
38
|
+
flags.librechat = arg.slice("--librechat=".length);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Support `--api-key VALUE` (space-separated) too.
|
|
43
|
+
const consumedPositionals = new Set();
|
|
44
|
+
const keyIdx = args.indexOf("--api-key");
|
|
45
|
+
if (keyIdx !== -1 && args[keyIdx + 1] && !args[keyIdx + 1].startsWith("--")) {
|
|
46
|
+
flags.apiKey = args[keyIdx + 1];
|
|
47
|
+
consumedPositionals.add(keyIdx + 1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Rescue: a bare `am_live_*` positional when no usable key was parsed —
|
|
51
|
+
// the classic `--api-key= <key>` typo (space around the `=`).
|
|
52
|
+
if (!flags.apiKey) {
|
|
53
|
+
const idx = args.findIndex(
|
|
54
|
+
(a, i) => !a.startsWith("--") && !consumedPositionals.has(i) && /^am_live_/.test(a),
|
|
55
|
+
);
|
|
56
|
+
if (idx !== -1) {
|
|
57
|
+
flags.apiKey = args[idx];
|
|
58
|
+
warnings.push(
|
|
59
|
+
"interpreted a bare argument as the API key — write it as --api-key=<key> (no space around \"=\")",
|
|
60
|
+
);
|
|
61
|
+
} else if (sawEmptyApiKeyFlag) {
|
|
62
|
+
warnings.push("--api-key= was given with no value — ignoring it");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { flags, warnings };
|
|
67
|
+
}
|
package/bin/lib/parse.mjs
CHANGED
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
* 1. plain JSON.parse
|
|
11
11
|
* 2. JSON.parse after doubling lone backslashes (those not starting a valid escape)
|
|
12
12
|
* 3. JSON.parse after also escaping raw control chars that appear *inside* strings
|
|
13
|
-
* 4. caller falls back to field extraction (extractFields) as a last resort
|
|
14
13
|
*/
|
|
15
14
|
|
|
16
15
|
/** Double backslashes that aren't part of a valid JSON escape sequence. */
|
|
@@ -82,67 +81,3 @@ export function lenientParse(text) {
|
|
|
82
81
|
return { ok: false, error: lastErr };
|
|
83
82
|
}
|
|
84
83
|
|
|
85
|
-
/**
|
|
86
|
-
* Pull the first balanced JSON object out of free text — handles ```json
|
|
87
|
-
* fences and objects embedded in prose. Returns the parsed object or null.
|
|
88
|
-
*/
|
|
89
|
-
export function extractJsonObject(text) {
|
|
90
|
-
if (typeof text !== "string") return null;
|
|
91
|
-
|
|
92
|
-
// Prefer a fenced ```json block if present.
|
|
93
|
-
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
94
|
-
const candidates = [];
|
|
95
|
-
if (fence) candidates.push(fence[1]);
|
|
96
|
-
|
|
97
|
-
// Then any balanced {...} span (greedy from first { to matching }).
|
|
98
|
-
const start = text.indexOf("{");
|
|
99
|
-
if (start !== -1) {
|
|
100
|
-
let depth = 0;
|
|
101
|
-
let inStr = false;
|
|
102
|
-
let esc = false;
|
|
103
|
-
for (let i = start; i < text.length; i++) {
|
|
104
|
-
const ch = text[i];
|
|
105
|
-
if (esc) { esc = false; continue; }
|
|
106
|
-
if (ch === "\\") { esc = true; continue; }
|
|
107
|
-
if (ch === '"') { inStr = !inStr; continue; }
|
|
108
|
-
if (inStr) continue;
|
|
109
|
-
if (ch === "{") depth++;
|
|
110
|
-
else if (ch === "}") {
|
|
111
|
-
depth--;
|
|
112
|
-
if (depth === 0) {
|
|
113
|
-
candidates.push(text.slice(start, i + 1));
|
|
114
|
-
break;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
for (const c of candidates) {
|
|
121
|
-
const parsed = lenientParse(c);
|
|
122
|
-
if (parsed.ok && parsed.value && typeof parsed.value === "object") {
|
|
123
|
-
return parsed.value;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
return null;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Last-resort field extraction: pull `"key": "..."` string values from raw text
|
|
131
|
-
* when the whole payload won't parse. Only handles flat string fields.
|
|
132
|
-
*/
|
|
133
|
-
export function extractFields(text, keys) {
|
|
134
|
-
const out = {};
|
|
135
|
-
if (typeof text !== "string") return out;
|
|
136
|
-
for (const key of keys) {
|
|
137
|
-
const re = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`);
|
|
138
|
-
const m = text.match(re);
|
|
139
|
-
if (m) {
|
|
140
|
-
try {
|
|
141
|
-
out[key] = JSON.parse(`"${m[1]}"`);
|
|
142
|
-
} catch {
|
|
143
|
-
out[key] = m[1];
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
return out;
|
|
148
|
-
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System-environment probes shared by the installer and `agentrelay doctor`.
|
|
3
|
+
*
|
|
4
|
+
* These exist because the failure modes they detect are exactly the ones that
|
|
5
|
+
* made "successful" installs produce `command not found` in the field:
|
|
6
|
+
* - npx prepends its ephemeral cache .bin to PATH, shadowing (or faking) a
|
|
7
|
+
* real global install for the lifetime of the npx process
|
|
8
|
+
* - npm's global prefix may not be writable (system Node → EACCES)
|
|
9
|
+
* - ~/.npm may contain root-owned files (old sudo npm) which kills BOTH
|
|
10
|
+
* `npm install -g` and npx itself
|
|
11
|
+
* - npm's global bin dir may not be on the user's PATH
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, realpathSync, accessSync, statSync, constants } from "fs";
|
|
15
|
+
import { homedir } from "os";
|
|
16
|
+
import { join, sep, dirname } from "path";
|
|
17
|
+
import { execSync } from "child_process";
|
|
18
|
+
|
|
19
|
+
/** Where a command actually resolves on this PATH, or null. */
|
|
20
|
+
export function resolveCommandPath(cmd) {
|
|
21
|
+
try {
|
|
22
|
+
const probe = process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`;
|
|
23
|
+
const out = execSync(probe, { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
24
|
+
return out.split("\n")[0].trim() || null;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function realpathSafe(p) {
|
|
31
|
+
try {
|
|
32
|
+
return realpathSync(p);
|
|
33
|
+
} catch {
|
|
34
|
+
return p;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* True when a resolved bin path is an ephemeral npx/npm-exec shim (or a copy
|
|
40
|
+
* inside the running package itself), not a real install. A dangling symlink
|
|
41
|
+
* also counts as not-a-usable-install.
|
|
42
|
+
*/
|
|
43
|
+
export function isEphemeralBin(binPath, pkgRoot) {
|
|
44
|
+
if (!binPath) return false;
|
|
45
|
+
let real = binPath;
|
|
46
|
+
try {
|
|
47
|
+
real = realpathSync(binPath);
|
|
48
|
+
} catch {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
return (
|
|
52
|
+
real.includes(`${sep}_npx${sep}`) ||
|
|
53
|
+
binPath.includes(`${sep}_npx${sep}`) ||
|
|
54
|
+
(pkgRoot ? real.startsWith(realpathSafe(pkgRoot)) : false)
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** npm's global prefix (e.g. /opt/homebrew, %AppData%\npm), or null. */
|
|
59
|
+
export function npmGlobalPrefix() {
|
|
60
|
+
try {
|
|
61
|
+
const out = execSync("npm prefix -g", { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
|
|
62
|
+
return out.trim() || null;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The dir npm links global bins into for a given prefix. */
|
|
69
|
+
export function npmGlobalBinDir(prefix) {
|
|
70
|
+
if (!prefix) return null;
|
|
71
|
+
return process.platform === "win32" ? prefix : join(prefix, "bin");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Path of this package's global bin shim under a prefix (win32 → .cmd). */
|
|
75
|
+
export function globalBinShimPath(prefix, binName) {
|
|
76
|
+
const dir = npmGlobalBinDir(prefix);
|
|
77
|
+
if (!dir) return null;
|
|
78
|
+
return join(dir, process.platform === "win32" ? `${binName}.cmd` : binName);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function isDirWritable(dir) {
|
|
82
|
+
if (!dir) return false;
|
|
83
|
+
try {
|
|
84
|
+
accessSync(dir, constants.W_OK);
|
|
85
|
+
return true;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Can `npm install -g` write where it needs to? Checks the global
|
|
93
|
+
* node_modules (or the nearest existing parent) under the prefix.
|
|
94
|
+
*/
|
|
95
|
+
export function globalPrefixWritable(prefix) {
|
|
96
|
+
if (!prefix) return false;
|
|
97
|
+
const nm =
|
|
98
|
+
process.platform === "win32" ? join(prefix, "node_modules") : join(prefix, "lib", "node_modules");
|
|
99
|
+
let probe = nm;
|
|
100
|
+
while (probe && !existsSync(probe)) {
|
|
101
|
+
const parent = dirname(probe);
|
|
102
|
+
if (parent === probe) break;
|
|
103
|
+
probe = parent;
|
|
104
|
+
}
|
|
105
|
+
return isDirWritable(probe);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* True when ~/.npm contains ANY entry owned by a different uid (the classic
|
|
110
|
+
* root-owned-cache breakage from an old `sudo npm` — the offending files are
|
|
111
|
+
* usually buried deep in _cacache, with user-owned parents). posix only; on
|
|
112
|
+
* win32 returns false. Uses `find -not -uid` with an early exit; falls back
|
|
113
|
+
* to a top-level stat if find is unavailable.
|
|
114
|
+
*/
|
|
115
|
+
export function npmCacheOwnedByOther() {
|
|
116
|
+
if (process.platform === "win32" || typeof process.getuid !== "function") return false;
|
|
117
|
+
const npmDir = join(homedir(), ".npm");
|
|
118
|
+
if (!existsSync(npmDir)) return false;
|
|
119
|
+
const uid = process.getuid();
|
|
120
|
+
try {
|
|
121
|
+
const out = execSync(`find "${npmDir}" -not -uid ${uid} -print 2>/dev/null | head -1`, {
|
|
122
|
+
encoding: "utf-8",
|
|
123
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
124
|
+
});
|
|
125
|
+
return out.trim().length > 0;
|
|
126
|
+
} catch {
|
|
127
|
+
try {
|
|
128
|
+
return statSync(npmDir).uid !== uid;
|
|
129
|
+
} catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Is a directory on the current PATH (exact component match)? */
|
|
136
|
+
export function dirOnPath(dir, pathVar = process.env.PATH || "") {
|
|
137
|
+
if (!dir) return false;
|
|
138
|
+
const delim = process.platform === "win32" ? ";" : ":";
|
|
139
|
+
const norm = (p) => p.replace(/[/\\]+$/, "");
|
|
140
|
+
return pathVar.split(delim).map(norm).includes(norm(dir));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The official npm user-level-prefix recipe for EACCES on the global prefix. */
|
|
144
|
+
export const USER_PREFIX_RECIPE = [
|
|
145
|
+
"mkdir -p ~/.npm-global",
|
|
146
|
+
"npm config set prefix '~/.npm-global'",
|
|
147
|
+
'# add to your shell profile: export PATH="$HOME/.npm-global/bin:$PATH"',
|
|
148
|
+
"npm i -g agentic-relay",
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
/** The fix for a root-owned ~/.npm cache (also fixes npx itself). */
|
|
152
|
+
export const CACHE_CHOWN_RECIPE = 'sudo chown -R $(id -u):$(id -g) "$HOME/.npm"';
|