premanmcp 0.10.4 → 0.10.5
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 +37 -13
- package/bin/account.js +34 -0
- package/bin/cli.js +9 -2
- package/bin/connect.js +84 -15
- package/bin/hook.js +160 -21
- package/bin/integrations.js +86 -13
- package/bin/runner.js +29 -2
- package/bin/shared.js +66 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -64,21 +64,28 @@ and then retries from your home directory, where the override cannot reach it
|
|
|
64
64
|
first, then your agent. Agents you start in the overriding directory keep using its
|
|
65
65
|
backend, which is the point of the file.
|
|
66
66
|
|
|
67
|
-
Once linked, `connect`
|
|
67
|
+
Once linked, `connect` installs the git pre-push hook — so `git push` checks the endpoints
|
|
68
|
+
you touched — prints what is left, and stops. It asks nothing. Everything else it used to
|
|
69
|
+
run is its own command, because each one can fail on its own and none of them should hold
|
|
70
|
+
up a link that already worked:
|
|
68
71
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
```bash
|
|
73
|
+
preman endpoints discover # map this repo's endpoints
|
|
74
|
+
preman runner start --background # let PreMan apply fixes on this machine
|
|
75
|
+
preman github # or connect it in the dashboard
|
|
76
|
+
preman status # which of those are done
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`preman onboard` (or `setup`) is the prompted walk through all of it — sign in, coding
|
|
80
|
+
agent, endpoints, runner, GitHub, AWS, Slack — one question per step, `b` to go back, and a
|
|
81
|
+
summary at the end. `connect --guide` runs the old full pass inside connect itself:
|
|
82
|
+
discovery, a first test, the runner, the desktop app and the integration prompts.
|
|
77
83
|
|
|
78
84
|
Useful flags: `--agent cursor|claude-code|codex` skips the picker, `--project` writes
|
|
79
|
-
project-local config, `--print` shows the config without writing it, `--
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
project-local config, `--print` shows the config without writing it, `--no-hook` leaves
|
|
86
|
+
push testing alone, and `--no-guide` connects and nothing else. With `--guide`, `--yes`
|
|
87
|
+
takes every step's default without asking and `--no-runner` / `--no-desktop` /
|
|
88
|
+
`--no-integrations` skip one each.
|
|
82
89
|
|
|
83
90
|
`--yes` deliberately does *not* install the desktop app: that step's default is no, because
|
|
84
91
|
it downloads a hundred-odd megabytes and writes to `/Applications`. Run `install-desktop`
|
|
@@ -218,9 +225,26 @@ npm exec -y premanmcp@latest -- login # Create/login and generate a PreMan API
|
|
|
218
225
|
npm exec -y premanmcp@latest -- install # Cursor-only installer (legacy)
|
|
219
226
|
```
|
|
220
227
|
|
|
228
|
+
### Push testing
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
preman hook install # Write the pre-push hook (connect does this too)
|
|
232
|
+
preman hook status # Installed? And does the command in it still answer?
|
|
233
|
+
preman hook uninstall # Remove it, restoring any hook it replaced
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
The hook is generated shell that runs `preman verify --pre-push` and can only ever be
|
|
237
|
+
advisory: no backend, no credentials, a crash or a timeout all exit 0 with a notice. Before
|
|
238
|
+
writing it, `install` runs the command it is about to embed and requires an answer — a hook
|
|
239
|
+
holding a `preman` that belongs to another package prints `checks skipped` at every push and
|
|
240
|
+
looks installed forever. The embedded command is pinned to the version that wrote it rather
|
|
241
|
+
than `@latest`, so upgrading us never changes what your pushes run; re-run `hook install`
|
|
242
|
+
to move it. Set `PREMAN_HOOK_INVOCATION` to write a command of your own instead, and
|
|
243
|
+
`PREMAN_SKIP_HOOK=1` to silence the hook for a push.
|
|
244
|
+
|
|
221
245
|
### Runner
|
|
222
246
|
|
|
223
|
-
`connect` sets this up for you; these are for managing it afterwards.
|
|
247
|
+
`connect --guide` sets this up for you; these are for managing it afterwards.
|
|
224
248
|
|
|
225
249
|
```bash
|
|
226
250
|
preman runner status # Paired? Running?
|
package/bin/account.js
CHANGED
|
@@ -11,6 +11,7 @@ import { existsSync, rmSync } from "node:fs";
|
|
|
11
11
|
import os from "node:os";
|
|
12
12
|
|
|
13
13
|
import { detectCandidates } from "./detect.js";
|
|
14
|
+
import { hookStatus } from "./hook.js";
|
|
14
15
|
import {
|
|
15
16
|
CREDENTIALS_FILE,
|
|
16
17
|
backendUrl,
|
|
@@ -150,6 +151,39 @@ export async function doctorCommand(commandArgs = []) {
|
|
|
150
151
|
)
|
|
151
152
|
);
|
|
152
153
|
|
|
154
|
+
// An installed hook whose command no longer answers is the one failure here
|
|
155
|
+
// that reports itself as success everywhere else: `git push` prints a single
|
|
156
|
+
// skipped line and nothing else ever mentions it.
|
|
157
|
+
const hook = (() => {
|
|
158
|
+
try {
|
|
159
|
+
return hookStatus({ probe: true });
|
|
160
|
+
} catch {
|
|
161
|
+
return null; // not a git repository
|
|
162
|
+
}
|
|
163
|
+
})();
|
|
164
|
+
if (hook && hook.state === "installed") {
|
|
165
|
+
results.push(
|
|
166
|
+
line(
|
|
167
|
+
"push hook",
|
|
168
|
+
hook.works,
|
|
169
|
+
hook.works
|
|
170
|
+
? `${hook.invocation}${hook.current ? "" : ` — out of date, run \`${cliInvocation()} hook install --force\``}`
|
|
171
|
+
: `${hook.invocation || "unreadable"} does not answer — run \`${cliInvocation()} hook install --force\``
|
|
172
|
+
)
|
|
173
|
+
);
|
|
174
|
+
if (!hook.works) failures += 1;
|
|
175
|
+
} else if (hook) {
|
|
176
|
+
results.push(
|
|
177
|
+
line(
|
|
178
|
+
"push hook",
|
|
179
|
+
null,
|
|
180
|
+
hook.state === "foreign"
|
|
181
|
+
? "another tool owns pre-push here"
|
|
182
|
+
: `not installed — run \`${cliInvocation()} hook install\``
|
|
183
|
+
)
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
153
187
|
if (status?.ok) {
|
|
154
188
|
const integrations = status.integrations || {};
|
|
155
189
|
for (const [name, section] of Object.entries(integrations)) {
|
package/bin/cli.js
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
CONNECT_HELP,
|
|
22
22
|
DISPATCH_HELP,
|
|
23
23
|
connectCommand,
|
|
24
|
+
discoverEndpoints,
|
|
24
25
|
dispatchCommand,
|
|
25
26
|
writeCursorConfig,
|
|
26
27
|
} from "./connect.js";
|
|
@@ -84,7 +85,7 @@ function printHelp() {
|
|
|
84
85
|
["runner start|status|stop", "Run PreMan's queued agent work on this machine"],
|
|
85
86
|
["doctor", "Diagnose credentials, backend, target, integrations"],
|
|
86
87
|
["install-desktop", "Download and install the PreMan desktop app"],
|
|
87
|
-
["onboard", "Sign in, then
|
|
88
|
+
["onboard", "Sign in, then agent, endpoints, runner, GitHub, AWS, Slack"],
|
|
88
89
|
["connect [options]", "Pick a coding agent and connect it"],
|
|
89
90
|
["dispatch [options]", "Let PreMan start agent runs for you"],
|
|
90
91
|
["aws | github | slack", "Connect one integration on its own"],
|
|
@@ -240,7 +241,13 @@ async function main() {
|
|
|
240
241
|
} else if (command === "onboard" || command === "setup") {
|
|
241
242
|
// makeArgs/authenticateTerminal/connectCommand are injected rather than
|
|
242
243
|
// imported there, so integrations.js stays free of a cycle back into the CLI.
|
|
243
|
-
await onboardCommand(commandArgs, {
|
|
244
|
+
await onboardCommand(commandArgs, {
|
|
245
|
+
makeArgs,
|
|
246
|
+
authenticateTerminal,
|
|
247
|
+
connectCommand,
|
|
248
|
+
discoverEndpoints,
|
|
249
|
+
runnerCommand,
|
|
250
|
+
});
|
|
244
251
|
} else if (command === "aws") {
|
|
245
252
|
await awsCommand(makeArgs(commandArgs));
|
|
246
253
|
} else if (command === "github") {
|
package/bin/connect.js
CHANGED
|
@@ -21,6 +21,7 @@ import { installHook, hookStatus } from "./hook.js";
|
|
|
21
21
|
import { MARK, awsCommand, githubCommand, slackCommand } from "./integrations.js";
|
|
22
22
|
import {
|
|
23
23
|
confirmRunnerOnline,
|
|
24
|
+
pairingIsLive,
|
|
24
25
|
readRunnerState,
|
|
25
26
|
registerRunner,
|
|
26
27
|
runnerIsAlive,
|
|
@@ -1060,6 +1061,26 @@ function nextStepsBlock(agent) {
|
|
|
1060
1061
|
);
|
|
1061
1062
|
}
|
|
1062
1063
|
|
|
1064
|
+
/**
|
|
1065
|
+
* What is left once the link is done, as commands rather than as questions.
|
|
1066
|
+
*
|
|
1067
|
+
* Connect used to run all of these itself, which bought a prompt per step and a
|
|
1068
|
+
* wait as long as the slowest broken one — and a link that had already succeeded
|
|
1069
|
+
* ended on a failed runner and a five-minute GitHub poll. Each is one command, so
|
|
1070
|
+
* this lists them and gets out of the way.
|
|
1071
|
+
*/
|
|
1072
|
+
function whatIsLeftBlock(args) {
|
|
1073
|
+
const cli = cliInvocation();
|
|
1074
|
+
return (
|
|
1075
|
+
"\nWhat is left:\n" +
|
|
1076
|
+
` ${cli} endpoints discover # map this repo's endpoints\n` +
|
|
1077
|
+
` ${cli} runner start --background # let PreMan apply fixes on this machine\n` +
|
|
1078
|
+
` ${cli} github # connect GitHub, or do it in the dashboard\n` +
|
|
1079
|
+
`\n${cli} status reports your endpoints, last push and integrations.\n` +
|
|
1080
|
+
`Watch runs at ${frontendUrl(args)}\n`
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1063
1084
|
/**
|
|
1064
1085
|
* Ask, or take the step's own default when nobody can answer.
|
|
1065
1086
|
*
|
|
@@ -1130,7 +1151,7 @@ export function headlessDiscovery(agent, serverName, instructions = []) {
|
|
|
1130
1151
|
*
|
|
1131
1152
|
* Never throws: a failed discovery must not fail the connect.
|
|
1132
1153
|
*/
|
|
1133
|
-
async function discoverEndpoints(args, agent, serverName, blockedHere = "") {
|
|
1154
|
+
export async function discoverEndpoints(args, agent, serverName, blockedHere = "") {
|
|
1134
1155
|
try {
|
|
1135
1156
|
const before = await endpointCounts(args);
|
|
1136
1157
|
if (before.registered) {
|
|
@@ -1241,6 +1262,24 @@ async function runFirstTest(args, runnable, { assumeYes }) {
|
|
|
1241
1262
|
}
|
|
1242
1263
|
}
|
|
1243
1264
|
|
|
1265
|
+
/**
|
|
1266
|
+
* Whether a stored pairing can still be used, rather than merely matching.
|
|
1267
|
+
*
|
|
1268
|
+
* Matching agent and path only prove the state was written for this project. The
|
|
1269
|
+
* token behind it can be revoked, replaced by another device, or left over from an
|
|
1270
|
+
* API key that no longer exists — and reusing one of those starts a daemon that
|
|
1271
|
+
* 401s on its first stream and exits, which reads as "the runner cannot start
|
|
1272
|
+
* here" when the pairing is the only thing that needed replacing.
|
|
1273
|
+
*
|
|
1274
|
+
* A backend we cannot reach answers "usable": re-pairing is for a token the
|
|
1275
|
+
* backend rejected, not for a network that was briefly down.
|
|
1276
|
+
*/
|
|
1277
|
+
export async function runnerPairingIsUsable(args, existing, agentId, { cwd = process.cwd() } = {}) {
|
|
1278
|
+
if (!existing || existing.agent !== agentId) return false;
|
|
1279
|
+
if (path.resolve(String(existing.project_path || "")) !== path.resolve(cwd)) return false;
|
|
1280
|
+
return pairingIsLive(args, existing);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1244
1283
|
/**
|
|
1245
1284
|
* Pair this machine as a runner and leave it running.
|
|
1246
1285
|
*
|
|
@@ -1267,7 +1306,7 @@ async function setUpRunner(args, agent, { assumeYes }) {
|
|
|
1267
1306
|
}
|
|
1268
1307
|
|
|
1269
1308
|
try {
|
|
1270
|
-
if (!
|
|
1309
|
+
if (!(await runnerPairingIsUsable(args, existing, agent.id))) {
|
|
1271
1310
|
await registerRunner(args, { agent: agent.id, projectPath: process.cwd() });
|
|
1272
1311
|
}
|
|
1273
1312
|
const started = startBackground([]);
|
|
@@ -1409,7 +1448,7 @@ async function setUpPushTesting(args) {
|
|
|
1409
1448
|
if (args.has("--no-hook")) return { state: "skipped" };
|
|
1410
1449
|
const current = (() => {
|
|
1411
1450
|
try {
|
|
1412
|
-
return hookStatus();
|
|
1451
|
+
return hookStatus({ probe: true });
|
|
1413
1452
|
} catch {
|
|
1414
1453
|
return null; // not a git repository
|
|
1415
1454
|
}
|
|
@@ -1418,9 +1457,10 @@ async function setUpPushTesting(args) {
|
|
|
1418
1457
|
process.stdout.write(`${MARK.skip()} Not a git repository — no push testing here.\n`);
|
|
1419
1458
|
return { state: "unavailable" };
|
|
1420
1459
|
}
|
|
1421
|
-
// A hook whose invocation went stale
|
|
1422
|
-
// it is the case where PreMan looks connected and
|
|
1423
|
-
|
|
1460
|
+
// A hook whose invocation went stale, or no longer answers, is reinstalled
|
|
1461
|
+
// rather than reported as on: it is the case where PreMan looks connected and
|
|
1462
|
+
// silently checks nothing.
|
|
1463
|
+
if (current.state === "installed" && current.current && current.works) {
|
|
1424
1464
|
process.stdout.write(`${MARK.ok()} Push testing already on.\n`);
|
|
1425
1465
|
return { state: "installed" };
|
|
1426
1466
|
}
|
|
@@ -1432,6 +1472,13 @@ async function setUpPushTesting(args) {
|
|
|
1432
1472
|
);
|
|
1433
1473
|
return result;
|
|
1434
1474
|
}
|
|
1475
|
+
if (result.action === "unproven") {
|
|
1476
|
+
process.stdout.write(
|
|
1477
|
+
`${MARK.skip()} No push testing: ${result.detail}\n` +
|
|
1478
|
+
` Then: ${cliInvocation()} hook install\n`
|
|
1479
|
+
);
|
|
1480
|
+
return result;
|
|
1481
|
+
}
|
|
1435
1482
|
process.stdout.write(
|
|
1436
1483
|
`${MARK.ok()} Push testing on — \`git push\` now checks the endpoints you touched.\n` +
|
|
1437
1484
|
" It never blocks a push; PREMAN_SKIP_HOOK=1 silences it.\n"
|
|
@@ -1440,10 +1487,12 @@ async function setUpPushTesting(args) {
|
|
|
1440
1487
|
}
|
|
1441
1488
|
|
|
1442
1489
|
/**
|
|
1443
|
-
* Everything after the link, in one pass,
|
|
1490
|
+
* Everything after the link, in one pass, for whoever asks for it with `--guide`.
|
|
1444
1491
|
*
|
|
1445
1492
|
* Order follows what a new account needs to see: what PreMan found, then where to
|
|
1446
|
-
* watch it, then who to tell, then when to run it.
|
|
1493
|
+
* watch it, then who to tell, then when to run it. It is opt-in because running
|
|
1494
|
+
* all of it unasked is what turned a finished connect into a screen of prompts,
|
|
1495
|
+
* a dead runner and a five-minute wait on an integration nobody had asked for.
|
|
1447
1496
|
*/
|
|
1448
1497
|
async function guidedFirstRun(args, agent, apiKey, serverName, { blockedHere = "" } = {}) {
|
|
1449
1498
|
const assumeYes = args.has("--yes");
|
|
@@ -1544,10 +1593,12 @@ Connect options:
|
|
|
1544
1593
|
--no-self-test Do not start the MCP server to finish the link
|
|
1545
1594
|
--no-auto-checkin Do not run the agent to finish the link
|
|
1546
1595
|
--no-wait Do not wait for the agent to check in
|
|
1547
|
-
--
|
|
1548
|
-
|
|
1549
|
-
--no-
|
|
1550
|
-
--no-
|
|
1596
|
+
--guide Also run discovery, the runner, the desktop app
|
|
1597
|
+
and the integration prompts after connecting
|
|
1598
|
+
--no-guide Connect only: no push hook, no closing summary
|
|
1599
|
+
--no-runner With --guide, do not pair this machine as a runner
|
|
1600
|
+
--no-desktop With --guide, do not offer the desktop app
|
|
1601
|
+
--no-integrations With --guide, do not offer GitHub / AWS / Slack
|
|
1551
1602
|
--no-hook Do not install the git pre-push hook
|
|
1552
1603
|
--yes Take every step's default without prompting
|
|
1553
1604
|
(the desktop app defaults to no; install-desktop)
|
|
@@ -1670,18 +1721,36 @@ export async function connectCommand(commandArgs) {
|
|
|
1670
1721
|
serverConfig,
|
|
1671
1722
|
projectInstall,
|
|
1672
1723
|
});
|
|
1724
|
+
// The agent goes back to the caller because `onboard` runs steps after this one
|
|
1725
|
+
// that need to know which agent to drive, and asking twice is a question we
|
|
1726
|
+
// already have the answer to.
|
|
1727
|
+
const connected = { agent, serverName, linked: checkIn.linked };
|
|
1673
1728
|
if (!checkIn.linked) {
|
|
1674
1729
|
// Still honour an explicitly-passed credential, but do not open a new prompt
|
|
1675
1730
|
// on top of a connect that just told the user something went wrong.
|
|
1676
1731
|
await captureDispatchCredential(args, agent, apiKey, { prompt: false });
|
|
1677
|
-
return;
|
|
1732
|
+
return connected;
|
|
1678
1733
|
}
|
|
1679
1734
|
|
|
1680
1735
|
process.stdout.write(`${MARK.ok()} Connected as ${agent.label}.\n`);
|
|
1681
|
-
|
|
1736
|
+
|
|
1737
|
+
// Connecting is one job. Running discovery, pairing a runner, offering the
|
|
1738
|
+
// desktop app and installing three integrations is five more, each with its
|
|
1739
|
+
// own prompt and its own way to fail -- and a connect that ends in a failed
|
|
1740
|
+
// runner and a five-minute GitHub poll reads as a broken product rather than a
|
|
1741
|
+
// finished link. The push hook stays because it is the only one that is not a
|
|
1742
|
+
// question: it is what makes PreMan run at all, and it costs a file write.
|
|
1743
|
+
if (args.has("--guide") && !args.has("--no-guide")) {
|
|
1682
1744
|
await guidedFirstRun(args, agent, apiKey, serverName, { blockedHere: checkIn.blockedHere });
|
|
1745
|
+
await captureDispatchCredential(args, agent, apiKey);
|
|
1746
|
+
return connected;
|
|
1683
1747
|
}
|
|
1684
|
-
|
|
1748
|
+
if (!args.has("--no-guide")) {
|
|
1749
|
+
await setUpPushTesting(args);
|
|
1750
|
+
process.stdout.write(whatIsLeftBlock(args));
|
|
1751
|
+
}
|
|
1752
|
+
await captureDispatchCredential(args, agent, apiKey, { prompt: false });
|
|
1753
|
+
return connected;
|
|
1685
1754
|
}
|
|
1686
1755
|
|
|
1687
1756
|
/**
|
package/bin/hook.js
CHANGED
|
@@ -12,20 +12,22 @@ import { spawnSync } from "node:child_process";
|
|
|
12
12
|
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
|
|
15
|
-
import { cliInvocation, makeArgs } from "./shared.js";
|
|
15
|
+
import { cliInvocation, makeArgs, packageVersion } from "./shared.js";
|
|
16
16
|
import { BLOCK_EXIT_CODE } from "./verify.js";
|
|
17
17
|
|
|
18
18
|
export const HOOK_HELP = `
|
|
19
19
|
Hook options:
|
|
20
20
|
install Write the pre-push hook into this repository
|
|
21
21
|
uninstall Remove PreMan's pre-push hook
|
|
22
|
-
status Report whether the hook is installed
|
|
22
|
+
status Report whether the hook is installed, and still works
|
|
23
23
|
--force Overwrite a foreign pre-push hook (a backup is kept)
|
|
24
|
+
PREMAN_HOOK_INVOCATION=<cmd> Use this command line instead of probing for one
|
|
24
25
|
`;
|
|
25
26
|
|
|
26
27
|
const MARKER = "# >>> preman pre-push >>>";
|
|
27
28
|
const END_MARKER = "# <<< preman pre-push <<<";
|
|
28
29
|
const HOOK_TIMEOUT_SECONDS = 120;
|
|
30
|
+
const PROBE_TIMEOUT_MS = 45000;
|
|
29
31
|
|
|
30
32
|
function gitDir() {
|
|
31
33
|
const result = spawnSync("git", ["rev-parse", "--git-dir"], { encoding: "utf8" });
|
|
@@ -39,7 +41,90 @@ function hookPath() {
|
|
|
39
41
|
return path.join(gitDir(), "hooks", "pre-push");
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
|
|
44
|
+
/**
|
|
45
|
+
* The command lines a hook could carry, best first.
|
|
46
|
+
*
|
|
47
|
+
* A hook is written once and read at every push, so `@latest` quietly hands each
|
|
48
|
+
* push to whatever we ship next; the version that wrote the hook is the one that
|
|
49
|
+
* keeps running it, and `preman hook install` is how that moves. `@latest` is
|
|
50
|
+
* left only for the case where we cannot read our own manifest to know it.
|
|
51
|
+
*
|
|
52
|
+
* The bare `preman` form is offered first when it is ours — see `pathPremanOwner`
|
|
53
|
+
* — because it starts in milliseconds where npm exec does not.
|
|
54
|
+
*/
|
|
55
|
+
export function hookInvocations() {
|
|
56
|
+
const version = packageVersion();
|
|
57
|
+
const pinned = `npm exec -y premanmcp@${version || "latest"} --`;
|
|
58
|
+
const preferred = cliInvocation();
|
|
59
|
+
return preferred.startsWith("npm exec") ? [pinned] : [preferred, pinned];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const probed = new Map();
|
|
63
|
+
|
|
64
|
+
/** An invocation the user pinned by hand, or "". */
|
|
65
|
+
function declaredInvocation() {
|
|
66
|
+
return String(process.env.PREMAN_HOOK_INVOCATION || "").trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Does this command line reach a PreMan CLI that knows `verify`, from a process
|
|
71
|
+
* that is not this one?
|
|
72
|
+
*
|
|
73
|
+
* This is the check whose absence produced `Unknown command: verify` on every
|
|
74
|
+
* push: the hook is generated shell run minutes or months later, so the only
|
|
75
|
+
* evidence that matters is a separate process answering. `help` is the cheapest
|
|
76
|
+
* subcommand that proves both halves — that something ran, and that it was us
|
|
77
|
+
* rather than another package's identically named `preman`.
|
|
78
|
+
*
|
|
79
|
+
* Memoized per command line, because a connect installs and then re-reads the
|
|
80
|
+
* hook, and npm exec is slow enough that paying twice shows.
|
|
81
|
+
*/
|
|
82
|
+
export function invocationAnswers(invocation) {
|
|
83
|
+
// Declaring an invocation means "use this and stop asking", so the same answer
|
|
84
|
+
// has to hold when we later read it back out of a hook -- otherwise the escape
|
|
85
|
+
// hatch writes a hook that every status then calls broken.
|
|
86
|
+
if (invocation && invocation === declaredInvocation()) return true;
|
|
87
|
+
if (probed.has(invocation)) return probed.get(invocation);
|
|
88
|
+
let answered = false;
|
|
89
|
+
try {
|
|
90
|
+
const probe = spawnSync(`${invocation} help`, {
|
|
91
|
+
shell: true,
|
|
92
|
+
encoding: "utf8",
|
|
93
|
+
timeout: Number(process.env.PREMAN_HOOK_PROBE_MS) || PROBE_TIMEOUT_MS,
|
|
94
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
95
|
+
});
|
|
96
|
+
answered = probe.status === 0 && /\bverify\b/.test(String(probe.stdout || ""));
|
|
97
|
+
} catch {
|
|
98
|
+
answered = false;
|
|
99
|
+
}
|
|
100
|
+
probed.set(invocation, answered);
|
|
101
|
+
return answered;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Test seam: the probe above is memoized for the life of the process. */
|
|
105
|
+
export function resetInvocationProbe() {
|
|
106
|
+
probed.clear();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The invocation to write into a hook, or "" when nothing here can run PreMan.
|
|
111
|
+
*
|
|
112
|
+
* `PREMAN_HOOK_INVOCATION` is taken on trust: it exists for the setups we cannot
|
|
113
|
+
* probe our way to — a wrapper script, a monorepo runner, a pinned mirror.
|
|
114
|
+
*/
|
|
115
|
+
export function provenInvocation() {
|
|
116
|
+
return (
|
|
117
|
+
declaredInvocation() || hookInvocations().find((candidate) => invocationAnswers(candidate)) || ""
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The command line an already-written hook calls, or "". */
|
|
122
|
+
function embeddedInvocation(text) {
|
|
123
|
+
const match = /^\s*PREMAN_HOOK=1 (.+) verify --pre-push\b/m.exec(text);
|
|
124
|
+
return match ? match[1].trim() : "";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function hookBody(invocation) {
|
|
43
128
|
// `exec` is deliberately absent: we want the wrapper to survive the CLI exiting
|
|
44
129
|
// non-zero and still exit 0 itself.
|
|
45
130
|
//
|
|
@@ -51,7 +136,7 @@ ${MARKER}
|
|
|
51
136
|
# Advisory unless this repository opted into blocking: only exit code
|
|
52
137
|
# ${BLOCK_EXIT_CODE} stops a push, so a crash or a timeout still lets it through.
|
|
53
138
|
if [ -z "\${PREMAN_SKIP_HOOK}" ]; then
|
|
54
|
-
PREMAN_HOOK=1 ${
|
|
139
|
+
PREMAN_HOOK=1 ${invocation} verify --pre-push --timeout ${HOOK_TIMEOUT_SECONDS}
|
|
55
140
|
preman_status=$?
|
|
56
141
|
if [ "$preman_status" -eq ${BLOCK_EXIT_CODE} ]; then
|
|
57
142
|
exit ${BLOCK_EXIT_CODE}
|
|
@@ -71,16 +156,31 @@ function isOurHook(text) {
|
|
|
71
156
|
return text.includes(MARKER);
|
|
72
157
|
}
|
|
73
158
|
|
|
74
|
-
export function installHook(args) {
|
|
159
|
+
export function installHook(args, { invocation = provenInvocation() } = {}) {
|
|
75
160
|
const target = hookPath();
|
|
161
|
+
|
|
162
|
+
// A hook holding a command that does not run is worse than no hook: it is
|
|
163
|
+
// silent, it says "installed" in every status we print, and the only sign of
|
|
164
|
+
// it is one skipped line scrolling past a push nobody reads.
|
|
165
|
+
if (!invocation) {
|
|
166
|
+
return {
|
|
167
|
+
path: target,
|
|
168
|
+
action: "unproven",
|
|
169
|
+
detail:
|
|
170
|
+
"no way to run the PreMan CLI from a hook was found here" +
|
|
171
|
+
" -- install it globally (npm i -g premanmcp) or set PREMAN_HOOK_INVOCATION",
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const body = hookBody(invocation);
|
|
76
176
|
mkdirSync(path.dirname(target), { recursive: true });
|
|
77
177
|
|
|
78
178
|
if (existsSync(target)) {
|
|
79
179
|
const existing = readFileSync(target, "utf8");
|
|
80
180
|
if (isOurHook(existing)) {
|
|
81
|
-
writeFileSync(target,
|
|
181
|
+
writeFileSync(target, body, { mode: 0o755 });
|
|
82
182
|
chmodSync(target, 0o755);
|
|
83
|
-
return { path: target, action: "updated" };
|
|
183
|
+
return { path: target, action: "updated", invocation };
|
|
84
184
|
}
|
|
85
185
|
if (!args.has("--force")) {
|
|
86
186
|
return {
|
|
@@ -91,14 +191,19 @@ export function installHook(args) {
|
|
|
91
191
|
}
|
|
92
192
|
const backup = `${target}.preman-backup`;
|
|
93
193
|
writeFileSync(backup, existing, { mode: 0o755 });
|
|
94
|
-
writeFileSync(target,
|
|
194
|
+
writeFileSync(target, body, { mode: 0o755 });
|
|
95
195
|
chmodSync(target, 0o755);
|
|
96
|
-
return {
|
|
196
|
+
return {
|
|
197
|
+
path: target,
|
|
198
|
+
action: "replaced",
|
|
199
|
+
invocation,
|
|
200
|
+
detail: `previous hook saved to ${backup}`,
|
|
201
|
+
};
|
|
97
202
|
}
|
|
98
203
|
|
|
99
|
-
writeFileSync(target,
|
|
204
|
+
writeFileSync(target, body, { mode: 0o755 });
|
|
100
205
|
chmodSync(target, 0o755);
|
|
101
|
-
return { path: target, action: "installed" };
|
|
206
|
+
return { path: target, action: "installed", invocation };
|
|
102
207
|
}
|
|
103
208
|
|
|
104
209
|
export function uninstallHook() {
|
|
@@ -122,17 +227,36 @@ export function uninstallHook() {
|
|
|
122
227
|
* `current` is what tells a caller an installed hook still needs rewriting.
|
|
123
228
|
*
|
|
124
229
|
* A hook is generated shell holding one invocation, and that invocation can go
|
|
125
|
-
* stale — the machine gained a `preman` that is not ours,
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* tell the difference.
|
|
230
|
+
* stale — the machine gained a `preman` that is not ours, lost the one that was,
|
|
231
|
+
* or upgraded past the version the hook pins. "Installed" then means a file
|
|
232
|
+
* exists that calls the wrong thing on every push, so anything that skips work
|
|
233
|
+
* when the hook is present has to be able to tell the difference.
|
|
234
|
+
*
|
|
235
|
+
* `works` answers the harder question — does the command in the file still run
|
|
236
|
+
* PreMan — so callers ask for it: `preman hook status` and `preman doctor` do,
|
|
237
|
+
* connect does not. Both answers cost a probe for an installed hook, since
|
|
238
|
+
* "up to date" means "equal to what we would write now", and what we would write
|
|
239
|
+
* is whatever answers here. An absent or foreign hook costs nothing.
|
|
129
240
|
*/
|
|
130
|
-
export function hookStatus() {
|
|
241
|
+
export function hookStatus({ probe = false } = {}) {
|
|
131
242
|
const target = hookPath();
|
|
132
|
-
|
|
243
|
+
const missing = { path: target, current: false, invocation: "", works: null };
|
|
244
|
+
if (!existsSync(target)) return { ...missing, state: "absent" };
|
|
133
245
|
const existing = readFileSync(target, "utf8");
|
|
134
|
-
if (!isOurHook(existing)) return {
|
|
135
|
-
|
|
246
|
+
if (!isOurHook(existing)) return { ...missing, state: "foreign" };
|
|
247
|
+
|
|
248
|
+
const invocation = embeddedInvocation(existing);
|
|
249
|
+
// Falling back to what the file already carries keeps a machine that can no
|
|
250
|
+
// longer prove any invocation -- offline, say -- from reporting a hook as out
|
|
251
|
+
// of date and sending its owner to a reinstall that would refuse to write.
|
|
252
|
+
const wanted = provenInvocation() || invocation;
|
|
253
|
+
return {
|
|
254
|
+
path: target,
|
|
255
|
+
state: "installed",
|
|
256
|
+
invocation,
|
|
257
|
+
current: Boolean(wanted) && existing === hookBody(wanted),
|
|
258
|
+
works: probe ? Boolean(invocation) && invocationAnswers(invocation) : null,
|
|
259
|
+
};
|
|
136
260
|
}
|
|
137
261
|
|
|
138
262
|
export async function hookCommand(commandArgs = []) {
|
|
@@ -141,13 +265,14 @@ export async function hookCommand(commandArgs = []) {
|
|
|
141
265
|
|
|
142
266
|
if (sub === "install") {
|
|
143
267
|
const result = installHook(args);
|
|
144
|
-
if (result.action === "conflict") {
|
|
268
|
+
if (result.action === "conflict" || result.action === "unproven") {
|
|
145
269
|
process.stdout.write(`Not installed: ${result.detail}\n ${result.path}\n`);
|
|
146
270
|
return result;
|
|
147
271
|
}
|
|
148
272
|
process.stdout.write(
|
|
149
273
|
`Pre-push hook ${result.action}: ${result.path}\n` +
|
|
150
274
|
(result.detail ? ` ${result.detail}\n` : "") +
|
|
275
|
+
` Runs: ${result.invocation} verify --pre-push\n` +
|
|
151
276
|
`\nPreMan will now check affected endpoints before each push.\n` +
|
|
152
277
|
`It never blocks a push -- set PREMAN_SKIP_HOOK=1 to silence it entirely.\n`
|
|
153
278
|
);
|
|
@@ -163,13 +288,27 @@ export async function hookCommand(commandArgs = []) {
|
|
|
163
288
|
}
|
|
164
289
|
|
|
165
290
|
if (sub === "status") {
|
|
166
|
-
const result = hookStatus();
|
|
291
|
+
const result = hookStatus({ probe: true });
|
|
167
292
|
const label = {
|
|
168
293
|
installed: "installed (PreMan)",
|
|
169
294
|
foreign: "present, but not written by PreMan",
|
|
170
295
|
absent: "not installed",
|
|
171
296
|
}[result.state];
|
|
172
297
|
process.stdout.write(`Pre-push hook: ${label}\n ${result.path}\n`);
|
|
298
|
+
if (result.state === "installed") {
|
|
299
|
+
process.stdout.write(` Runs: ${result.invocation || "(unreadable)"}\n`);
|
|
300
|
+
// "Installed" was never the question -- a hook that cannot reach the CLI
|
|
301
|
+
// prints one skipped line per push and is otherwise indistinguishable.
|
|
302
|
+
process.stdout.write(
|
|
303
|
+
result.works
|
|
304
|
+
? " That command answers here.\n"
|
|
305
|
+
: ` That command does not answer here -- pushes are being skipped.\n` +
|
|
306
|
+
` Repair it: ${cliInvocation()} hook install --force\n`
|
|
307
|
+
);
|
|
308
|
+
if (!result.current) {
|
|
309
|
+
process.stdout.write(` Out of date: ${cliInvocation()} hook install --force\n`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
173
312
|
return result;
|
|
174
313
|
}
|
|
175
314
|
|
package/bin/integrations.js
CHANGED
|
@@ -26,6 +26,12 @@ import {
|
|
|
26
26
|
|
|
27
27
|
const POLL_INTERVAL_MS = 3000;
|
|
28
28
|
const POLL_TIMEOUT_MS = 300000;
|
|
29
|
+
// GitHub is the one install where waiting longer buys nothing. A CloudFormation
|
|
30
|
+
// stack genuinely takes minutes, but the App either redirects back seconds after
|
|
31
|
+
// the customer confirms or it never does — and five more minutes of dots turns a
|
|
32
|
+
// hand-off into an outage. PREMAN_GITHUB_POLL_MS shortens it, so a test of the
|
|
33
|
+
// hand-off does not have to spend three quarters of a minute reaching it.
|
|
34
|
+
const GITHUB_POLL_TIMEOUT_MS = 45000;
|
|
29
35
|
|
|
30
36
|
/**
|
|
31
37
|
* Colour only when someone is actually watching.
|
|
@@ -75,9 +81,13 @@ export class Unrecoverable extends Error {}
|
|
|
75
81
|
* Returns the truthy value from ``check``, or null on timeout. Ordinary
|
|
76
82
|
* exceptions are swallowed and retried; :class:`Unrecoverable` stops the wait.
|
|
77
83
|
*/
|
|
78
|
-
async function waitFor(
|
|
84
|
+
async function waitFor(
|
|
85
|
+
label,
|
|
86
|
+
check,
|
|
87
|
+
{ hint = "", hintAfterMs = 60000, timeoutMs = POLL_TIMEOUT_MS } = {}
|
|
88
|
+
) {
|
|
79
89
|
const startedAt = Date.now();
|
|
80
|
-
const deadline = startedAt +
|
|
90
|
+
const deadline = startedAt + timeoutMs;
|
|
81
91
|
let hinted = false;
|
|
82
92
|
process.stdout.write(`Waiting for ${label}`);
|
|
83
93
|
while (Date.now() < deadline) {
|
|
@@ -232,6 +242,10 @@ export async function githubCommand(args) {
|
|
|
232
242
|
present(url, "install the PreMan GitHub App");
|
|
233
243
|
process.stdout.write("Pick the repositories PreMan may read.\n");
|
|
234
244
|
|
|
245
|
+
// The refresh answer is the diagnosis. A 409 means GitHub never called back;
|
|
246
|
+
// a success carrying no repositories means the App is installed and sharing
|
|
247
|
+
// nothing — two dead ends that are indistinguishable from the repository list.
|
|
248
|
+
let refresh = null;
|
|
235
249
|
const done = await waitFor(
|
|
236
250
|
"the installation",
|
|
237
251
|
async () => {
|
|
@@ -239,7 +253,7 @@ export async function githubCommand(args) {
|
|
|
239
253
|
// callback records the installation, and a refresh materialises the repos.
|
|
240
254
|
// Polling the repo list alone waits for something that may never arrive on
|
|
241
255
|
// its own.
|
|
242
|
-
await callBackendJson(args, "POST", "/integrations/github/app/refresh", {
|
|
256
|
+
refresh = await callBackendJson(args, "POST", "/integrations/github/app/refresh", {
|
|
243
257
|
token,
|
|
244
258
|
json: {},
|
|
245
259
|
});
|
|
@@ -249,24 +263,46 @@ export async function githubCommand(args) {
|
|
|
249
263
|
return fresh.length ? fresh : null;
|
|
250
264
|
},
|
|
251
265
|
{
|
|
266
|
+
timeoutMs: Number(process.env.PREMAN_GITHUB_POLL_MS) || GITHUB_POLL_TIMEOUT_MS,
|
|
267
|
+
hintAfterMs: 20000,
|
|
252
268
|
hint:
|
|
253
|
-
"
|
|
254
|
-
"
|
|
269
|
+
"Still nothing from GitHub. Picking at least one repository and confirming\n" +
|
|
270
|
+
"is what sends you back here.",
|
|
255
271
|
}
|
|
256
272
|
);
|
|
257
273
|
|
|
258
274
|
if (!done) {
|
|
259
|
-
process.stdout.write(
|
|
260
|
-
`Timed out: GitHub never told PreMan about an installation.\n` +
|
|
261
|
-
` - Check that the App is installed: https://github.com/settings/installations\n` +
|
|
262
|
-
` - Then re-run '${cliInvocation()} github'.\n`
|
|
263
|
-
);
|
|
275
|
+
process.stdout.write(githubHandOff(args, refresh));
|
|
264
276
|
return;
|
|
265
277
|
}
|
|
266
278
|
connected(`GitHub connected: ${done.length} repository(ies).`);
|
|
267
279
|
for (const repo of done.slice(0, 5)) process.stdout.write(` - ${repo.repo_url}\n`);
|
|
268
280
|
}
|
|
269
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Stop waiting, and name the dead end instead of the timeout.
|
|
284
|
+
*
|
|
285
|
+
* Holding the terminal for five minutes taught nobody anything: the install
|
|
286
|
+
* finishes in the browser whether this process is watching or not, and the two
|
|
287
|
+
* ways it can complete and still leave PreMan with nothing are both actionable.
|
|
288
|
+
*/
|
|
289
|
+
function githubHandOff(args, refresh) {
|
|
290
|
+
const installed = Boolean(refresh?.ok) && Number(refresh.installations_refreshed || 0) > 0;
|
|
291
|
+
if (installed) {
|
|
292
|
+
return (
|
|
293
|
+
`The App is installed, but no repositories are shared with it.\n` +
|
|
294
|
+
` - Add some: https://github.com/settings/installations\n` +
|
|
295
|
+
` - Then re-run '${cliInvocation()} github'.\n`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
return (
|
|
299
|
+
`Nothing from GitHub yet — no need to wait here.\n` +
|
|
300
|
+
` - Finish the install in the browser; it records itself when you confirm.\n` +
|
|
301
|
+
` - Check it: ${frontendUrl(args)} or https://github.com/settings/installations\n` +
|
|
302
|
+
` - Then re-run '${cliInvocation()} github'.\n`
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
270
306
|
// ---------------------------------------------------------------------------
|
|
271
307
|
// Slack
|
|
272
308
|
// ---------------------------------------------------------------------------
|
|
@@ -301,6 +337,17 @@ export async function slackCommand(args) {
|
|
|
301
337
|
// The guided run
|
|
302
338
|
// ---------------------------------------------------------------------------
|
|
303
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Why a step that needs a connected agent cannot run on its own.
|
|
342
|
+
*
|
|
343
|
+
* Both of these drive the agent `connect` just linked, so skipping that step
|
|
344
|
+
* leaves them without one -- which is a thing to say plainly, with the command
|
|
345
|
+
* that does it later, rather than a stack trace about a missing id.
|
|
346
|
+
*/
|
|
347
|
+
function needsAgent(command) {
|
|
348
|
+
return `no coding agent connected yet -- run '${cliInvocation()} connect', then '${cliInvocation()} ${command}'`;
|
|
349
|
+
}
|
|
350
|
+
|
|
304
351
|
/** "yes" | "no" | "back" -- back only offered once there is somewhere to go. */
|
|
305
352
|
async function askStep(question, { assumeYes, canGoBack }) {
|
|
306
353
|
if (assumeYes) return "yes";
|
|
@@ -319,7 +366,10 @@ async function askStep(question, { assumeYes, canGoBack }) {
|
|
|
319
366
|
* throws is reported and the run continues rather than unwinding the ones that
|
|
320
367
|
* already worked.
|
|
321
368
|
*/
|
|
322
|
-
export async function onboardCommand(
|
|
369
|
+
export async function onboardCommand(
|
|
370
|
+
commandArgs,
|
|
371
|
+
{ makeArgs, authenticateTerminal, connectCommand, discoverEndpoints, runnerCommand }
|
|
372
|
+
) {
|
|
323
373
|
const args = makeArgs(commandArgs);
|
|
324
374
|
const assumeYes = args.has("--yes");
|
|
325
375
|
|
|
@@ -328,11 +378,33 @@ export async function onboardCommand(commandArgs, { makeArgs, authenticateTermin
|
|
|
328
378
|
const creds = await authenticateTerminal(args);
|
|
329
379
|
connected(`Signed in as ${creds.user_email || "your account"}.`);
|
|
330
380
|
|
|
381
|
+
// Which agent the endpoints and runner steps drive. `connect` decided it, by
|
|
382
|
+
// detection or by asking, and this is the answer rather than a second prompt.
|
|
383
|
+
let linked = null;
|
|
384
|
+
|
|
331
385
|
const steps = [
|
|
332
386
|
{
|
|
333
387
|
name: "coding agent",
|
|
334
388
|
question: "Connect your coding agent?",
|
|
335
|
-
run: () =>
|
|
389
|
+
run: async () => {
|
|
390
|
+
linked = (await connectCommand([...commandArgs, "--skip-login"])) || null;
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
name: "endpoints",
|
|
395
|
+
question: "Map this repository's endpoints?",
|
|
396
|
+
run: () => {
|
|
397
|
+
if (!linked?.agent) throw new Error(needsAgent("endpoints discover"));
|
|
398
|
+
return discoverEndpoints(args, linked.agent, linked.serverName);
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
name: "runner",
|
|
403
|
+
question: "Let PreMan run your agent here when it finds something to fix?",
|
|
404
|
+
run: () => {
|
|
405
|
+
if (!linked?.agent) throw new Error(needsAgent("runner start --background"));
|
|
406
|
+
return runnerCommand(["start", "--background", "--agent", linked.agent.id]);
|
|
407
|
+
},
|
|
336
408
|
},
|
|
337
409
|
{ name: "GitHub", question: "Connect GitHub?", run: () => githubCommand(args) },
|
|
338
410
|
{ name: "AWS logs", question: "Connect AWS?", run: () => awsCommand(args) },
|
|
@@ -392,7 +464,8 @@ export async function onboardCommand(commandArgs, { makeArgs, authenticateTermin
|
|
|
392
464
|
|
|
393
465
|
export const INTEGRATIONS_HELP = `
|
|
394
466
|
Setup options:
|
|
395
|
-
preman onboard Sign in, then
|
|
467
|
+
preman onboard Sign in, then agent, endpoints, runner, GitHub,
|
|
468
|
+
AWS and Slack, one prompt per step
|
|
396
469
|
preman aws Connect an AWS account and stream a log group
|
|
397
470
|
preman github Install the PreMan GitHub App
|
|
398
471
|
preman slack Add PreMan to a Slack workspace
|
package/bin/runner.js
CHANGED
|
@@ -608,6 +608,12 @@ export async function executeJob(args, state, job, { log = () => {}, fullAccess
|
|
|
608
608
|
|
|
609
609
|
// ── Event loop ──────────────────────────────────────────────────────────
|
|
610
610
|
|
|
611
|
+
/** The re-pairing command for this runner. `register` without --agent exits 2. */
|
|
612
|
+
function repairCommand(state) {
|
|
613
|
+
const agent = String(state?.agent || "claude_code").replace(/_/g, "-");
|
|
614
|
+
return `${cliInvocation()} runner register --agent ${agent}`;
|
|
615
|
+
}
|
|
616
|
+
|
|
611
617
|
function defaultLog(message) {
|
|
612
618
|
process.stdout.write(`[preman runner] ${new Date().toISOString()} ${message}\n`);
|
|
613
619
|
}
|
|
@@ -682,7 +688,7 @@ export async function runnerLoop(
|
|
|
682
688
|
}
|
|
683
689
|
|
|
684
690
|
if (response.status === 401) {
|
|
685
|
-
log(
|
|
691
|
+
log(`runner token was revoked; re-pair with \`${repairCommand(state)}\``);
|
|
686
692
|
clearRunnerState();
|
|
687
693
|
return { stopped: true, jobsRun, reason: "revoked" };
|
|
688
694
|
}
|
|
@@ -729,7 +735,7 @@ export async function runnerLoop(
|
|
|
729
735
|
}
|
|
730
736
|
|
|
731
737
|
if (terminal === "revoked") {
|
|
732
|
-
log(
|
|
738
|
+
log(`runner was revoked; re-pair with \`${repairCommand(state)}\``);
|
|
733
739
|
clearRunnerState();
|
|
734
740
|
return { stopped: true, jobsRun, reason: "revoked" };
|
|
735
741
|
}
|
|
@@ -815,6 +821,19 @@ export async function confirmRunnerOnline({ pid, offset = 0, timeoutMs = 12_000,
|
|
|
815
821
|
}
|
|
816
822
|
}
|
|
817
823
|
|
|
824
|
+
/**
|
|
825
|
+
* Does the backend still honour this pairing's token?
|
|
826
|
+
*
|
|
827
|
+
* One heartbeat, which is the only call that can answer it. A backend we cannot
|
|
828
|
+
* reach answers yes: re-pairing is for a token that was rejected, not for a
|
|
829
|
+
* network that blinked, and registering again would spend a pair code to fix
|
|
830
|
+
* nothing.
|
|
831
|
+
*/
|
|
832
|
+
export async function pairingIsLive(args, state) {
|
|
833
|
+
const { revoked } = await sendHeartbeat(args, state, { log: () => {} });
|
|
834
|
+
return !revoked;
|
|
835
|
+
}
|
|
836
|
+
|
|
818
837
|
/** Start the daemon detached, so a terminal can be closed without killing it. */
|
|
819
838
|
export function startBackground(commandArgs) {
|
|
820
839
|
ensureDir();
|
|
@@ -844,6 +863,14 @@ async function startForeground(args, commandArgs) {
|
|
|
844
863
|
);
|
|
845
864
|
}
|
|
846
865
|
state = await registerRunner(args, { agent, projectPath: args.value("--path", process.cwd()) });
|
|
866
|
+
} else if (!(await pairingIsLive(args, state))) {
|
|
867
|
+
// A stored pairing is not a working one. The backend revokes a runner when
|
|
868
|
+
// another device takes over, when the key behind it is deleted, or when this
|
|
869
|
+
// machine was last seen going offline -- and starting on one of those dies on
|
|
870
|
+
// the first stream call with a 401, which reads as "the runner cannot run
|
|
871
|
+
// here" when the token was the only thing that needed replacing.
|
|
872
|
+
defaultLog("stored pairing was rejected; registering this machine again");
|
|
873
|
+
state = await registerRunner(args, { agent: state.agent, projectPath: state.project_path });
|
|
847
874
|
}
|
|
848
875
|
|
|
849
876
|
const log = defaultLog;
|
package/bin/shared.js
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import os from "node:os";
|
|
21
21
|
import path from "node:path";
|
|
22
22
|
import { createInterface } from "node:readline/promises";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
23
24
|
|
|
24
25
|
export const DEFAULT_BACKEND = "https://api.preman.live";
|
|
25
26
|
export const DEFAULT_FRONTEND = "https://app.preman.live";
|
|
@@ -38,7 +39,40 @@ export const CREDENTIALS_FILE = path.join(CREDENTIALS_DIR, "credentials.json");
|
|
|
38
39
|
let _invocation = null;
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
|
-
*
|
|
42
|
+
* A `preman` that exists only for this process, and cannot be written down.
|
|
43
|
+
*
|
|
44
|
+
* `npm exec` prepends its cache's `node_modules/.bin` to the child's PATH, and
|
|
45
|
+
* this package declares a `preman` bin — so inside our own documented launcher,
|
|
46
|
+
* `which preman` finds *us*. Believing it produced a git hook calling `preman
|
|
47
|
+
* verify`, which worked in that one process and answered "Unknown command:
|
|
48
|
+
* verify" at every later `git push`, because the PATH entry was gone.
|
|
49
|
+
*
|
|
50
|
+
* A durable global install (`npm i -g premanmcp`) is not under an `_npx` cache,
|
|
51
|
+
* so it still counts.
|
|
52
|
+
*/
|
|
53
|
+
function isEphemeralBin(realPath) {
|
|
54
|
+
return realPath.split(path.sep).includes("_npx");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The package owning a resolved bin, by the nearest manifest above it, or "". */
|
|
58
|
+
function owningPackage(realPath) {
|
|
59
|
+
// Walk up from the real file, not the symlink: a bin is a link into the
|
|
60
|
+
// package directory, and only that directory's manifest names the owner.
|
|
61
|
+
let dir = path.dirname(realPath);
|
|
62
|
+
for (let depth = 0; depth < 5; depth += 1) {
|
|
63
|
+
const manifest = path.join(dir, "package.json");
|
|
64
|
+
if (existsSync(manifest)) {
|
|
65
|
+
return String(JSON.parse(readFileSync(manifest, "utf8")).name || "");
|
|
66
|
+
}
|
|
67
|
+
const parent = path.dirname(dir);
|
|
68
|
+
if (parent === dir) break;
|
|
69
|
+
dir = parent;
|
|
70
|
+
}
|
|
71
|
+
return "";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The package that owns the `preman` a *later* process would find, or "".
|
|
42
76
|
*
|
|
43
77
|
* `preman` is not ours by name alone: `preman-sdk` publishes a bin called
|
|
44
78
|
* exactly that, and whichever package lost the race still resolves. Assuming it
|
|
@@ -46,34 +80,36 @@ let _invocation = null;
|
|
|
46
80
|
* "Unknown command: verify" and turns every push into a silent
|
|
47
81
|
* "[preman] checks skipped", the worst possible failure for a tool whose whole
|
|
48
82
|
* job is running checks on push.
|
|
83
|
+
*
|
|
84
|
+
* Every match is considered, not just the first: an ephemeral hit shadowing a
|
|
85
|
+
* real global install must not decide the answer for either of them.
|
|
49
86
|
*/
|
|
50
87
|
export function pathPremanOwner() {
|
|
51
|
-
const
|
|
52
|
-
let
|
|
88
|
+
const onWindows = process.platform === "win32";
|
|
89
|
+
let candidates = [];
|
|
53
90
|
try {
|
|
54
|
-
const found = spawnSync(
|
|
91
|
+
const found = spawnSync(onWindows ? "where" : "which", onWindows ? ["preman"] : ["-a", "preman"], {
|
|
92
|
+
stdio: "pipe",
|
|
93
|
+
encoding: "utf8",
|
|
94
|
+
});
|
|
55
95
|
if (found.status !== 0) return "";
|
|
56
|
-
|
|
96
|
+
candidates = String(found.stdout || "")
|
|
97
|
+
.split("\n")
|
|
98
|
+
.map((line) => line.trim())
|
|
99
|
+
.filter(Boolean);
|
|
57
100
|
} catch {
|
|
58
101
|
return "";
|
|
59
102
|
}
|
|
60
|
-
if (!resolved) return "";
|
|
61
103
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
const parent = path.dirname(dir);
|
|
72
|
-
if (parent === dir) break;
|
|
73
|
-
dir = parent;
|
|
104
|
+
for (const candidate of candidates) {
|
|
105
|
+
try {
|
|
106
|
+
const realPath = realpathSync(candidate);
|
|
107
|
+
if (isEphemeralBin(realPath)) continue;
|
|
108
|
+
const owner = owningPackage(realPath);
|
|
109
|
+
if (owner) return owner;
|
|
110
|
+
} catch {
|
|
111
|
+
// A dangling link or an unreadable manifest: try the next match.
|
|
74
112
|
}
|
|
75
|
-
} catch {
|
|
76
|
-
return "";
|
|
77
113
|
}
|
|
78
114
|
return "";
|
|
79
115
|
}
|
|
@@ -83,6 +119,16 @@ export function cliInvocation() {
|
|
|
83
119
|
pathPremanOwner() === "premanmcp" ? "preman" : "npm exec -y premanmcp@latest --");
|
|
84
120
|
}
|
|
85
121
|
|
|
122
|
+
/** The version of the package this process is running from, or "". */
|
|
123
|
+
export function packageVersion() {
|
|
124
|
+
try {
|
|
125
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
126
|
+
return String(JSON.parse(readFileSync(path.join(here, "..", "package.json"), "utf8")).version || "");
|
|
127
|
+
} catch {
|
|
128
|
+
return "";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
86
132
|
/** Test seam: `cliInvocation` memoizes a PATH probe for the life of the process. */
|
|
87
133
|
export function resetCliInvocation() {
|
|
88
134
|
_invocation = null;
|