premanmcp 0.7.0 → 0.8.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/bin/account.js +223 -0
- package/bin/changed.js +175 -0
- package/bin/cli.js +61 -16
- package/bin/connect.js +33 -9
- package/bin/desktop.js +214 -0
- package/bin/detect.js +378 -0
- package/bin/hook.js +165 -0
- package/bin/integrations.js +59 -21
- package/bin/progress.js +110 -0
- package/bin/shared.js +52 -6
- package/bin/status.js +210 -0
- package/bin/verify.js +701 -0
- package/package.json +4 -2
package/bin/desktop.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `preman install-desktop` — download and install the PreMan desktop app.
|
|
3
|
+
*
|
|
4
|
+
* Version-less asset names are used on purpose: `/releases/latest/download/
|
|
5
|
+
* PreMan-mac-arm64.dmg` keeps working after every release, where a versioned URL
|
|
6
|
+
* would 404 the day the next build ships. The live tag is read from the GitHub
|
|
7
|
+
* API only to report what was installed.
|
|
8
|
+
*
|
|
9
|
+
* Arch detection is trivial here in a way it is not on the website: `uname -m`
|
|
10
|
+
* is authoritative, so there is no need for the WebGL guessing the download page
|
|
11
|
+
* has to do.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawnSync } from "node:child_process";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
|
|
20
|
+
import { cliInvocation, makeArgs } from "./shared.js";
|
|
21
|
+
|
|
22
|
+
export const DESKTOP_HELP = `
|
|
23
|
+
Install-desktop options:
|
|
24
|
+
--arch <arm64|x64> Override architecture detection
|
|
25
|
+
--dest <dir> Install directory. Defaults to /Applications
|
|
26
|
+
--keep-dmg Leave the downloaded disk image in place
|
|
27
|
+
--print-url Print the resolved download URL and exit
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
const RELEASES_BASE = "https://github.com/PreMan-Inc/PreMan-Desktop/releases";
|
|
31
|
+
const RELEASE_API = "https://api.github.com/repos/PreMan-Inc/PreMan-Desktop/releases/latest";
|
|
32
|
+
const APP_NAME = "PreMan.app";
|
|
33
|
+
const DOWNLOAD_TIMEOUT_MS = 300_000;
|
|
34
|
+
|
|
35
|
+
export function dmgUrl(arch) {
|
|
36
|
+
return `${RELEASES_BASE}/latest/download/PreMan-mac-${arch}.dmg`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function detectArch() {
|
|
40
|
+
const machine = os.machine ? os.machine() : os.arch();
|
|
41
|
+
if (machine === "arm64" || machine === "aarch64") return "arm64";
|
|
42
|
+
if (machine === "x86_64" || machine === "x64") return "x64";
|
|
43
|
+
// Rosetta reports x86_64 for the Node binary itself, so ask sysctl whether the
|
|
44
|
+
// hardware is actually Apple silicon before handing over an Intel build.
|
|
45
|
+
const probe = spawnSync("sysctl", ["-n", "hw.optional.arm64"], { encoding: "utf8" });
|
|
46
|
+
if (probe.status === 0 && probe.stdout.trim() === "1") return "arm64";
|
|
47
|
+
return "x64";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function fetchLatestRelease() {
|
|
51
|
+
try {
|
|
52
|
+
const resp = await fetch(RELEASE_API, {
|
|
53
|
+
headers: { Accept: "application/vnd.github+json", "User-Agent": "premanmcp-cli" },
|
|
54
|
+
});
|
|
55
|
+
if (!resp.ok) return null;
|
|
56
|
+
return await resp.json();
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The sha512 and size electron-builder published for this architecture's disk
|
|
64
|
+
* image, or null if the manifest could not be read.
|
|
65
|
+
*
|
|
66
|
+
* `latest-mac.yml` is the only integrity signal available without our own signing
|
|
67
|
+
* infrastructure. It keys entries by the *versioned* filename
|
|
68
|
+
* (`PreMan-0.3.79-arm64.dmg`), while we download the version-less alias, so the
|
|
69
|
+
* version has to be read out of the manifest to find the right entry. The alias
|
|
70
|
+
* is a copy of the same artifact, which the size check confirms before the digest
|
|
71
|
+
* is trusted.
|
|
72
|
+
*/
|
|
73
|
+
export function parseMacManifest(text, arch) {
|
|
74
|
+
const version = text.match(/^version:\s*(\S+)/m)?.[1];
|
|
75
|
+
if (!version) return null;
|
|
76
|
+
const wanted = `PreMan-${version}-${arch}.dmg`;
|
|
77
|
+
const lines = String(text).split("\n");
|
|
78
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
79
|
+
if (!lines[i].includes(wanted)) continue;
|
|
80
|
+
let sha512 = null;
|
|
81
|
+
let size = null;
|
|
82
|
+
for (let j = i + 1; j < Math.min(lines.length, i + 5); j += 1) {
|
|
83
|
+
if (/^\s*-\s*url:/.test(lines[j])) break;
|
|
84
|
+
sha512 ??= lines[j].match(/sha512:\s*([A-Za-z0-9+/=]+)/)?.[1] ?? null;
|
|
85
|
+
size ??= lines[j].match(/size:\s*(\d+)/)?.[1] ?? null;
|
|
86
|
+
}
|
|
87
|
+
if (sha512) return { version, sha512, size: size ? Number(size) : null, asset: wanted };
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function expectedDigest(arch) {
|
|
93
|
+
try {
|
|
94
|
+
const resp = await fetch(`${RELEASES_BASE}/latest/download/latest-mac.yml`, {
|
|
95
|
+
headers: { "User-Agent": "premanmcp-cli" },
|
|
96
|
+
});
|
|
97
|
+
if (!resp.ok) return null;
|
|
98
|
+
return parseMacManifest(await resp.text(), arch);
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function download(url, destination) {
|
|
105
|
+
const controller = new AbortController();
|
|
106
|
+
const timer = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
|
|
107
|
+
try {
|
|
108
|
+
const resp = await fetch(url, { signal: controller.signal, redirect: "follow" });
|
|
109
|
+
if (!resp.ok) throw new Error(`download failed: ${resp.status} ${url}`);
|
|
110
|
+
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
111
|
+
writeFileSync(destination, buffer);
|
|
112
|
+
return buffer.length;
|
|
113
|
+
} finally {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function sha512Base64(filePath) {
|
|
119
|
+
return createHash("sha512").update(readFileSync(filePath)).digest("base64");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function run(command, commandArgs) {
|
|
123
|
+
const result = spawnSync(command, commandArgs, { encoding: "utf8" });
|
|
124
|
+
if (result.status !== 0) {
|
|
125
|
+
throw new Error(`${command} ${commandArgs.join(" ")} failed: ${result.stderr?.trim() || result.status}`);
|
|
126
|
+
}
|
|
127
|
+
return result.stdout || "";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function mountedVolume(hdiutilOutput) {
|
|
131
|
+
const match = hdiutilOutput.match(/(\/Volumes\/[^\n\t]+)/);
|
|
132
|
+
if (!match) throw new Error("could not find the mounted volume in hdiutil output");
|
|
133
|
+
return match[1].trim();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function installDesktopCommand(commandArgs = []) {
|
|
137
|
+
const args = makeArgs(commandArgs);
|
|
138
|
+
|
|
139
|
+
if (process.platform !== "darwin") {
|
|
140
|
+
process.stdout.write(
|
|
141
|
+
`The PreMan desktop app is macOS-only right now.\n` +
|
|
142
|
+
`Downloads for other platforms: ${RELEASES_BASE}/latest\n`
|
|
143
|
+
);
|
|
144
|
+
return { state: "unsupported", platform: process.platform };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const arch = args.value("--arch", "") || detectArch();
|
|
148
|
+
if (!["arm64", "x64"].includes(arch)) {
|
|
149
|
+
throw new Error(`unsupported --arch ${arch}; expected arm64 or x64`);
|
|
150
|
+
}
|
|
151
|
+
const url = dmgUrl(arch);
|
|
152
|
+
|
|
153
|
+
if (args.has("--print-url")) {
|
|
154
|
+
process.stdout.write(`${url}\n`);
|
|
155
|
+
return { state: "printed", url, arch };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const release = await fetchLatestRelease();
|
|
159
|
+
const version = String(release?.tag_name || "").replace(/^v/i, "") || "latest";
|
|
160
|
+
const destination = args.value("--dest", "/Applications");
|
|
161
|
+
|
|
162
|
+
process.stdout.write(`Downloading PreMan ${version} (${arch})…\n`);
|
|
163
|
+
const workDir = mkdtempSync(path.join(os.tmpdir(), "preman-desktop-"));
|
|
164
|
+
const dmgPath = path.join(workDir, `PreMan-mac-${arch}.dmg`);
|
|
165
|
+
let mounted = null;
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
const bytes = await download(url, dmgPath);
|
|
169
|
+
process.stdout.write(` ${(bytes / 1024 / 1024).toFixed(1)} MB\n`);
|
|
170
|
+
|
|
171
|
+
const expected = await expectedDigest(arch);
|
|
172
|
+
if (expected) {
|
|
173
|
+
if (expected.size && expected.size !== bytes) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`size mismatch: downloaded ${bytes} bytes but the release manifest ` +
|
|
176
|
+
`records ${expected.size} for ${expected.asset}. Nothing was installed.`
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (sha512Base64(dmgPath) !== expected.sha512) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
"digest mismatch: the downloaded disk image does not match the checksum " +
|
|
182
|
+
"published with the release. Nothing was installed."
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
process.stdout.write(` checksum verified against ${expected.asset}\n`);
|
|
186
|
+
} else {
|
|
187
|
+
process.stdout.write(" checksum unavailable for this release; skipping verification\n");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const attach = run("hdiutil", ["attach", dmgPath, "-nobrowse", "-quiet", "-readonly"]);
|
|
191
|
+
mounted = mountedVolume(attach || run("hdiutil", ["info"]));
|
|
192
|
+
|
|
193
|
+
const source = path.join(mounted, APP_NAME);
|
|
194
|
+
if (!existsSync(source)) {
|
|
195
|
+
throw new Error(`${APP_NAME} not found inside the disk image at ${mounted}`);
|
|
196
|
+
}
|
|
197
|
+
const target = path.join(destination, APP_NAME);
|
|
198
|
+
if (existsSync(target)) rmSync(target, { recursive: true, force: true });
|
|
199
|
+
run("cp", ["-R", source, target]);
|
|
200
|
+
chmodSync(target, 0o755);
|
|
201
|
+
|
|
202
|
+
process.stdout.write(
|
|
203
|
+
`\nInstalled ${target}\n\nNext: open PreMan and it will pair with this account.\n` +
|
|
204
|
+
`Then run \`${cliInvocation()} status\` to see endpoint health.\n`
|
|
205
|
+
);
|
|
206
|
+
return { state: "installed", version, arch, path: target };
|
|
207
|
+
} finally {
|
|
208
|
+
if (mounted) {
|
|
209
|
+
spawnSync("hdiutil", ["detach", mounted, "-quiet"], { encoding: "utf8" });
|
|
210
|
+
}
|
|
211
|
+
if (!args.has("--keep-dmg")) rmSync(workDir, { recursive: true, force: true });
|
|
212
|
+
else process.stdout.write(`Disk image kept at ${dmgPath}\n`);
|
|
213
|
+
}
|
|
214
|
+
}
|
package/bin/detect.js
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local target detection.
|
|
3
|
+
*
|
|
4
|
+
* At `git push` time the developer's own running app is the disposable
|
|
5
|
+
* environment, so PreMan can test it with writes allowed. Finding it must take
|
|
6
|
+
* zero configuration, which means guessing -- and a wrong guess is worse than no
|
|
7
|
+
* guess, because it would test some unrelated service listening on port 3000 and
|
|
8
|
+
* report the results as if they were this repo's.
|
|
9
|
+
*
|
|
10
|
+
* So detection is two separate steps: propose candidates from repo signals, then
|
|
11
|
+
* *prove* one is this app by intersecting its live routes with the inventory we
|
|
12
|
+
* scanned. Nothing is tested until a candidate is confirmed.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
// Ordered by how much the signal actually tells us. An explicit port in .env beats
|
|
19
|
+
// a framework default, which beats a guess.
|
|
20
|
+
export const SOURCE_RANK = {
|
|
21
|
+
env_file: 100,
|
|
22
|
+
package_script: 80,
|
|
23
|
+
compose: 70,
|
|
24
|
+
procfile: 60,
|
|
25
|
+
framework_default: 40,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const ENV_FILES = [".env", ".env.local", ".env.development", ".env.dev"];
|
|
29
|
+
const ENV_URL_KEYS = ["API_URL", "API_BASE_URL", "BASE_URL", "PREMAN_TARGET_URL", "APP_URL"];
|
|
30
|
+
const ENV_PORT_KEYS = ["PORT", "API_PORT", "SERVER_PORT", "APP_PORT", "HTTP_PORT"];
|
|
31
|
+
|
|
32
|
+
const FRAMEWORK_DEFAULTS = [
|
|
33
|
+
{ file: "next.config.js", port: 3000, stack: "next" },
|
|
34
|
+
{ file: "next.config.mjs", port: 3000, stack: "next" },
|
|
35
|
+
{ file: "next.config.ts", port: 3000, stack: "next" },
|
|
36
|
+
{ file: "nest-cli.json", port: 3000, stack: "nest" },
|
|
37
|
+
{ file: "manage.py", port: 8000, stack: "django" },
|
|
38
|
+
{ file: "Gemfile", port: 3000, stack: "rails" },
|
|
39
|
+
{ file: "pom.xml", port: 8080, stack: "spring" },
|
|
40
|
+
{ file: "build.gradle", port: 8080, stack: "spring" },
|
|
41
|
+
{ file: "go.mod", port: 8080, stack: "go" },
|
|
42
|
+
{ file: "pyproject.toml", port: 8000, stack: "python" },
|
|
43
|
+
{ file: "requirements.txt", port: 8000, stack: "python" },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function readIfExists(filePath) {
|
|
47
|
+
try {
|
|
48
|
+
return existsSync(filePath) ? readFileSync(filePath, "utf8") : "";
|
|
49
|
+
} catch {
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Minimal dotenv reader: KEY=value, ignoring comments, exports, and quotes. */
|
|
55
|
+
export function parseEnvFile(text) {
|
|
56
|
+
const out = {};
|
|
57
|
+
for (const rawLine of String(text || "").split("\n")) {
|
|
58
|
+
const line = rawLine.trim();
|
|
59
|
+
if (!line || line.startsWith("#")) continue;
|
|
60
|
+
const withoutExport = line.startsWith("export ") ? line.slice(7).trim() : line;
|
|
61
|
+
const eq = withoutExport.indexOf("=");
|
|
62
|
+
if (eq <= 0) continue;
|
|
63
|
+
const key = withoutExport.slice(0, eq).trim();
|
|
64
|
+
let value = withoutExport.slice(eq + 1).trim();
|
|
65
|
+
const quote = value[0];
|
|
66
|
+
if ((quote === '"' || quote === "'") && value.endsWith(quote) && value.length > 1) {
|
|
67
|
+
value = value.slice(1, -1);
|
|
68
|
+
} else {
|
|
69
|
+
const hash = value.indexOf(" #");
|
|
70
|
+
if (hash >= 0) value = value.slice(0, hash).trim();
|
|
71
|
+
}
|
|
72
|
+
if (key) out[key] = value;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalisePort(value) {
|
|
78
|
+
const port = Number.parseInt(String(value ?? "").trim(), 10);
|
|
79
|
+
return Number.isInteger(port) && port > 0 && port < 65536 ? port : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function loopbackUrl(port) {
|
|
83
|
+
return `http://127.0.0.1:${port}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isLoopback(url) {
|
|
87
|
+
try {
|
|
88
|
+
const { hostname } = new URL(url);
|
|
89
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "0.0.0.0";
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function addCandidate(map, { url, port, source, detail }) {
|
|
96
|
+
if (!url && port) url = loopbackUrl(port);
|
|
97
|
+
if (!url) return;
|
|
98
|
+
// Only ever propose loopback. A detected staging or production URL must never
|
|
99
|
+
// be silently promoted into a write-enabled target.
|
|
100
|
+
if (!isLoopback(url)) return;
|
|
101
|
+
const key = url.replace(/\/+$/, "");
|
|
102
|
+
const rank = SOURCE_RANK[source] ?? 0;
|
|
103
|
+
const existing = map.get(key);
|
|
104
|
+
if (existing && existing.rank >= rank) {
|
|
105
|
+
if (!existing.signals.includes(detail)) existing.signals.push(detail);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
map.set(key, {
|
|
109
|
+
url: key,
|
|
110
|
+
source,
|
|
111
|
+
rank,
|
|
112
|
+
signals: existing ? [...existing.signals, detail] : [detail],
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function fromEnvFiles(root, map) {
|
|
117
|
+
for (const name of ENV_FILES) {
|
|
118
|
+
const text = readIfExists(path.join(root, name));
|
|
119
|
+
if (!text) continue;
|
|
120
|
+
const env = parseEnvFile(text);
|
|
121
|
+
for (const key of ENV_URL_KEYS) {
|
|
122
|
+
if (env[key]) addCandidate(map, { url: env[key], source: "env_file", detail: `${name}:${key}` });
|
|
123
|
+
}
|
|
124
|
+
for (const key of ENV_PORT_KEYS) {
|
|
125
|
+
const port = normalisePort(env[key]);
|
|
126
|
+
if (port) addCandidate(map, { port, source: "env_file", detail: `${name}:${key}` });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function fromPackageScripts(root, map) {
|
|
132
|
+
const text = readIfExists(path.join(root, "package.json"));
|
|
133
|
+
if (!text) return;
|
|
134
|
+
let pkg;
|
|
135
|
+
try {
|
|
136
|
+
pkg = JSON.parse(text);
|
|
137
|
+
} catch {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const scripts = pkg.scripts || {};
|
|
141
|
+
for (const name of ["dev", "start", "serve", "start:dev"]) {
|
|
142
|
+
const script = scripts[name];
|
|
143
|
+
if (typeof script !== "string") continue;
|
|
144
|
+
const flag = script.match(/(?:-p|--port|PORT)[= ](\d{2,5})/);
|
|
145
|
+
if (flag) {
|
|
146
|
+
const port = normalisePort(flag[1]);
|
|
147
|
+
if (port) addCandidate(map, { port, source: "package_script", detail: `package.json:scripts.${name}` });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function fromCompose(root, map) {
|
|
153
|
+
for (const name of ["docker-compose.yaml", "docker-compose.yml", "compose.yaml", "compose.yml"]) {
|
|
154
|
+
const text = readIfExists(path.join(root, name));
|
|
155
|
+
if (!text) continue;
|
|
156
|
+
// Published host ports only: "8080:8080", "127.0.0.1:8080:8080", "- 3000:3000".
|
|
157
|
+
for (const match of text.matchAll(/["'\s-]\s*(?:\d+\.\d+\.\d+\.\d+:)?(\d{2,5}):(\d{2,5})/g)) {
|
|
158
|
+
const port = normalisePort(match[1]);
|
|
159
|
+
if (port) addCandidate(map, { port, source: "compose", detail: `${name}:ports` });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function fromProcfile(root, map) {
|
|
165
|
+
const text = readIfExists(path.join(root, "Procfile"));
|
|
166
|
+
if (!text) return;
|
|
167
|
+
const match = text.match(/(?:-p|--port|\$?\{?PORT\}?[= ])[= ]?(\d{2,5})/);
|
|
168
|
+
const port = normalisePort(match?.[1]);
|
|
169
|
+
if (port) addCandidate(map, { port, source: "procfile", detail: "Procfile" });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function fromFrameworkDefaults(root, map) {
|
|
173
|
+
let entries = [];
|
|
174
|
+
try {
|
|
175
|
+
entries = readdirSync(root);
|
|
176
|
+
} catch {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const present = new Set(entries);
|
|
180
|
+
for (const { file, port, stack } of FRAMEWORK_DEFAULTS) {
|
|
181
|
+
if (present.has(file)) {
|
|
182
|
+
addCandidate(map, { port, source: "framework_default", detail: `${file} (${stack} default)` });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Candidate local base URLs, most trustworthy first. Proposals only -- none of
|
|
189
|
+
* these is safe to test against until `confirmCandidate` proves it is this app.
|
|
190
|
+
*/
|
|
191
|
+
export function detectCandidates(root = process.cwd(), { extraPorts = [] } = {}) {
|
|
192
|
+
const map = new Map();
|
|
193
|
+
fromEnvFiles(root, map);
|
|
194
|
+
fromPackageScripts(root, map);
|
|
195
|
+
fromCompose(root, map);
|
|
196
|
+
fromProcfile(root, map);
|
|
197
|
+
fromFrameworkDefaults(root, map);
|
|
198
|
+
for (const port of extraPorts) {
|
|
199
|
+
const parsed = normalisePort(port);
|
|
200
|
+
if (parsed) addCandidate(map, { port: parsed, source: "env_file", detail: "--port" });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return [...map.values()].sort((a, b) => b.rank - a.rank || a.url.localeCompare(b.url));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const SPEC_PATHS = ["/openapi.json", "/swagger.json", "/v3/api-docs", "/openapi.yaml"];
|
|
207
|
+
|
|
208
|
+
async function fetchJson(url, timeoutMs) {
|
|
209
|
+
const controller = new AbortController();
|
|
210
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
211
|
+
try {
|
|
212
|
+
const resp = await fetch(url, { signal: controller.signal, redirect: "manual" });
|
|
213
|
+
if (!resp.ok) return null;
|
|
214
|
+
const text = await resp.text();
|
|
215
|
+
try {
|
|
216
|
+
return JSON.parse(text);
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
} finally {
|
|
223
|
+
clearTimeout(timer);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function probe(url, timeoutMs) {
|
|
228
|
+
const controller = new AbortController();
|
|
229
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
230
|
+
try {
|
|
231
|
+
const resp = await fetch(url, { signal: controller.signal, redirect: "manual" });
|
|
232
|
+
return { reachable: true, status: resp.status };
|
|
233
|
+
} catch {
|
|
234
|
+
return { reachable: false, status: 0 };
|
|
235
|
+
} finally {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function normalisePathTemplate(value) {
|
|
241
|
+
return String(value || "")
|
|
242
|
+
.trim()
|
|
243
|
+
.replace(/\{[^}]*\}/g, "{}")
|
|
244
|
+
.replace(/:[A-Za-z_][A-Za-z0-9_]*/g, "{}")
|
|
245
|
+
.replace(/\/+$/, "")
|
|
246
|
+
.toLowerCase() || "/";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Route keys the live app advertises through its generated spec, if it has one. */
|
|
250
|
+
async function liveSpecRoutes(baseUrl, timeoutMs) {
|
|
251
|
+
for (const specPath of SPEC_PATHS) {
|
|
252
|
+
const spec = await fetchJson(`${baseUrl}${specPath}`, timeoutMs);
|
|
253
|
+
const paths = spec && typeof spec === "object" ? spec.paths : null;
|
|
254
|
+
if (!paths || typeof paths !== "object") continue;
|
|
255
|
+
const keys = new Set();
|
|
256
|
+
for (const [rawPath, methods] of Object.entries(paths)) {
|
|
257
|
+
if (!methods || typeof methods !== "object") continue;
|
|
258
|
+
for (const method of Object.keys(methods)) {
|
|
259
|
+
keys.add(`${method.toUpperCase()} ${normalisePathTemplate(rawPath)}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (keys.size) return { keys, via: specPath };
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export const CONFIRM_MIN_OVERLAP = 0.5;
|
|
268
|
+
export const CONFIRM_MIN_ROUTES = 2;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Decide whether a reachable candidate really is the repo under test.
|
|
272
|
+
*
|
|
273
|
+
* With a live spec we require a real overlap with the scanned inventory. Without
|
|
274
|
+
* one we fall back to probing a couple of known routes and requiring that they
|
|
275
|
+
* do not 404 -- weaker, so it is reported as `weak` confidence and the caller
|
|
276
|
+
* decides whether that is enough.
|
|
277
|
+
*/
|
|
278
|
+
export async function confirmCandidate(candidate, inventory, { timeoutMs = 2000 } = {}) {
|
|
279
|
+
const baseUrl = candidate.url.replace(/\/+$/, "");
|
|
280
|
+
const root = await probe(`${baseUrl}/`, timeoutMs);
|
|
281
|
+
if (!root.reachable) {
|
|
282
|
+
return { ...candidate, confirmed: false, reason: "nothing listening" };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const expected = new Set(
|
|
286
|
+
(inventory || [])
|
|
287
|
+
.map((ep) => {
|
|
288
|
+
const method = String(ep.method || "GET").toUpperCase();
|
|
289
|
+
const template = normalisePathTemplate(ep.path_template || ep.path || "");
|
|
290
|
+
return template ? `${method} ${template}` : null;
|
|
291
|
+
})
|
|
292
|
+
.filter(Boolean)
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
if (expected.size === 0) {
|
|
296
|
+
return {
|
|
297
|
+
...candidate,
|
|
298
|
+
confirmed: false,
|
|
299
|
+
reason: "no scanned inventory to match against",
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const live = await liveSpecRoutes(baseUrl, timeoutMs);
|
|
304
|
+
if (live) {
|
|
305
|
+
let matched = 0;
|
|
306
|
+
for (const key of expected) if (live.keys.has(key)) matched += 1;
|
|
307
|
+
const overlap = matched / expected.size;
|
|
308
|
+
if (matched >= CONFIRM_MIN_ROUTES && overlap >= CONFIRM_MIN_OVERLAP) {
|
|
309
|
+
return {
|
|
310
|
+
...candidate,
|
|
311
|
+
confirmed: true,
|
|
312
|
+
confidence: "spec",
|
|
313
|
+
matched,
|
|
314
|
+
expected: expected.size,
|
|
315
|
+
overlap: Number(overlap.toFixed(3)),
|
|
316
|
+
via: live.via,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
...candidate,
|
|
321
|
+
confirmed: false,
|
|
322
|
+
reason: `live spec at ${live.via} matches ${matched}/${expected.size} scanned routes; ` +
|
|
323
|
+
"this is probably a different service",
|
|
324
|
+
matched,
|
|
325
|
+
expected: expected.size,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// No spec. Probe a few GET routes and require that the app recognises them.
|
|
330
|
+
const probes = (inventory || [])
|
|
331
|
+
.filter((ep) => String(ep.method || "GET").toUpperCase() === "GET")
|
|
332
|
+
.slice(0, 3);
|
|
333
|
+
let recognised = 0;
|
|
334
|
+
for (const ep of probes) {
|
|
335
|
+
const concrete = String(ep.path_template || ep.path || "/").replace(/\{[^}]*\}/g, "1");
|
|
336
|
+
const result = await probe(`${baseUrl}${concrete.startsWith("/") ? "" : "/"}${concrete}`, timeoutMs);
|
|
337
|
+
if (result.reachable && result.status !== 404) recognised += 1;
|
|
338
|
+
}
|
|
339
|
+
if (probes.length && recognised === probes.length) {
|
|
340
|
+
return {
|
|
341
|
+
...candidate,
|
|
342
|
+
confirmed: true,
|
|
343
|
+
confidence: "weak",
|
|
344
|
+
matched: recognised,
|
|
345
|
+
expected: probes.length,
|
|
346
|
+
via: "route probe",
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
...candidate,
|
|
351
|
+
confirmed: false,
|
|
352
|
+
reason: probes.length
|
|
353
|
+
? `${recognised}/${probes.length} probed routes recognised`
|
|
354
|
+
: "no GET route available to probe",
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** First candidate that proves itself, or a structured miss. */
|
|
359
|
+
export async function resolveLocalTarget(
|
|
360
|
+
inventory,
|
|
361
|
+
{ root = process.cwd(), extraPorts = [], timeoutMs = 2000 } = {}
|
|
362
|
+
) {
|
|
363
|
+
const candidates = detectCandidates(root, { extraPorts });
|
|
364
|
+
if (!candidates.length) {
|
|
365
|
+
return { target: null, candidates: [], reason: "no local target signals in this repo" };
|
|
366
|
+
}
|
|
367
|
+
const attempts = [];
|
|
368
|
+
for (const candidate of candidates) {
|
|
369
|
+
const result = await confirmCandidate(candidate, inventory, { timeoutMs });
|
|
370
|
+
attempts.push(result);
|
|
371
|
+
if (result.confirmed) return { target: result, candidates: attempts };
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
target: null,
|
|
375
|
+
candidates: attempts,
|
|
376
|
+
reason: attempts[0]?.reason || "no candidate could be confirmed",
|
|
377
|
+
};
|
|
378
|
+
}
|