@openthink/stamp 1.3.1 → 1.5.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/README.md +34 -14
- package/dist/hooks/pre-receive.cjs.map +1 -1
- package/dist/index.js +1042 -159
- package/dist/index.js.map +1 -1
- package/dist/server/authorized-keys.cjs +131 -0
- package/dist/server/authorized-keys.cjs.map +1 -0
- package/dist/server/mint-invite.cjs +306 -0
- package/dist/server/mint-invite.cjs.map +1 -0
- package/dist/server/seed-users.cjs +247 -0
- package/dist/server/seed-users.cjs.map +1 -0
- package/dist/server/start-http-server.cjs +400 -0
- package/dist/server/start-http-server.cjs.map +1 -0
- package/dist/server/users-cli.cjs +473 -0
- package/dist/server/users-cli.cjs.map +1 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -520,15 +520,34 @@ function validateConfig(input) {
|
|
|
520
520
|
}
|
|
521
521
|
enforce_reads_on_dotstamp = d.enforce_reads_on_dotstamp;
|
|
522
522
|
}
|
|
523
|
+
const max_turns = parsePositiveInt(
|
|
524
|
+
d.max_turns,
|
|
525
|
+
`config.reviewers.${name}.max_turns`
|
|
526
|
+
);
|
|
527
|
+
const timeout_ms = parsePositiveInt(
|
|
528
|
+
d.timeout_ms,
|
|
529
|
+
`config.reviewers.${name}.timeout_ms`
|
|
530
|
+
);
|
|
523
531
|
reviewers2[name] = {
|
|
524
532
|
prompt: d.prompt,
|
|
525
533
|
...tools ? { tools } : {},
|
|
526
534
|
...mcp_servers ? { mcp_servers } : {},
|
|
527
|
-
...enforce_reads_on_dotstamp !== void 0 ? { enforce_reads_on_dotstamp } : {}
|
|
535
|
+
...enforce_reads_on_dotstamp !== void 0 ? { enforce_reads_on_dotstamp } : {},
|
|
536
|
+
...max_turns !== void 0 ? { max_turns } : {},
|
|
537
|
+
...timeout_ms !== void 0 ? { timeout_ms } : {}
|
|
528
538
|
};
|
|
529
539
|
}
|
|
530
540
|
return { branches, reviewers: reviewers2 };
|
|
531
541
|
}
|
|
542
|
+
function parsePositiveInt(input, path2) {
|
|
543
|
+
if (input === void 0 || input === null) return void 0;
|
|
544
|
+
if (typeof input !== "number" || !Number.isFinite(input) || !Number.isInteger(input) || input <= 0) {
|
|
545
|
+
throw new Error(
|
|
546
|
+
`${path2} must be a positive integer (got ${JSON.stringify(input)})`
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
return input;
|
|
550
|
+
}
|
|
532
551
|
function parseChecks(input, branchName) {
|
|
533
552
|
if (input === void 0 || input === null) return void 0;
|
|
534
553
|
if (!Array.isArray(input)) {
|
|
@@ -1354,13 +1373,13 @@ function readLineSync() {
|
|
|
1354
1373
|
function runMerge(opts) {
|
|
1355
1374
|
const repoRoot = findRepoRoot();
|
|
1356
1375
|
const config2 = loadConfig(stampConfigFile(repoRoot));
|
|
1357
|
-
const
|
|
1376
|
+
const currentBranch3 = git(
|
|
1358
1377
|
["rev-parse", "--abbrev-ref", "HEAD"],
|
|
1359
1378
|
repoRoot
|
|
1360
1379
|
).trim();
|
|
1361
|
-
if (
|
|
1380
|
+
if (currentBranch3 !== opts.into) {
|
|
1362
1381
|
throw new Error(
|
|
1363
|
-
`must be on target branch "${opts.into}" to merge into it (currently on "${
|
|
1382
|
+
`must be on target branch "${opts.into}" to merge into it (currently on "${currentBranch3}"). Run \`git checkout ${opts.into}\` first.`
|
|
1364
1383
|
);
|
|
1365
1384
|
}
|
|
1366
1385
|
const dirty = git(
|
|
@@ -2027,14 +2046,14 @@ async function invokeReviewer(params) {
|
|
|
2027
2046
|
...mcpServersResolved ?? {},
|
|
2028
2047
|
"stamp-verdict": verdictServer
|
|
2029
2048
|
};
|
|
2030
|
-
const maxTurns = parseIntEnv("STAMP_REVIEWER_MAX_TURNS", 8);
|
|
2031
|
-
const timeoutMs = parseIntEnv("STAMP_REVIEWER_TIMEOUT_MS", 5 * 60 * 1e3);
|
|
2049
|
+
const maxTurns = def.max_turns ?? parseIntEnv("STAMP_REVIEWER_MAX_TURNS", 8);
|
|
2050
|
+
const timeoutMs = def.timeout_ms ?? parseIntEnv("STAMP_REVIEWER_TIMEOUT_MS", 5 * 60 * 1e3);
|
|
2032
2051
|
const modelOverride = resolveReviewerModel(params.reviewer);
|
|
2033
2052
|
const abortController = new AbortController();
|
|
2034
2053
|
const timeoutHandle = setTimeout(() => {
|
|
2035
2054
|
abortController.abort(
|
|
2036
2055
|
new Error(
|
|
2037
|
-
`reviewer "${params.reviewer}" exceeded ${timeoutMs}ms wall-clock budget
|
|
2056
|
+
`reviewer "${params.reviewer}" exceeded ${timeoutMs}ms wall-clock budget`
|
|
2038
2057
|
)
|
|
2039
2058
|
);
|
|
2040
2059
|
}, timeoutMs);
|
|
@@ -2119,7 +2138,14 @@ async function invokeReviewer(params) {
|
|
|
2119
2138
|
if (msg.subtype === "success") {
|
|
2120
2139
|
finalText = msg.result;
|
|
2121
2140
|
} else {
|
|
2122
|
-
errorMessage =
|
|
2141
|
+
errorMessage = formatRunFailureMessage({
|
|
2142
|
+
reviewer: params.reviewer,
|
|
2143
|
+
subtype: msg.subtype,
|
|
2144
|
+
repoRoot: params.repoRoot,
|
|
2145
|
+
toolCalls,
|
|
2146
|
+
maxTurns,
|
|
2147
|
+
timeoutMs
|
|
2148
|
+
});
|
|
2123
2149
|
}
|
|
2124
2150
|
break;
|
|
2125
2151
|
}
|
|
@@ -2127,7 +2153,16 @@ async function invokeReviewer(params) {
|
|
|
2127
2153
|
} catch (err) {
|
|
2128
2154
|
if (abortController.signal.aborted) {
|
|
2129
2155
|
const reason = abortController.signal.reason instanceof Error ? abortController.signal.reason.message : String(abortController.signal.reason ?? "aborted");
|
|
2130
|
-
throw new Error(
|
|
2156
|
+
throw new Error(
|
|
2157
|
+
formatAbortMessage({
|
|
2158
|
+
reviewer: params.reviewer,
|
|
2159
|
+
reason,
|
|
2160
|
+
repoRoot: params.repoRoot,
|
|
2161
|
+
toolCalls,
|
|
2162
|
+
maxTurns,
|
|
2163
|
+
timeoutMs
|
|
2164
|
+
})
|
|
2165
|
+
);
|
|
2131
2166
|
}
|
|
2132
2167
|
throw err;
|
|
2133
2168
|
} finally {
|
|
@@ -2339,6 +2374,66 @@ function writeFailedParseSpool(repoRoot, reviewer, text) {
|
|
|
2339
2374
|
const lineCount = text === "" ? 0 : text.split("\n").length;
|
|
2340
2375
|
return { path: filepath, lineCount };
|
|
2341
2376
|
}
|
|
2377
|
+
function writeFailedRunSpool(args) {
|
|
2378
|
+
const dir = path.join(gitCommonDir(args.repoRoot), "stamp", "failed-runs");
|
|
2379
|
+
mkdirSync2(dir, { recursive: true, mode: 448 });
|
|
2380
|
+
chmodSync(dir, 448);
|
|
2381
|
+
const slug = sanitizeReviewerSlug(args.reviewer);
|
|
2382
|
+
const filename = `${Date.now()}-${slug}.log`;
|
|
2383
|
+
const filepath = path.join(dir, filename);
|
|
2384
|
+
const payload = {
|
|
2385
|
+
reviewer: args.reviewer,
|
|
2386
|
+
failure: args.failure,
|
|
2387
|
+
max_turns: args.maxTurns,
|
|
2388
|
+
timeout_ms: args.timeoutMs,
|
|
2389
|
+
tool_call_count: args.toolCalls.length,
|
|
2390
|
+
tool_calls: args.toolCalls,
|
|
2391
|
+
captured_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
2392
|
+
};
|
|
2393
|
+
writeFileSync3(filepath, JSON.stringify(payload, null, 2) + "\n", {
|
|
2394
|
+
flag: "wx",
|
|
2395
|
+
mode: 384
|
|
2396
|
+
});
|
|
2397
|
+
chmodSync(filepath, 384);
|
|
2398
|
+
return filepath;
|
|
2399
|
+
}
|
|
2400
|
+
function formatRunFailureMessage(args) {
|
|
2401
|
+
const spool = trySpool({
|
|
2402
|
+
repoRoot: args.repoRoot,
|
|
2403
|
+
reviewer: args.reviewer,
|
|
2404
|
+
failure: `subtype=${args.subtype}`,
|
|
2405
|
+
toolCalls: args.toolCalls,
|
|
2406
|
+
maxTurns: args.maxTurns,
|
|
2407
|
+
timeoutMs: args.timeoutMs
|
|
2408
|
+
});
|
|
2409
|
+
const tracePart = formatTracePart(spool, args.toolCalls.length);
|
|
2410
|
+
const hintPart = args.subtype === "error_max_turns" ? ` \u2014 raise STAMP_REVIEWER_MAX_TURNS (currently ${args.maxTurns}) or set reviewers.${args.reviewer}.max_turns in .stamp/config.yml to extend it` : "";
|
|
2411
|
+
return `reviewer "${args.reviewer}" run failed (subtype=${args.subtype})${tracePart}${hintPart}`;
|
|
2412
|
+
}
|
|
2413
|
+
function formatAbortMessage(args) {
|
|
2414
|
+
const spool = trySpool({
|
|
2415
|
+
repoRoot: args.repoRoot,
|
|
2416
|
+
reviewer: args.reviewer,
|
|
2417
|
+
failure: `abort: ${args.reason}`,
|
|
2418
|
+
toolCalls: args.toolCalls,
|
|
2419
|
+
maxTurns: args.maxTurns,
|
|
2420
|
+
timeoutMs: args.timeoutMs
|
|
2421
|
+
});
|
|
2422
|
+
const tracePart = formatTracePart(spool, args.toolCalls.length);
|
|
2423
|
+
const hintPart = ` \u2014 raise STAMP_REVIEWER_TIMEOUT_MS (currently ${args.timeoutMs}ms) or set reviewers.${args.reviewer}.timeout_ms in .stamp/config.yml to extend it`;
|
|
2424
|
+
return `${args.reason}${tracePart}${hintPart}`;
|
|
2425
|
+
}
|
|
2426
|
+
function trySpool(args) {
|
|
2427
|
+
try {
|
|
2428
|
+
return writeFailedRunSpool(args);
|
|
2429
|
+
} catch {
|
|
2430
|
+
return null;
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
function formatTracePart(spool, toolCallCount) {
|
|
2434
|
+
if (spool === null) return "";
|
|
2435
|
+
return ` \u2014 turn trace at ${spool} (${toolCallCount} tool call${toolCallCount === 1 ? "" : "s"} captured)`;
|
|
2436
|
+
}
|
|
2342
2437
|
function parseIntEnv(name, fallback) {
|
|
2343
2438
|
const raw = process.env[name];
|
|
2344
2439
|
if (!raw) return fallback;
|
|
@@ -3717,60 +3812,109 @@ function resolveMode(userMode, remoteClass) {
|
|
|
3717
3812
|
}
|
|
3718
3813
|
}
|
|
3719
3814
|
|
|
3720
|
-
// src/commands/
|
|
3721
|
-
import { spawnSync as spawnSync6 } from "child_process";
|
|
3722
|
-
import { existsSync as existsSync11, mkdtempSync, readFileSync as readFileSync8, rmSync, writeFileSync as writeFileSync9 } from "fs";
|
|
3723
|
-
import { tmpdir } from "os";
|
|
3724
|
-
import { join as join6, resolve as resolvePath } from "path";
|
|
3725
|
-
import { parse as parseYaml5 } from "yaml";
|
|
3726
|
-
|
|
3727
|
-
// src/commands/server.ts
|
|
3815
|
+
// src/commands/invites.ts
|
|
3728
3816
|
import { spawnSync as spawnSync5 } from "child_process";
|
|
3729
|
-
import {
|
|
3730
|
-
import {
|
|
3731
|
-
import {
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3817
|
+
import { request as httpRequest } from "http";
|
|
3818
|
+
import { request as httpsRequest } from "https";
|
|
3819
|
+
import { existsSync as existsSync10, readFileSync as readFileSync8 } from "fs";
|
|
3820
|
+
import { homedir as homedir2, hostname, userInfo } from "os";
|
|
3821
|
+
import { join as join6 } from "path";
|
|
3822
|
+
import { createInterface as createInterface2 } from "readline/promises";
|
|
3823
|
+
|
|
3824
|
+
// src/lib/inviteUrl.ts
|
|
3825
|
+
var TOKEN_RE = /^[A-Za-z0-9_-]{20,128}$/;
|
|
3826
|
+
var ShareUrlError = class extends Error {
|
|
3827
|
+
constructor(message) {
|
|
3828
|
+
super(message);
|
|
3829
|
+
this.name = "ShareUrlError";
|
|
3740
3830
|
}
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3831
|
+
};
|
|
3832
|
+
function parseShareUrl(input, serverFlag) {
|
|
3833
|
+
const trimmed = input.trim();
|
|
3834
|
+
if (trimmed.startsWith("stamp+invite://")) {
|
|
3835
|
+
const remainder = trimmed.slice("stamp+invite://".length);
|
|
3836
|
+
const firstSlash = remainder.indexOf("/");
|
|
3837
|
+
if (firstSlash < 0) {
|
|
3838
|
+
throw new ShareUrlError(`share URL has no token: ${JSON.stringify(input)}`);
|
|
3839
|
+
}
|
|
3840
|
+
const host = remainder.slice(0, firstSlash);
|
|
3841
|
+
let tokenPart = remainder.slice(firstSlash + 1);
|
|
3842
|
+
let insecure = false;
|
|
3843
|
+
const queryIdx = tokenPart.indexOf("?");
|
|
3844
|
+
if (queryIdx >= 0) {
|
|
3845
|
+
const query2 = tokenPart.slice(queryIdx + 1);
|
|
3846
|
+
tokenPart = tokenPart.slice(0, queryIdx);
|
|
3847
|
+
if (query2 === "insecure=1") insecure = true;
|
|
3848
|
+
}
|
|
3849
|
+
if (!TOKEN_RE.test(tokenPart)) {
|
|
3850
|
+
throw new ShareUrlError(
|
|
3851
|
+
`share URL has a malformed token: ${JSON.stringify(tokenPart)}`
|
|
3852
|
+
);
|
|
3853
|
+
}
|
|
3854
|
+
if (host.length === 0) {
|
|
3855
|
+
throw new ShareUrlError(`share URL has no host: ${JSON.stringify(input)}`);
|
|
3856
|
+
}
|
|
3857
|
+
return { host, token: tokenPart, insecure };
|
|
3745
3858
|
}
|
|
3746
|
-
if (
|
|
3747
|
-
throw new
|
|
3748
|
-
`
|
|
3859
|
+
if (!TOKEN_RE.test(trimmed)) {
|
|
3860
|
+
throw new ShareUrlError(
|
|
3861
|
+
`expected a stamp+invite:// URL or a bare token (got ${JSON.stringify(input)})`
|
|
3749
3862
|
);
|
|
3750
3863
|
}
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
`computePerRepoKeyPath: githubRepo must be exactly <owner>/<repo>: ${githubRepo}`
|
|
3864
|
+
if (!serverFlag) {
|
|
3865
|
+
throw new ShareUrlError(
|
|
3866
|
+
"bare token requires --server <host>:<port> so we know where to POST"
|
|
3755
3867
|
);
|
|
3756
3868
|
}
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3869
|
+
return { host: serverFlag, token: trimmed, insecure: false };
|
|
3870
|
+
}
|
|
3871
|
+
|
|
3872
|
+
// src/lib/sshKeys.ts
|
|
3873
|
+
import { createHash as createHash5 } from "crypto";
|
|
3874
|
+
var ALLOWED_ALGOS = /* @__PURE__ */ new Set([
|
|
3875
|
+
"ssh-ed25519",
|
|
3876
|
+
"ssh-rsa",
|
|
3877
|
+
"ecdsa-sha2-nistp256",
|
|
3878
|
+
"ecdsa-sha2-nistp384",
|
|
3879
|
+
"ecdsa-sha2-nistp521"
|
|
3880
|
+
]);
|
|
3881
|
+
function parseSshPubkey(line) {
|
|
3882
|
+
const trimmed = line.trim();
|
|
3883
|
+
if (trimmed.length === 0) {
|
|
3884
|
+
throw new Error("ssh pubkey line is empty");
|
|
3762
3885
|
}
|
|
3763
|
-
if (
|
|
3764
|
-
throw new Error(
|
|
3765
|
-
`computePerRepoKeyPath: owner must match [A-Za-z0-9-]+ (got "${owner}" in "${githubRepo}")`
|
|
3766
|
-
);
|
|
3886
|
+
if (trimmed.startsWith("#")) {
|
|
3887
|
+
throw new Error("ssh pubkey line is a comment");
|
|
3767
3888
|
}
|
|
3768
|
-
|
|
3889
|
+
const parts = trimmed.split(/\s+/);
|
|
3890
|
+
if (parts.length < 2) {
|
|
3769
3891
|
throw new Error(
|
|
3770
|
-
|
|
3892
|
+
"ssh pubkey line must have at least <algorithm> <base64> tokens"
|
|
3771
3893
|
);
|
|
3772
3894
|
}
|
|
3773
|
-
|
|
3895
|
+
const [algorithm, b64, ...rest] = parts;
|
|
3896
|
+
if (!ALLOWED_ALGOS.has(algorithm)) {
|
|
3897
|
+
throw new Error(`unsupported ssh pubkey algorithm: ${algorithm}`);
|
|
3898
|
+
}
|
|
3899
|
+
const keyBlob = Buffer.from(b64, "base64");
|
|
3900
|
+
if (keyBlob.length === 0) {
|
|
3901
|
+
throw new Error("ssh pubkey base64 blob is empty");
|
|
3902
|
+
}
|
|
3903
|
+
if (keyBlob.toString("base64").replace(/=+$/, "") !== b64.replace(/=+$/, "")) {
|
|
3904
|
+
throw new Error("ssh pubkey base64 blob has trailing junk");
|
|
3905
|
+
}
|
|
3906
|
+
return {
|
|
3907
|
+
algorithm,
|
|
3908
|
+
keyBlob,
|
|
3909
|
+
comment: rest.join(" "),
|
|
3910
|
+
full: trimmed,
|
|
3911
|
+
fingerprint: sshFingerprintFromBlob(keyBlob)
|
|
3912
|
+
};
|
|
3913
|
+
}
|
|
3914
|
+
function sshFingerprintFromBlob(keyBlob) {
|
|
3915
|
+
const hash = createHash5("sha256").update(keyBlob).digest();
|
|
3916
|
+
const b64 = hash.toString("base64").replace(/=+$/, "");
|
|
3917
|
+
return `SHA256:${b64}`;
|
|
3774
3918
|
}
|
|
3775
3919
|
|
|
3776
3920
|
// src/commands/serverRepo.ts
|
|
@@ -3951,12 +4095,301 @@ function validateGithubRepoSpec(spec) {
|
|
|
3951
4095
|
}
|
|
3952
4096
|
function prompt(question) {
|
|
3953
4097
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
3954
|
-
return new Promise((
|
|
4098
|
+
return new Promise((resolve3) => {
|
|
3955
4099
|
rl.question(question, (answer) => {
|
|
3956
4100
|
rl.close();
|
|
3957
|
-
|
|
4101
|
+
resolve3(answer);
|
|
4102
|
+
});
|
|
4103
|
+
});
|
|
4104
|
+
}
|
|
4105
|
+
|
|
4106
|
+
// src/commands/invites.ts
|
|
4107
|
+
var MINT_EXIT = {
|
|
4108
|
+
CONFIG: 1,
|
|
4109
|
+
USAGE: 2,
|
|
4110
|
+
ROLE_FORBIDDEN: 3,
|
|
4111
|
+
NAME_TAKEN: 4
|
|
4112
|
+
};
|
|
4113
|
+
function runInvitesMint(opts) {
|
|
4114
|
+
const server2 = loadServerConfig();
|
|
4115
|
+
if (!server2) {
|
|
4116
|
+
throw new UsageError(
|
|
4117
|
+
"no ~/.stamp/server.yml \u2014 run `stamp server config <host>:<port>` first (or `stamp provision` to set up a new server)"
|
|
4118
|
+
);
|
|
4119
|
+
}
|
|
4120
|
+
const result = spawnSync5(
|
|
4121
|
+
"ssh",
|
|
4122
|
+
[
|
|
4123
|
+
"-p",
|
|
4124
|
+
String(server2.port),
|
|
4125
|
+
"--",
|
|
4126
|
+
`${server2.user}@${server2.host}`,
|
|
4127
|
+
"stamp-mint-invite",
|
|
4128
|
+
opts.shortName,
|
|
4129
|
+
"--role",
|
|
4130
|
+
opts.role
|
|
4131
|
+
],
|
|
4132
|
+
{
|
|
4133
|
+
// Capture stdout (the share URL) for clean re-emit; let stderr
|
|
4134
|
+
// flow through to the operator's terminal verbatim — the
|
|
4135
|
+
// server-side wrapper writes its prose there.
|
|
4136
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
4137
|
+
encoding: "utf8"
|
|
4138
|
+
}
|
|
4139
|
+
);
|
|
4140
|
+
if (result.status === 0) {
|
|
4141
|
+
const shareUrl = result.stdout.trim();
|
|
4142
|
+
if (!shareUrl.startsWith("stamp+invite://")) {
|
|
4143
|
+
throw new Error(
|
|
4144
|
+
`unexpected server output (no stamp+invite:// URL on stdout). Got: ${JSON.stringify(shareUrl.slice(0, 120))}`
|
|
4145
|
+
);
|
|
4146
|
+
}
|
|
4147
|
+
process.stdout.write(shareUrl + "\n");
|
|
4148
|
+
return;
|
|
4149
|
+
}
|
|
4150
|
+
switch (result.status) {
|
|
4151
|
+
case MINT_EXIT.ROLE_FORBIDDEN:
|
|
4152
|
+
throw new UsageError(
|
|
4153
|
+
`your account on ${server2.host} doesn't permit minting invites (role must be admin or owner). Ask an admin to mint the invite for you, or to promote your account.`
|
|
4154
|
+
);
|
|
4155
|
+
case MINT_EXIT.NAME_TAKEN:
|
|
4156
|
+
throw new UsageError(
|
|
4157
|
+
`short_name ${JSON.stringify(opts.shortName)} is already in use on ${server2.host}. Pick a different name.`
|
|
4158
|
+
);
|
|
4159
|
+
case MINT_EXIT.USAGE:
|
|
4160
|
+
throw new UsageError(
|
|
4161
|
+
`server rejected the mint request (usage error). Verify the short_name is alphanumerics + . _ - (max 63 chars) and --role is 'admin' or 'member'.`
|
|
4162
|
+
);
|
|
4163
|
+
case MINT_EXIT.CONFIG:
|
|
4164
|
+
throw new Error(
|
|
4165
|
+
`server-side configuration error against ${server2.host}. The server may be missing STAMP_PUBLIC_URL or 'ExposeAuthInfo yes' in sshd_config. See server-side log for specifics.`
|
|
4166
|
+
);
|
|
4167
|
+
default:
|
|
4168
|
+
throw new Error(
|
|
4169
|
+
`mint failed against ${server2.user}@${server2.host}:${server2.port} (exit ${result.status}). Common causes: server unreachable, your SSH key isn't enrolled, or the server image is older than the invite-mint feature \u2014 redeploy if so.`
|
|
4170
|
+
);
|
|
4171
|
+
}
|
|
4172
|
+
}
|
|
4173
|
+
function defaultSshPubkeyPath() {
|
|
4174
|
+
return join6(homedir2(), ".ssh", "id_ed25519.pub");
|
|
4175
|
+
}
|
|
4176
|
+
function defaultStampPubkeyPath() {
|
|
4177
|
+
return join6(homedir2(), ".stamp", "keys", "ed25519.pub");
|
|
4178
|
+
}
|
|
4179
|
+
function defaultShortName() {
|
|
4180
|
+
const u = userInfo().username || "user";
|
|
4181
|
+
const h = hostname().split(".")[0] || "host";
|
|
4182
|
+
return `${u}-${h}`.toLowerCase().replace(/[^a-z0-9._-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
|
|
4183
|
+
}
|
|
4184
|
+
function loadSshPubkey(path2) {
|
|
4185
|
+
if (!existsSync10(path2)) {
|
|
4186
|
+
throw new UsageError(
|
|
4187
|
+
`SSH pubkey not found at ${path2}. Generate one with \`ssh-keygen -t ed25519\` or pass --ssh-pubkey <path>.`
|
|
4188
|
+
);
|
|
4189
|
+
}
|
|
4190
|
+
const raw = readFileSync8(path2, "utf8");
|
|
4191
|
+
const parsed = parseSshPubkey(raw);
|
|
4192
|
+
return { path: path2, full: parsed.full, fingerprint: parsed.fingerprint };
|
|
4193
|
+
}
|
|
4194
|
+
function loadStampPubkey(path2) {
|
|
4195
|
+
if (!existsSync10(path2)) return null;
|
|
4196
|
+
const pem = readFileSync8(path2, "utf8");
|
|
4197
|
+
try {
|
|
4198
|
+
const fp = fingerprintFromPem(pem);
|
|
4199
|
+
return { path: path2, pem, fingerprint: fp };
|
|
4200
|
+
} catch (e) {
|
|
4201
|
+
throw new UsageError(
|
|
4202
|
+
`stamp signing pubkey at ${path2} is malformed: ${e.message}`
|
|
4203
|
+
);
|
|
4204
|
+
}
|
|
4205
|
+
}
|
|
4206
|
+
async function postAccept(target, body) {
|
|
4207
|
+
const payload = Buffer.from(JSON.stringify(body), "utf8");
|
|
4208
|
+
const requestFn = target.insecure ? httpRequest : httpsRequest;
|
|
4209
|
+
return new Promise((resolve3, reject) => {
|
|
4210
|
+
const req = requestFn(
|
|
4211
|
+
`${target.insecure ? "http" : "https"}://${target.host}/invite/accept`,
|
|
4212
|
+
{
|
|
4213
|
+
method: "POST",
|
|
4214
|
+
headers: {
|
|
4215
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
4216
|
+
"Content-Length": payload.length.toString(),
|
|
4217
|
+
Accept: "application/json"
|
|
4218
|
+
}
|
|
4219
|
+
},
|
|
4220
|
+
(res) => {
|
|
4221
|
+
const chunks = [];
|
|
4222
|
+
res.on("data", (c) => chunks.push(c));
|
|
4223
|
+
res.on("end", () => {
|
|
4224
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
4225
|
+
let parsed;
|
|
4226
|
+
try {
|
|
4227
|
+
parsed = JSON.parse(text);
|
|
4228
|
+
} catch {
|
|
4229
|
+
parsed = { raw: text };
|
|
4230
|
+
}
|
|
4231
|
+
resolve3({ status: res.statusCode ?? 0, body: parsed });
|
|
4232
|
+
});
|
|
4233
|
+
}
|
|
4234
|
+
);
|
|
4235
|
+
req.on("error", reject);
|
|
4236
|
+
req.write(payload);
|
|
4237
|
+
req.end();
|
|
4238
|
+
});
|
|
4239
|
+
}
|
|
4240
|
+
function nextStepForError(error, opts) {
|
|
4241
|
+
switch (error) {
|
|
4242
|
+
case "invite_not_found":
|
|
4243
|
+
return "the token doesn't match any pending invite. Ask the inviter to mint a fresh one.";
|
|
4244
|
+
case "invite_expired":
|
|
4245
|
+
return "the invite expired (15-minute TTL). Ask the inviter to mint a fresh one.";
|
|
4246
|
+
case "invite_already_consumed":
|
|
4247
|
+
return "the invite has already been redeemed. Ask the inviter to mint a fresh one.";
|
|
4248
|
+
case "ssh_pubkey_already_registered":
|
|
4249
|
+
return "your SSH public key is already enrolled on this server. Try `git push` / `stamp` directly \u2014 you may already have access. If not, ask an admin to look up the existing account.";
|
|
4250
|
+
case "short_name_taken":
|
|
4251
|
+
return `pass --short-name <other-name> (current attempt: ${JSON.stringify(opts.shortName ?? defaultShortName())}).`;
|
|
4252
|
+
case "short_name_malformed":
|
|
4253
|
+
return "pass --short-name with alphanumerics + . _ - (must start with alnum, max 63 chars).";
|
|
4254
|
+
case "ssh_pubkey_required":
|
|
4255
|
+
case "token_required":
|
|
4256
|
+
return "this is a client bug \u2014 file an issue at https://github.com/OpenThinkAi/stamp-cli/issues with the command you ran.";
|
|
4257
|
+
case "content_type_must_be_application_json":
|
|
4258
|
+
case "body_too_large":
|
|
4259
|
+
case "body_not_json":
|
|
4260
|
+
case "body_read_failed":
|
|
4261
|
+
return "this is a client bug \u2014 file an issue at https://github.com/OpenThinkAi/stamp-cli/issues with the command you ran.";
|
|
4262
|
+
default:
|
|
4263
|
+
if (error.startsWith("ssh_pubkey_invalid")) {
|
|
4264
|
+
return "the SSH pubkey is malformed. Verify ~/.ssh/id_ed25519.pub or pass --ssh-pubkey <path>.";
|
|
4265
|
+
}
|
|
4266
|
+
return "see the server error code above for context.";
|
|
4267
|
+
}
|
|
4268
|
+
}
|
|
4269
|
+
async function runInvitesAccept(opts) {
|
|
4270
|
+
let target;
|
|
4271
|
+
try {
|
|
4272
|
+
target = parseShareUrl(opts.urlOrToken, opts.server);
|
|
4273
|
+
} catch (e) {
|
|
4274
|
+
if (e instanceof ShareUrlError) throw new UsageError(e.message);
|
|
4275
|
+
throw e;
|
|
4276
|
+
}
|
|
4277
|
+
const sshPath = opts.sshPubkeyPath ?? defaultSshPubkeyPath();
|
|
4278
|
+
const ssh = loadSshPubkey(sshPath);
|
|
4279
|
+
const stampPath = opts.stampPubkeyPath ?? defaultStampPubkeyPath();
|
|
4280
|
+
const stamp = loadStampPubkey(stampPath);
|
|
4281
|
+
const shortName = opts.shortName ?? defaultShortName();
|
|
4282
|
+
const isInteractive = process.stdin.isTTY === true;
|
|
4283
|
+
let confirmed = opts.yes === true;
|
|
4284
|
+
if (!confirmed) {
|
|
4285
|
+
if (!isInteractive) {
|
|
4286
|
+
throw new UsageError(
|
|
4287
|
+
"non-interactive stdin: pass --yes to skip confirmation (after supplying any overrides via --ssh-pubkey / --stamp-pubkey / --short-name)"
|
|
4288
|
+
);
|
|
4289
|
+
}
|
|
4290
|
+
const rl = createInterface2({
|
|
4291
|
+
input: process.stdin,
|
|
4292
|
+
output: process.stdout
|
|
3958
4293
|
});
|
|
4294
|
+
try {
|
|
4295
|
+
process.stdout.write(
|
|
4296
|
+
`Accepting invite at ${target.insecure ? "http" : "https"}://${target.host}
|
|
4297
|
+
|
|
4298
|
+
ssh pubkey: ${ssh.path}
|
|
4299
|
+
${ssh.fingerprint}
|
|
4300
|
+
` + (stamp ? ` stamp pubkey: ${stamp.path}
|
|
4301
|
+
${stamp.fingerprint}
|
|
4302
|
+
` : ` stamp pubkey: (none detected at ${stampPath} \u2014 okay; trust grants need it later)
|
|
4303
|
+
`) + ` short_name: ${shortName}
|
|
4304
|
+
|
|
4305
|
+
`
|
|
4306
|
+
);
|
|
4307
|
+
const answer = (await rl.question("Send these to the server? [Y/n] ")).trim().toLowerCase();
|
|
4308
|
+
if (answer === "" || answer === "y" || answer === "yes") {
|
|
4309
|
+
confirmed = true;
|
|
4310
|
+
} else {
|
|
4311
|
+
process.stdout.write("aborted.\n");
|
|
4312
|
+
return;
|
|
4313
|
+
}
|
|
4314
|
+
} finally {
|
|
4315
|
+
rl.close();
|
|
4316
|
+
}
|
|
4317
|
+
}
|
|
4318
|
+
const response = await postAccept(target, {
|
|
4319
|
+
token: target.token,
|
|
4320
|
+
ssh_pubkey: ssh.full,
|
|
4321
|
+
stamp_pubkey: stamp?.pem ?? null,
|
|
4322
|
+
short_name: shortName
|
|
3959
4323
|
});
|
|
4324
|
+
const body = response.body;
|
|
4325
|
+
if (response.status === 200 && body.ok) {
|
|
4326
|
+
process.stdout.write(
|
|
4327
|
+
`\u2713 enrolled as ${body.role} (user_id=${body.user_id}). You can now \`git push\` and \`stamp\` against ${target.host}.
|
|
4328
|
+
`
|
|
4329
|
+
);
|
|
4330
|
+
return;
|
|
4331
|
+
}
|
|
4332
|
+
const errorText = body.error ?? `http_${response.status}`;
|
|
4333
|
+
const nextStep = nextStepForError(errorText, opts);
|
|
4334
|
+
throw new Error(
|
|
4335
|
+
`accept-invite failed: ${errorText} (status ${response.status}). ${nextStep}`
|
|
4336
|
+
);
|
|
4337
|
+
}
|
|
4338
|
+
|
|
4339
|
+
// src/commands/provision.ts
|
|
4340
|
+
import { spawnSync as spawnSync7 } from "child_process";
|
|
4341
|
+
import { existsSync as existsSync12, mkdtempSync, readFileSync as readFileSync9, rmSync, writeFileSync as writeFileSync9 } from "fs";
|
|
4342
|
+
import { tmpdir } from "os";
|
|
4343
|
+
import { join as join7, resolve as resolvePath } from "path";
|
|
4344
|
+
import { parse as parseYaml5 } from "yaml";
|
|
4345
|
+
|
|
4346
|
+
// src/commands/server.ts
|
|
4347
|
+
import { spawnSync as spawnSync6 } from "child_process";
|
|
4348
|
+
import { existsSync as existsSync11, mkdirSync as mkdirSync4, renameSync as renameSync3, unlinkSync as unlinkSync2, writeFileSync as writeFileSync8 } from "fs";
|
|
4349
|
+
import { dirname as dirname4 } from "path";
|
|
4350
|
+
import { stringify as stringifyYaml2 } from "yaml";
|
|
4351
|
+
|
|
4352
|
+
// src/lib/perRepoKey.ts
|
|
4353
|
+
var VALID_OWNER = /^[A-Za-z0-9-]+$/;
|
|
4354
|
+
var VALID_REPO = /^[A-Za-z0-9._-]+$/;
|
|
4355
|
+
var SSH_CLIENT_KEY_DIR = "/srv/git/.ssh-client-keys";
|
|
4356
|
+
function computePerRepoKeyPath(githubRepo) {
|
|
4357
|
+
if (typeof githubRepo !== "string" || githubRepo.length === 0) {
|
|
4358
|
+
throw new Error("computePerRepoKeyPath: githubRepo must be a non-empty string");
|
|
4359
|
+
}
|
|
4360
|
+
if (githubRepo.startsWith("-")) {
|
|
4361
|
+
throw new Error(
|
|
4362
|
+
`computePerRepoKeyPath: githubRepo must not start with '-': ${githubRepo}`
|
|
4363
|
+
);
|
|
4364
|
+
}
|
|
4365
|
+
if (githubRepo.includes("..")) {
|
|
4366
|
+
throw new Error(
|
|
4367
|
+
`computePerRepoKeyPath: githubRepo must not contain '..': ${githubRepo}`
|
|
4368
|
+
);
|
|
4369
|
+
}
|
|
4370
|
+
const slashCount = (githubRepo.match(/\//g) ?? []).length;
|
|
4371
|
+
if (slashCount !== 1) {
|
|
4372
|
+
throw new Error(
|
|
4373
|
+
`computePerRepoKeyPath: githubRepo must be exactly <owner>/<repo>: ${githubRepo}`
|
|
4374
|
+
);
|
|
4375
|
+
}
|
|
4376
|
+
const [owner, repo] = githubRepo.split("/");
|
|
4377
|
+
if (!owner || !repo) {
|
|
4378
|
+
throw new Error(
|
|
4379
|
+
`computePerRepoKeyPath: owner and repo halves must both be non-empty: ${githubRepo}`
|
|
4380
|
+
);
|
|
4381
|
+
}
|
|
4382
|
+
if (!VALID_OWNER.test(owner)) {
|
|
4383
|
+
throw new Error(
|
|
4384
|
+
`computePerRepoKeyPath: owner must match [A-Za-z0-9-]+ (got "${owner}" in "${githubRepo}")`
|
|
4385
|
+
);
|
|
4386
|
+
}
|
|
4387
|
+
if (!VALID_REPO.test(repo)) {
|
|
4388
|
+
throw new Error(
|
|
4389
|
+
`computePerRepoKeyPath: repo must match [A-Za-z0-9._-]+ (got "${repo}" in "${githubRepo}")`
|
|
4390
|
+
);
|
|
4391
|
+
}
|
|
4392
|
+
return `${SSH_CLIENT_KEY_DIR}/${owner}_${repo}_ed25519`;
|
|
3960
4393
|
}
|
|
3961
4394
|
|
|
3962
4395
|
// src/commands/server.ts
|
|
@@ -3989,7 +4422,7 @@ function runServerConfig(opts) {
|
|
|
3989
4422
|
}
|
|
3990
4423
|
function showConfig() {
|
|
3991
4424
|
const path2 = userServerConfigPath();
|
|
3992
|
-
if (!
|
|
4425
|
+
if (!existsSync11(path2)) {
|
|
3993
4426
|
console.log(`note: no stamp server configured (${path2} does not exist)`);
|
|
3994
4427
|
console.log(`note: run \`stamp server config <host:port>\` to create one`);
|
|
3995
4428
|
return;
|
|
@@ -4007,7 +4440,7 @@ function showConfig() {
|
|
|
4007
4440
|
}
|
|
4008
4441
|
function unsetConfig() {
|
|
4009
4442
|
const path2 = userServerConfigPath();
|
|
4010
|
-
if (!
|
|
4443
|
+
if (!existsSync11(path2)) {
|
|
4011
4444
|
console.log(`note: ${path2} does not exist; nothing to remove`);
|
|
4012
4445
|
return;
|
|
4013
4446
|
}
|
|
@@ -4015,7 +4448,7 @@ function unsetConfig() {
|
|
|
4015
4448
|
console.log(`removed ${path2}`);
|
|
4016
4449
|
}
|
|
4017
4450
|
function fetchServerPubkey(server2, mirror) {
|
|
4018
|
-
const
|
|
4451
|
+
const sshArgs2 = [
|
|
4019
4452
|
"-p",
|
|
4020
4453
|
String(server2.port),
|
|
4021
4454
|
"--",
|
|
@@ -4023,9 +4456,9 @@ function fetchServerPubkey(server2, mirror) {
|
|
|
4023
4456
|
"stamp-server-pubkey"
|
|
4024
4457
|
];
|
|
4025
4458
|
if (mirror) {
|
|
4026
|
-
|
|
4459
|
+
sshArgs2.push(`${mirror.owner}/${mirror.repo}`);
|
|
4027
4460
|
}
|
|
4028
|
-
const result =
|
|
4461
|
+
const result = spawnSync6("ssh", sshArgs2, {
|
|
4029
4462
|
stdio: ["ignore", "pipe", "inherit"],
|
|
4030
4463
|
encoding: "utf8"
|
|
4031
4464
|
});
|
|
@@ -4073,7 +4506,7 @@ function writeConfig(opts) {
|
|
|
4073
4506
|
});
|
|
4074
4507
|
const path2 = userServerConfigPath();
|
|
4075
4508
|
const dir = dirname4(path2);
|
|
4076
|
-
if (!
|
|
4509
|
+
if (!existsSync11(dir)) mkdirSync4(dir, { recursive: true, mode: 448 });
|
|
4077
4510
|
const tmp = `${path2}.tmp.${process.pid}`;
|
|
4078
4511
|
writeFileSync8(tmp, yaml, { mode: 384 });
|
|
4079
4512
|
renameSync3(tmp, path2);
|
|
@@ -4135,7 +4568,7 @@ See docs/quickstart-server.md for how to deploy a stamp server first.`
|
|
|
4135
4568
|
return;
|
|
4136
4569
|
}
|
|
4137
4570
|
const cloneTarget = resolvePath(opts.into ?? opts.name);
|
|
4138
|
-
if (
|
|
4571
|
+
if (existsSync12(cloneTarget)) {
|
|
4139
4572
|
throw new Error(
|
|
4140
4573
|
`clone destination already exists: ${cloneTarget}. Move or remove it, or pass --into <other-path>.`
|
|
4141
4574
|
);
|
|
@@ -4212,7 +4645,7 @@ function printPlan2(args) {
|
|
|
4212
4645
|
console.log(bar);
|
|
4213
4646
|
}
|
|
4214
4647
|
function provisionBareRepoOnServer(server2, name) {
|
|
4215
|
-
const result =
|
|
4648
|
+
const result = spawnSync7(
|
|
4216
4649
|
"ssh",
|
|
4217
4650
|
[
|
|
4218
4651
|
"-p",
|
|
@@ -4238,7 +4671,7 @@ function createGithubMirrorRepo(owner, repo, privateRepo) {
|
|
|
4238
4671
|
);
|
|
4239
4672
|
}
|
|
4240
4673
|
const visibility = privateRepo ? "--private" : "--public";
|
|
4241
|
-
const result =
|
|
4674
|
+
const result = spawnSync7(
|
|
4242
4675
|
"gh",
|
|
4243
4676
|
["repo", "create", `${owner}/${repo}`, visibility],
|
|
4244
4677
|
{ stdio: ["ignore", "inherit", "inherit"] }
|
|
@@ -4385,9 +4818,9 @@ async function runMigrateExisting(opts, server2) {
|
|
|
4385
4818
|
console.log("\n(dry run \u2014 no changes made)");
|
|
4386
4819
|
return;
|
|
4387
4820
|
}
|
|
4388
|
-
const stagingDir = mkdtempSync(
|
|
4389
|
-
const bareCloneDir =
|
|
4390
|
-
const tarballPath =
|
|
4821
|
+
const stagingDir = mkdtempSync(join7(tmpdir(), "stamp-migrate-"));
|
|
4822
|
+
const bareCloneDir = join7(stagingDir, `${opts.name}.git`);
|
|
4823
|
+
const tarballPath = join7(stagingDir, `${opts.name}.tar.gz`);
|
|
4391
4824
|
try {
|
|
4392
4825
|
console.log(`
|
|
4393
4826
|
Building bare-clone tarball of existing repo`);
|
|
@@ -4423,9 +4856,9 @@ function ensureCwdIsGitRepo(cwd) {
|
|
|
4423
4856
|
}
|
|
4424
4857
|
}
|
|
4425
4858
|
function ensureStampInitDone(cwd) {
|
|
4426
|
-
if (!
|
|
4859
|
+
if (!existsSync12(join7(cwd, ".stamp", "config.yml"))) {
|
|
4427
4860
|
throw new Error(
|
|
4428
|
-
`--migrate-existing expects this repo to already be stamp-init'd (${
|
|
4861
|
+
`--migrate-existing expects this repo to already be stamp-init'd (${join7(cwd, ".stamp/config.yml")} not found). Run \`stamp init --mode local-only\` first, calibrate your reviewers, then re-run with --migrate-existing.`
|
|
4429
4862
|
);
|
|
4430
4863
|
}
|
|
4431
4864
|
}
|
|
@@ -4455,7 +4888,7 @@ function ensureWorkingTreeClean(cwd) {
|
|
|
4455
4888
|
}
|
|
4456
4889
|
}
|
|
4457
4890
|
function runTarGz(parentDir, dirName, outputPath) {
|
|
4458
|
-
const result =
|
|
4891
|
+
const result = spawnSync7("tar", ["-czf", outputPath, "-C", parentDir, dirName], {
|
|
4459
4892
|
stdio: ["ignore", "inherit", "inherit"]
|
|
4460
4893
|
});
|
|
4461
4894
|
if (result.status !== 0) {
|
|
@@ -4465,7 +4898,7 @@ function runTarGz(parentDir, dirName, outputPath) {
|
|
|
4465
4898
|
}
|
|
4466
4899
|
}
|
|
4467
4900
|
function scpToServer(server2, localPath, remotePath) {
|
|
4468
|
-
const result =
|
|
4901
|
+
const result = spawnSync7(
|
|
4469
4902
|
"scp",
|
|
4470
4903
|
[
|
|
4471
4904
|
"-P",
|
|
@@ -4483,7 +4916,7 @@ function scpToServer(server2, localPath, remotePath) {
|
|
|
4483
4916
|
}
|
|
4484
4917
|
}
|
|
4485
4918
|
function sshRunNewStampRepoFromTarball(server2, name, remoteTarballPath) {
|
|
4486
|
-
const result =
|
|
4919
|
+
const result = spawnSync7(
|
|
4487
4920
|
"ssh",
|
|
4488
4921
|
[
|
|
4489
4922
|
"-p",
|
|
@@ -4550,15 +4983,15 @@ mirror.yml was added to .stamp/. Commit it through the normal stamp flow:`);
|
|
|
4550
4983
|
}
|
|
4551
4984
|
}
|
|
4552
4985
|
function readMirrorYmlGithubRepo(repoRoot) {
|
|
4553
|
-
const path2 =
|
|
4554
|
-
if (!
|
|
4986
|
+
const path2 = join7(repoRoot, ".stamp", "mirror.yml");
|
|
4987
|
+
if (!existsSync12(path2)) {
|
|
4555
4988
|
throw new Error(
|
|
4556
4989
|
`${path2} not found \u2014 --migrate-bypass operates on an already-server-gated repo, but this cwd has no .stamp/mirror.yml. If the repo is not yet server-gated, provision it first with \`stamp provision --migrate-existing\`.`
|
|
4557
4990
|
);
|
|
4558
4991
|
}
|
|
4559
4992
|
let raw;
|
|
4560
4993
|
try {
|
|
4561
|
-
raw =
|
|
4994
|
+
raw = readFileSync9(path2, "utf8");
|
|
4562
4995
|
} catch (err) {
|
|
4563
4996
|
throw new Error(
|
|
4564
4997
|
`could not read ${path2}: ${err instanceof Error ? err.message : String(err)}`
|
|
@@ -4789,9 +5222,366 @@ Direct \`git push origin main\` from any non-stamp source will be rejected.`
|
|
|
4789
5222
|
}
|
|
4790
5223
|
}
|
|
4791
5224
|
|
|
5225
|
+
// src/commands/trust.ts
|
|
5226
|
+
import { spawnSync as spawnSync8 } from "child_process";
|
|
5227
|
+
import {
|
|
5228
|
+
createPublicKey,
|
|
5229
|
+
createHash as createHash6
|
|
5230
|
+
} from "crypto";
|
|
5231
|
+
import {
|
|
5232
|
+
existsSync as existsSync13,
|
|
5233
|
+
readdirSync as readdirSync2,
|
|
5234
|
+
readFileSync as readFileSync10,
|
|
5235
|
+
writeFileSync as writeFileSync10
|
|
5236
|
+
} from "fs";
|
|
5237
|
+
import { join as join8, resolve } from "path";
|
|
5238
|
+
var USERS_EXIT = {
|
|
5239
|
+
OK: 0,
|
|
5240
|
+
CONFIG: 1,
|
|
5241
|
+
USAGE: 2,
|
|
5242
|
+
AUTHORITY: 3,
|
|
5243
|
+
NOT_FOUND: 4
|
|
5244
|
+
};
|
|
5245
|
+
var SHORT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/;
|
|
5246
|
+
function resolveServer2() {
|
|
5247
|
+
const server2 = loadServerConfig();
|
|
5248
|
+
if (!server2) {
|
|
5249
|
+
throw new UsageError(
|
|
5250
|
+
"no ~/.stamp/server.yml \u2014 run `stamp server config <host>:<port>` first"
|
|
5251
|
+
);
|
|
5252
|
+
}
|
|
5253
|
+
return server2;
|
|
5254
|
+
}
|
|
5255
|
+
function fetchStampPubkey(server2, shortName) {
|
|
5256
|
+
const result = spawnSync8(
|
|
5257
|
+
"ssh",
|
|
5258
|
+
[
|
|
5259
|
+
"-p",
|
|
5260
|
+
String(server2.port),
|
|
5261
|
+
"--",
|
|
5262
|
+
`${server2.user}@${server2.host}`,
|
|
5263
|
+
"stamp-users",
|
|
5264
|
+
"get-stamp-pubkey",
|
|
5265
|
+
shortName
|
|
5266
|
+
],
|
|
5267
|
+
{ stdio: ["ignore", "pipe", "inherit"], encoding: "utf8" }
|
|
5268
|
+
);
|
|
5269
|
+
if (result.status === 0) {
|
|
5270
|
+
return result.stdout;
|
|
5271
|
+
}
|
|
5272
|
+
if (result.status === USERS_EXIT.NOT_FOUND) {
|
|
5273
|
+
throw new UsageError(
|
|
5274
|
+
`${server2.host}: user ${JSON.stringify(shortName)} is either not enrolled or has no stamp signing pubkey on file. Run \`stamp users list\` to confirm enrollment; ask the user to re-run \`stamp invites accept --stamp-pubkey <path>\` if their signing key was missing at invite time.`
|
|
5275
|
+
);
|
|
5276
|
+
}
|
|
5277
|
+
if (result.status === USERS_EXIT.CONFIG) {
|
|
5278
|
+
throw new Error(
|
|
5279
|
+
`server-side identity binding failed against ${server2.host}. Your SSH key may not be enrolled in the membership DB yet, or the server is missing 'ExposeAuthInfo yes' in sshd_config.`
|
|
5280
|
+
);
|
|
5281
|
+
}
|
|
5282
|
+
throw new Error(
|
|
5283
|
+
`fetching stamp_pubkey for ${JSON.stringify(shortName)} from ${server2.user}@${server2.host}:${server2.port} failed (exit ${result.status}). Common causes: server unreachable, your SSH key isn't enrolled, or the server image predates the get-stamp-pubkey subcommand.`
|
|
5284
|
+
);
|
|
5285
|
+
}
|
|
5286
|
+
function fingerprintFromPem2(pem) {
|
|
5287
|
+
const pub = createPublicKey(pem);
|
|
5288
|
+
const raw = pub.export({ type: "spki", format: "der" });
|
|
5289
|
+
return `sha256:${createHash6("sha256").update(raw).digest("hex")}`;
|
|
5290
|
+
}
|
|
5291
|
+
function findExistingTrustedKey(repoRoot, fingerprint) {
|
|
5292
|
+
const dir = stampTrustedKeysDir(repoRoot);
|
|
5293
|
+
if (!existsSync13(dir)) return null;
|
|
5294
|
+
for (const f of readdirSync2(dir)) {
|
|
5295
|
+
if (!f.endsWith(".pub")) continue;
|
|
5296
|
+
const fullPath = join8(dir, f);
|
|
5297
|
+
let pem;
|
|
5298
|
+
try {
|
|
5299
|
+
pem = readFileSync10(fullPath, "utf8");
|
|
5300
|
+
} catch {
|
|
5301
|
+
continue;
|
|
5302
|
+
}
|
|
5303
|
+
try {
|
|
5304
|
+
if (fingerprintFromPem2(pem) === fingerprint) return f;
|
|
5305
|
+
} catch {
|
|
5306
|
+
}
|
|
5307
|
+
}
|
|
5308
|
+
return null;
|
|
5309
|
+
}
|
|
5310
|
+
function runGit2(args, cwd) {
|
|
5311
|
+
const result = spawnSync8("git", args, {
|
|
5312
|
+
cwd,
|
|
5313
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
5314
|
+
encoding: "utf8"
|
|
5315
|
+
});
|
|
5316
|
+
if (result.status !== 0) {
|
|
5317
|
+
throw new Error(
|
|
5318
|
+
`git ${args.join(" ")} failed (exit ${result.status}):
|
|
5319
|
+
${result.stderr ?? ""}`.trim()
|
|
5320
|
+
);
|
|
5321
|
+
}
|
|
5322
|
+
return result.stdout ?? "";
|
|
5323
|
+
}
|
|
5324
|
+
function workingTreeClean(repoRoot) {
|
|
5325
|
+
const result = spawnSync8("git", ["status", "--porcelain"], {
|
|
5326
|
+
cwd: repoRoot,
|
|
5327
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
5328
|
+
encoding: "utf8"
|
|
5329
|
+
});
|
|
5330
|
+
if (result.status !== 0) return false;
|
|
5331
|
+
return (result.stdout ?? "").trim().length === 0;
|
|
5332
|
+
}
|
|
5333
|
+
function branchExists2(repoRoot, branch) {
|
|
5334
|
+
const result = spawnSync8(
|
|
5335
|
+
"git",
|
|
5336
|
+
["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
|
|
5337
|
+
{ cwd: repoRoot, stdio: "ignore" }
|
|
5338
|
+
);
|
|
5339
|
+
return result.status === 0;
|
|
5340
|
+
}
|
|
5341
|
+
function currentBranch2(repoRoot) {
|
|
5342
|
+
return runGit2(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot).trim();
|
|
5343
|
+
}
|
|
5344
|
+
function runTrustGrant(opts) {
|
|
5345
|
+
if (!SHORT_NAME_RE.test(opts.shortName)) {
|
|
5346
|
+
throw new UsageError(
|
|
5347
|
+
`short-name ${JSON.stringify(opts.shortName)} has an invalid shape (allowed: alphanumerics + . _ -, must start with alnum, max 63 chars).`
|
|
5348
|
+
);
|
|
5349
|
+
}
|
|
5350
|
+
const repoRoot = resolve(opts.repoPath ?? process.cwd());
|
|
5351
|
+
if (!existsSync13(join8(repoRoot, ".git"))) {
|
|
5352
|
+
throw new UsageError(`${repoRoot} is not a git repository`);
|
|
5353
|
+
}
|
|
5354
|
+
if (!existsSync13(stampConfigDir(repoRoot))) {
|
|
5355
|
+
throw new UsageError(
|
|
5356
|
+
`${repoRoot} has no .stamp/ directory \u2014 run \`stamp init\` first or pass --repo <path> pointing at a stamp-gated repo`
|
|
5357
|
+
);
|
|
5358
|
+
}
|
|
5359
|
+
if (!opts.forceDirty && !workingTreeClean(repoRoot)) {
|
|
5360
|
+
throw new UsageError(
|
|
5361
|
+
`${repoRoot} has uncommitted changes. Commit or stash them first, then re-run. (Use --force-dirty to override, but the resulting branch will include unrelated changes.)`
|
|
5362
|
+
);
|
|
5363
|
+
}
|
|
5364
|
+
const branch = `stamp-trust/${opts.shortName}`;
|
|
5365
|
+
if (branchExists2(repoRoot, branch)) {
|
|
5366
|
+
throw new UsageError(
|
|
5367
|
+
`branch ${branch} already exists. Delete it (\`git branch -D ${branch}\`) or finish the in-flight grant by switching to it and pushing/merging.`
|
|
5368
|
+
);
|
|
5369
|
+
}
|
|
5370
|
+
const server2 = resolveServer2();
|
|
5371
|
+
const pemRaw = fetchStampPubkey(server2, opts.shortName);
|
|
5372
|
+
const pem = pemRaw.trim() + "\n";
|
|
5373
|
+
let fingerprint;
|
|
5374
|
+
try {
|
|
5375
|
+
fingerprint = fingerprintFromPem2(pem);
|
|
5376
|
+
} catch (e) {
|
|
5377
|
+
throw new Error(
|
|
5378
|
+
`server returned an invalid PEM for ${opts.shortName}: ${e.message}. This is almost always a server-side issue (corrupted membership DB, or a future server version emitting a key format this client can't parse). Contact the server admin with the PEM body.`
|
|
5379
|
+
);
|
|
5380
|
+
}
|
|
5381
|
+
const existingFile = findExistingTrustedKey(repoRoot, fingerprint);
|
|
5382
|
+
if (existingFile) {
|
|
5383
|
+
process.stdout.write(
|
|
5384
|
+
`note: repo already trusts ${opts.shortName}'s stamp signing key (matching .stamp/trusted-keys/${existingFile}). No changes.
|
|
5385
|
+
`
|
|
5386
|
+
);
|
|
5387
|
+
return;
|
|
5388
|
+
}
|
|
5389
|
+
const startingBranch = currentBranch2(repoRoot);
|
|
5390
|
+
runGit2(["checkout", "-b", branch], repoRoot);
|
|
5391
|
+
const keysDir = stampTrustedKeysDir(repoRoot);
|
|
5392
|
+
ensureDir(keysDir, 493);
|
|
5393
|
+
const keyFile = join8(keysDir, `${opts.shortName}.pub`);
|
|
5394
|
+
writeFileSync10(keyFile, pem, { mode: 420 });
|
|
5395
|
+
runGit2(["add", keyFile], repoRoot);
|
|
5396
|
+
runGit2(
|
|
5397
|
+
[
|
|
5398
|
+
"commit",
|
|
5399
|
+
"-m",
|
|
5400
|
+
`Trust grant: add ${opts.shortName} to .stamp/trusted-keys/
|
|
5401
|
+
|
|
5402
|
+
Stages ${opts.shortName}'s stamp signing pubkey (fingerprint ${fingerprint}) so their stamp-signed merges into protected branches verify against this repo's trust set.
|
|
5403
|
+
|
|
5404
|
+
Source: stamp trust grant ${opts.shortName} (server ${server2.host})`
|
|
5405
|
+
],
|
|
5406
|
+
repoRoot
|
|
5407
|
+
);
|
|
5408
|
+
process.stdout.write(
|
|
5409
|
+
`\u2713 staged trust-grant for ${opts.shortName} on branch ${branch}
|
|
5410
|
+
trusted-keys file: ${keyFile}
|
|
5411
|
+
fingerprint: ${fingerprint}
|
|
5412
|
+
started from: ${startingBranch}
|
|
5413
|
+
|
|
5414
|
+
Next steps:
|
|
5415
|
+
stamp review --diff ${startingBranch}..${branch}
|
|
5416
|
+
git checkout ${startingBranch}
|
|
5417
|
+
stamp merge ${branch} --into ${startingBranch}
|
|
5418
|
+
stamp push ${startingBranch}
|
|
5419
|
+
`
|
|
5420
|
+
);
|
|
5421
|
+
}
|
|
5422
|
+
|
|
5423
|
+
// src/commands/users.ts
|
|
5424
|
+
import { spawnSync as spawnSync9 } from "child_process";
|
|
5425
|
+
var EXIT = {
|
|
5426
|
+
OK: 0,
|
|
5427
|
+
CONFIG: 1,
|
|
5428
|
+
USAGE: 2,
|
|
5429
|
+
AUTHORITY: 3,
|
|
5430
|
+
NOT_FOUND: 4,
|
|
5431
|
+
LAST_OWNER: 5,
|
|
5432
|
+
CANNOT_REMOVE_SELF: 6
|
|
5433
|
+
};
|
|
5434
|
+
function resolveServer3() {
|
|
5435
|
+
const server2 = loadServerConfig();
|
|
5436
|
+
if (!server2) {
|
|
5437
|
+
throw new UsageError(
|
|
5438
|
+
"no ~/.stamp/server.yml \u2014 run `stamp server config <host>:<port>` first"
|
|
5439
|
+
);
|
|
5440
|
+
}
|
|
5441
|
+
return server2;
|
|
5442
|
+
}
|
|
5443
|
+
function sshArgs(server2, remoteArgs) {
|
|
5444
|
+
return [
|
|
5445
|
+
"-p",
|
|
5446
|
+
String(server2.port),
|
|
5447
|
+
"--",
|
|
5448
|
+
`${server2.user}@${server2.host}`,
|
|
5449
|
+
"stamp-users",
|
|
5450
|
+
...remoteArgs
|
|
5451
|
+
];
|
|
5452
|
+
}
|
|
5453
|
+
function callRemote(server2, remoteArgs) {
|
|
5454
|
+
const result = spawnSync9("ssh", sshArgs(server2, remoteArgs), {
|
|
5455
|
+
// Stdout is always piped: `list` needs it for JSON parsing; write
|
|
5456
|
+
// ops don't emit on stdout so an empty pipe is harmless. Stderr is
|
|
5457
|
+
// always inherited so server-side `note:` confirmations and
|
|
5458
|
+
// `error:` prose land in the operator's terminal verbatim.
|
|
5459
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
5460
|
+
encoding: "utf8"
|
|
5461
|
+
});
|
|
5462
|
+
return { status: result.status, stdout: result.stdout ?? "" };
|
|
5463
|
+
}
|
|
5464
|
+
function explainExit(status, context, details) {
|
|
5465
|
+
switch (status) {
|
|
5466
|
+
case EXIT.AUTHORITY:
|
|
5467
|
+
return new UsageError(
|
|
5468
|
+
`${context}: your role on ${details.server.host} doesn't permit this action. Owners may manage any user; admins may manage members only (and may self-promote to owner if no owners exist yet \u2014 the bootstrap path). Ask an existing owner to perform this action.`
|
|
5469
|
+
);
|
|
5470
|
+
case EXIT.NOT_FOUND:
|
|
5471
|
+
return new UsageError(
|
|
5472
|
+
`${context}: short_name ${JSON.stringify(details.shortName ?? "?")} isn't in the membership DB on ${details.server.host}. Run \`stamp users list\` to see who's enrolled.`
|
|
5473
|
+
);
|
|
5474
|
+
case EXIT.LAST_OWNER:
|
|
5475
|
+
return new UsageError(
|
|
5476
|
+
`${context}: this would leave the server with zero owners. Promote another user to owner first (\`stamp users promote <name> --to owner\`).`
|
|
5477
|
+
);
|
|
5478
|
+
case EXIT.CANNOT_REMOVE_SELF:
|
|
5479
|
+
return new UsageError(
|
|
5480
|
+
`${context}: you can't remove your own account. Ask another admin or owner to do it \u2014 prevents accidentally locking yourself out.`
|
|
5481
|
+
);
|
|
5482
|
+
case EXIT.USAGE:
|
|
5483
|
+
return new UsageError(
|
|
5484
|
+
`${context}: server rejected the request as a usage error. Double-check the short_name (alphanumerics + . _ -, must start with alnum, max 63 chars) and --to value.`
|
|
5485
|
+
);
|
|
5486
|
+
case EXIT.CONFIG:
|
|
5487
|
+
return new Error(
|
|
5488
|
+
`${context}: server-side configuration error on ${details.server.host}. Most likely your account isn't in the membership DB yet, or the server is missing 'ExposeAuthInfo yes' in sshd_config. See server logs for specifics.`
|
|
5489
|
+
);
|
|
5490
|
+
default:
|
|
5491
|
+
return new Error(
|
|
5492
|
+
`${context}: failed against ${details.server.user}@${details.server.host}:${details.server.port} (exit ${status}). Common causes: server unreachable, your SSH key isn't enrolled, or the server image is older than the user-management feature.`
|
|
5493
|
+
);
|
|
5494
|
+
}
|
|
5495
|
+
}
|
|
5496
|
+
function pad(s, width) {
|
|
5497
|
+
return s.length >= width ? s : s + " ".repeat(width - s.length);
|
|
5498
|
+
}
|
|
5499
|
+
function formatUsersTable(rows) {
|
|
5500
|
+
if (rows.length === 0) return "(no users)\n";
|
|
5501
|
+
const widths = {
|
|
5502
|
+
short_name: Math.max(10, ...rows.map((r) => r.short_name.length)),
|
|
5503
|
+
role: Math.max(6, ...rows.map((r) => r.role.length)),
|
|
5504
|
+
source: Math.max(6, ...rows.map((r) => r.source.length))
|
|
5505
|
+
};
|
|
5506
|
+
const out = [];
|
|
5507
|
+
out.push(
|
|
5508
|
+
pad("short_name", widths.short_name) + " " + pad("role", widths.role) + " " + pad("source", widths.source) + " ssh_fp"
|
|
5509
|
+
);
|
|
5510
|
+
out.push(
|
|
5511
|
+
pad("-".repeat(widths.short_name), widths.short_name) + " " + pad("-".repeat(widths.role), widths.role) + " " + pad("-".repeat(widths.source), widths.source) + " " + "-".repeat(20)
|
|
5512
|
+
);
|
|
5513
|
+
for (const r of rows) {
|
|
5514
|
+
out.push(
|
|
5515
|
+
pad(r.short_name, widths.short_name) + " " + pad(r.role, widths.role) + " " + pad(r.source, widths.source) + " " + r.ssh_fp
|
|
5516
|
+
);
|
|
5517
|
+
}
|
|
5518
|
+
return out.join("\n") + "\n";
|
|
5519
|
+
}
|
|
5520
|
+
function runUsersList(opts) {
|
|
5521
|
+
const server2 = resolveServer3();
|
|
5522
|
+
const result = callRemote(server2, ["list"]);
|
|
5523
|
+
if (result.status !== 0) {
|
|
5524
|
+
throw explainExit(result.status, "stamp users list", { server: server2 });
|
|
5525
|
+
}
|
|
5526
|
+
if (opts.json) {
|
|
5527
|
+
process.stdout.write(result.stdout);
|
|
5528
|
+
return;
|
|
5529
|
+
}
|
|
5530
|
+
let payload;
|
|
5531
|
+
try {
|
|
5532
|
+
payload = JSON.parse(result.stdout);
|
|
5533
|
+
} catch (e) {
|
|
5534
|
+
throw new Error(
|
|
5535
|
+
`server returned non-JSON output: ${e.message}. Raw: ${JSON.stringify(result.stdout.slice(0, 200))}`
|
|
5536
|
+
);
|
|
5537
|
+
}
|
|
5538
|
+
const rows = payload.users ?? [];
|
|
5539
|
+
process.stdout.write(formatUsersTable(rows));
|
|
5540
|
+
}
|
|
5541
|
+
function runUsersPromote(opts) {
|
|
5542
|
+
const server2 = resolveServer3();
|
|
5543
|
+
const result = callRemote(server2, [
|
|
5544
|
+
"promote",
|
|
5545
|
+
opts.shortName,
|
|
5546
|
+
"--to",
|
|
5547
|
+
opts.to
|
|
5548
|
+
]);
|
|
5549
|
+
if (result.status !== 0) {
|
|
5550
|
+
throw explainExit(result.status, `stamp users promote ${opts.shortName}`, {
|
|
5551
|
+
server: server2,
|
|
5552
|
+
shortName: opts.shortName
|
|
5553
|
+
});
|
|
5554
|
+
}
|
|
5555
|
+
}
|
|
5556
|
+
function runUsersDemote(opts) {
|
|
5557
|
+
const server2 = resolveServer3();
|
|
5558
|
+
const result = callRemote(server2, [
|
|
5559
|
+
"demote",
|
|
5560
|
+
opts.shortName,
|
|
5561
|
+
"--to",
|
|
5562
|
+
opts.to
|
|
5563
|
+
]);
|
|
5564
|
+
if (result.status !== 0) {
|
|
5565
|
+
throw explainExit(result.status, `stamp users demote ${opts.shortName}`, {
|
|
5566
|
+
server: server2,
|
|
5567
|
+
shortName: opts.shortName
|
|
5568
|
+
});
|
|
5569
|
+
}
|
|
5570
|
+
}
|
|
5571
|
+
function runUsersRemove(opts) {
|
|
5572
|
+
const server2 = resolveServer3();
|
|
5573
|
+
const result = callRemote(server2, ["remove", opts.shortName]);
|
|
5574
|
+
if (result.status !== 0) {
|
|
5575
|
+
throw explainExit(result.status, `stamp users remove ${opts.shortName}`, {
|
|
5576
|
+
server: server2,
|
|
5577
|
+
shortName: opts.shortName
|
|
5578
|
+
});
|
|
5579
|
+
}
|
|
5580
|
+
}
|
|
5581
|
+
|
|
4792
5582
|
// src/commands/keys.ts
|
|
4793
|
-
import { existsSync as
|
|
4794
|
-
import { basename, join as
|
|
5583
|
+
import { existsSync as existsSync14, readdirSync as readdirSync3, readFileSync as readFileSync11, writeFileSync as writeFileSync11 } from "fs";
|
|
5584
|
+
import { basename, join as join9 } from "path";
|
|
4795
5585
|
function keysGenerate() {
|
|
4796
5586
|
const existing = loadUserKeypair();
|
|
4797
5587
|
if (existing) {
|
|
@@ -4824,18 +5614,18 @@ function keysList() {
|
|
|
4824
5614
|
const repoRoot = findRepoRoot();
|
|
4825
5615
|
const trustedDir = stampTrustedKeysDir(repoRoot);
|
|
4826
5616
|
console.log(`repo trusted keys: ${trustedDir}/`);
|
|
4827
|
-
if (!
|
|
5617
|
+
if (!existsSync14(trustedDir)) {
|
|
4828
5618
|
console.log(" (directory does not exist \u2014 run `stamp init`)");
|
|
4829
5619
|
return;
|
|
4830
5620
|
}
|
|
4831
|
-
const pubFiles =
|
|
5621
|
+
const pubFiles = readdirSync3(trustedDir).filter((f) => f.endsWith(".pub"));
|
|
4832
5622
|
if (pubFiles.length === 0) {
|
|
4833
5623
|
console.log(" (none)");
|
|
4834
5624
|
return;
|
|
4835
5625
|
}
|
|
4836
5626
|
for (const file of pubFiles.sort()) {
|
|
4837
5627
|
try {
|
|
4838
|
-
const pem =
|
|
5628
|
+
const pem = readFileSync11(join9(trustedDir, file), "utf8");
|
|
4839
5629
|
const fp = fingerprintFromPem(pem);
|
|
4840
5630
|
const marker = local && fp === local.fingerprint ? " (you)" : "";
|
|
4841
5631
|
console.log(` ${fp}${marker} [${file}]`);
|
|
@@ -4854,15 +5644,15 @@ function keysExport() {
|
|
|
4854
5644
|
function keysTrust(pubFile) {
|
|
4855
5645
|
const repoRoot = findRepoRoot();
|
|
4856
5646
|
const trustedDir = stampTrustedKeysDir(repoRoot);
|
|
4857
|
-
if (!
|
|
5647
|
+
if (!existsSync14(trustedDir)) {
|
|
4858
5648
|
throw new Error(
|
|
4859
5649
|
`no ${trustedDir} \u2014 run \`stamp init\` first to create the trust store`
|
|
4860
5650
|
);
|
|
4861
5651
|
}
|
|
4862
|
-
if (!
|
|
5652
|
+
if (!existsSync14(pubFile)) {
|
|
4863
5653
|
throw new Error(`public key file not found: ${pubFile}`);
|
|
4864
5654
|
}
|
|
4865
|
-
const pem =
|
|
5655
|
+
const pem = readFileSync11(pubFile, "utf8");
|
|
4866
5656
|
let fingerprint;
|
|
4867
5657
|
try {
|
|
4868
5658
|
fingerprint = fingerprintFromPem(pem);
|
|
@@ -4872,12 +5662,12 @@ function keysTrust(pubFile) {
|
|
|
4872
5662
|
);
|
|
4873
5663
|
}
|
|
4874
5664
|
const filename = publicKeyFingerprintFilename(fingerprint);
|
|
4875
|
-
const dest =
|
|
4876
|
-
if (
|
|
5665
|
+
const dest = join9(trustedDir, filename);
|
|
5666
|
+
if (existsSync14(dest)) {
|
|
4877
5667
|
console.log(`${fingerprint} is already trusted (${basename(dest)})`);
|
|
4878
5668
|
return;
|
|
4879
5669
|
}
|
|
4880
|
-
|
|
5670
|
+
writeFileSync11(dest, pem);
|
|
4881
5671
|
console.log(`trusted ${fingerprint}`);
|
|
4882
5672
|
console.log(` \u2192 ${dest}`);
|
|
4883
5673
|
console.log();
|
|
@@ -4885,11 +5675,11 @@ function keysTrust(pubFile) {
|
|
|
4885
5675
|
}
|
|
4886
5676
|
|
|
4887
5677
|
// src/commands/log.ts
|
|
4888
|
-
import { existsSync as
|
|
5678
|
+
import { existsSync as existsSync15 } from "fs";
|
|
4889
5679
|
function runLog(opts) {
|
|
4890
5680
|
const repoRoot = findRepoRoot();
|
|
4891
5681
|
const configPath = stampConfigFile(repoRoot);
|
|
4892
|
-
if (!
|
|
5682
|
+
if (!existsSync15(configPath)) {
|
|
4893
5683
|
throw new Error(
|
|
4894
5684
|
`no .stamp/config.yml at ${configPath}. Run \`stamp init\` first.`
|
|
4895
5685
|
);
|
|
@@ -5006,7 +5796,7 @@ function printCommitDetail(sha, repoRoot) {
|
|
|
5006
5796
|
}
|
|
5007
5797
|
function collectReviewProse(repoRoot, payload) {
|
|
5008
5798
|
const dbPath = stampStateDbPath(repoRoot);
|
|
5009
|
-
if (!
|
|
5799
|
+
if (!existsSync15(dbPath)) return [];
|
|
5010
5800
|
const db = openDb(dbPath);
|
|
5011
5801
|
try {
|
|
5012
5802
|
const rows = latestReviews(db, payload.base_sha, payload.head_sha);
|
|
@@ -5020,7 +5810,7 @@ function printReviewHistory(repoRoot, limit, diff) {
|
|
|
5020
5810
|
const configPath = stampConfigFile(repoRoot);
|
|
5021
5811
|
loadConfig(configPath);
|
|
5022
5812
|
const dbPath = stampStateDbPath(repoRoot);
|
|
5023
|
-
if (!
|
|
5813
|
+
if (!existsSync15(dbPath)) {
|
|
5024
5814
|
console.log("No reviews recorded yet.");
|
|
5025
5815
|
return;
|
|
5026
5816
|
}
|
|
@@ -5061,8 +5851,8 @@ function printReviewHistory(repoRoot, limit, diff) {
|
|
|
5061
5851
|
}
|
|
5062
5852
|
|
|
5063
5853
|
// src/commands/prune.ts
|
|
5064
|
-
import { existsSync as
|
|
5065
|
-
import { join as
|
|
5854
|
+
import { existsSync as existsSync16, readdirSync as readdirSync4, statSync as statSync2, unlinkSync as unlinkSync3 } from "fs";
|
|
5855
|
+
import { join as join10 } from "path";
|
|
5066
5856
|
|
|
5067
5857
|
// src/lib/duration.ts
|
|
5068
5858
|
function parseRetentionDuration(input) {
|
|
@@ -5091,15 +5881,16 @@ function runPrune(opts) {
|
|
|
5091
5881
|
);
|
|
5092
5882
|
const repoRoot = findRepoRoot();
|
|
5093
5883
|
const dbPath = stampStateDbPath(repoRoot);
|
|
5094
|
-
const
|
|
5884
|
+
const parsesDir = join10(gitCommonDir(repoRoot), "stamp", "failed-parses");
|
|
5885
|
+
const runsDir = join10(gitCommonDir(repoRoot), "stamp", "failed-runs");
|
|
5095
5886
|
const spoolCutoffMs = Date.now() - durationMs;
|
|
5096
|
-
if (!
|
|
5887
|
+
if (!existsSync16(dbPath) && !existsSync16(parsesDir) && !existsSync16(runsDir)) {
|
|
5097
5888
|
console.log(
|
|
5098
|
-
`note: nothing to prune (
|
|
5889
|
+
`note: nothing to prune (none of ${dbPath}, ${parsesDir}, ${runsDir} exist \u2014 all are created on first \`stamp review\`)`
|
|
5099
5890
|
);
|
|
5100
5891
|
return;
|
|
5101
5892
|
}
|
|
5102
|
-
const db =
|
|
5893
|
+
const db = existsSync16(dbPath) ? openDb(dbPath) : null;
|
|
5103
5894
|
try {
|
|
5104
5895
|
if (opts.dryRun) {
|
|
5105
5896
|
let any2 = false;
|
|
@@ -5113,14 +5904,19 @@ function runPrune(opts) {
|
|
|
5113
5904
|
any2 = true;
|
|
5114
5905
|
}
|
|
5115
5906
|
}
|
|
5116
|
-
const
|
|
5117
|
-
|
|
5118
|
-
|
|
5119
|
-
|
|
5120
|
-
|
|
5121
|
-
)
|
|
5122
|
-
|
|
5123
|
-
|
|
5907
|
+
for (const [label, dir] of [
|
|
5908
|
+
["failed-parse", parsesDir],
|
|
5909
|
+
["failed-run", runsDir]
|
|
5910
|
+
]) {
|
|
5911
|
+
const peek = peekSpools(dir, spoolCutoffMs);
|
|
5912
|
+
if (peek.length > 0) {
|
|
5913
|
+
if (any2) console.log("");
|
|
5914
|
+
console.log(
|
|
5915
|
+
`would prune ${peek.length} ${label} spool file${peek.length === 1 ? "" : "s"} older than ${humanLabel}:`
|
|
5916
|
+
);
|
|
5917
|
+
for (const f of peek) console.log(` ${f}`);
|
|
5918
|
+
any2 = true;
|
|
5919
|
+
}
|
|
5124
5920
|
}
|
|
5125
5921
|
if (!any2) {
|
|
5126
5922
|
console.log(`note: nothing to prune (no rows or spools older than ${humanLabel})`);
|
|
@@ -5143,13 +5939,18 @@ function runPrune(opts) {
|
|
|
5143
5939
|
any = true;
|
|
5144
5940
|
}
|
|
5145
5941
|
}
|
|
5146
|
-
const
|
|
5147
|
-
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
)
|
|
5152
|
-
|
|
5942
|
+
for (const [label, dir] of [
|
|
5943
|
+
["failed-parse", parsesDir],
|
|
5944
|
+
["failed-run", runsDir]
|
|
5945
|
+
]) {
|
|
5946
|
+
const deleted = pruneSpools(dir, spoolCutoffMs);
|
|
5947
|
+
if (deleted > 0) {
|
|
5948
|
+
if (any) console.log("");
|
|
5949
|
+
console.log(
|
|
5950
|
+
`${deleted} ${label} spool file${deleted === 1 ? "" : "s"} pruned`
|
|
5951
|
+
);
|
|
5952
|
+
any = true;
|
|
5953
|
+
}
|
|
5153
5954
|
}
|
|
5154
5955
|
if (!any) {
|
|
5155
5956
|
console.log(`note: nothing to prune (no rows or spools older than ${humanLabel})`);
|
|
@@ -5158,11 +5959,11 @@ function runPrune(opts) {
|
|
|
5158
5959
|
db?.close();
|
|
5159
5960
|
}
|
|
5160
5961
|
}
|
|
5161
|
-
function
|
|
5162
|
-
if (!
|
|
5962
|
+
function peekSpools(spoolDir, cutoffMs) {
|
|
5963
|
+
if (!existsSync16(spoolDir)) return [];
|
|
5163
5964
|
const out = [];
|
|
5164
|
-
for (const entry of
|
|
5165
|
-
const filepath =
|
|
5965
|
+
for (const entry of readdirSync4(spoolDir)) {
|
|
5966
|
+
const filepath = join10(spoolDir, entry);
|
|
5166
5967
|
let stat;
|
|
5167
5968
|
try {
|
|
5168
5969
|
stat = statSync2(filepath);
|
|
@@ -5174,8 +5975,8 @@ function peekFailedParseSpools(spoolDir, cutoffMs) {
|
|
|
5174
5975
|
}
|
|
5175
5976
|
return out.sort();
|
|
5176
5977
|
}
|
|
5177
|
-
function
|
|
5178
|
-
const targets =
|
|
5978
|
+
function pruneSpools(spoolDir, cutoffMs) {
|
|
5979
|
+
const targets = peekSpools(spoolDir, cutoffMs);
|
|
5179
5980
|
let deleted = 0;
|
|
5180
5981
|
for (const filepath of targets) {
|
|
5181
5982
|
try {
|
|
@@ -5196,7 +5997,7 @@ function printPerReviewer(rows) {
|
|
|
5196
5997
|
}
|
|
5197
5998
|
|
|
5198
5999
|
// src/commands/config.ts
|
|
5199
|
-
import { existsSync as
|
|
6000
|
+
import { existsSync as existsSync17 } from "fs";
|
|
5200
6001
|
function runConfigReviewersSet(opts) {
|
|
5201
6002
|
if (!isValidReviewerName(opts.reviewer)) {
|
|
5202
6003
|
throw new UsageError(
|
|
@@ -5269,7 +6070,7 @@ function runConfigReviewersClear(opts) {
|
|
|
5269
6070
|
}
|
|
5270
6071
|
function runConfigReviewersShow() {
|
|
5271
6072
|
const path2 = userConfigPath();
|
|
5272
|
-
if (!
|
|
6073
|
+
if (!existsSync17(path2)) {
|
|
5273
6074
|
console.log(`note: no per-user stamp config (${path2} does not exist).`);
|
|
5274
6075
|
console.log(
|
|
5275
6076
|
` Defaults will apply on next \`stamp init\` or \`stamp review\`:`
|
|
@@ -5314,30 +6115,30 @@ function loadOrEmpty() {
|
|
|
5314
6115
|
}
|
|
5315
6116
|
|
|
5316
6117
|
// src/commands/reviewers.ts
|
|
5317
|
-
import { spawnSync as
|
|
6118
|
+
import { spawnSync as spawnSync10 } from "child_process";
|
|
5318
6119
|
import {
|
|
5319
|
-
existsSync as
|
|
5320
|
-
readFileSync as
|
|
6120
|
+
existsSync as existsSync19,
|
|
6121
|
+
readFileSync as readFileSync13,
|
|
5321
6122
|
statSync as statSync3,
|
|
5322
6123
|
unlinkSync as unlinkSync4,
|
|
5323
|
-
writeFileSync as
|
|
6124
|
+
writeFileSync as writeFileSync13
|
|
5324
6125
|
} from "fs";
|
|
5325
|
-
import { join as
|
|
6126
|
+
import { join as join12, relative, resolve as resolve2 } from "path";
|
|
5326
6127
|
import { parse as parseYaml6, stringify as stringifyYaml3 } from "yaml";
|
|
5327
6128
|
|
|
5328
6129
|
// src/lib/reviewerLock.ts
|
|
5329
|
-
import { existsSync as
|
|
5330
|
-
import { join as
|
|
6130
|
+
import { existsSync as existsSync18, readFileSync as readFileSync12, writeFileSync as writeFileSync12 } from "fs";
|
|
6131
|
+
import { join as join11 } from "path";
|
|
5331
6132
|
var LOCK_FILE_VERSION = 1;
|
|
5332
6133
|
var LOCK_DRIFT_EXIT = 3;
|
|
5333
6134
|
function lockFilePath(repoRoot, reviewerName) {
|
|
5334
|
-
return
|
|
6135
|
+
return join11(repoRoot, ".stamp", "reviewers", `${reviewerName}.lock.json`);
|
|
5335
6136
|
}
|
|
5336
6137
|
function readLockFile(repoRoot, reviewerName) {
|
|
5337
6138
|
const path2 = lockFilePath(repoRoot, reviewerName);
|
|
5338
|
-
if (!
|
|
6139
|
+
if (!existsSync18(path2)) return null;
|
|
5339
6140
|
try {
|
|
5340
|
-
const raw =
|
|
6141
|
+
const raw = readFileSync12(path2, "utf8");
|
|
5341
6142
|
const parsed = JSON.parse(raw);
|
|
5342
6143
|
if (typeof parsed.version !== "number" || typeof parsed.source !== "string" || typeof parsed.ref !== "string" || typeof parsed.reviewer !== "string" || typeof parsed.prompt_sha256 !== "string" || typeof parsed.tools_sha256 !== "string" || typeof parsed.mcp_sha256 !== "string") {
|
|
5343
6144
|
throw new Error(`malformed lock file at ${path2}`);
|
|
@@ -5351,20 +6152,20 @@ function readLockFile(repoRoot, reviewerName) {
|
|
|
5351
6152
|
}
|
|
5352
6153
|
function writeLockFile(repoRoot, reviewerName, lock) {
|
|
5353
6154
|
const path2 = lockFilePath(repoRoot, reviewerName);
|
|
5354
|
-
|
|
6155
|
+
writeFileSync12(path2, JSON.stringify(lock, null, 2) + "\n", "utf8");
|
|
5355
6156
|
}
|
|
5356
6157
|
function checkReviewerDrift(repoRoot, reviewerName, def) {
|
|
5357
6158
|
const lock = readLockFile(repoRoot, reviewerName);
|
|
5358
6159
|
if (!lock) {
|
|
5359
6160
|
return unpinnedResult();
|
|
5360
6161
|
}
|
|
5361
|
-
const promptPath =
|
|
5362
|
-
if (!
|
|
6162
|
+
const promptPath = join11(repoRoot, def.prompt);
|
|
6163
|
+
if (!existsSync18(promptPath)) {
|
|
5363
6164
|
throw new Error(
|
|
5364
6165
|
`reviewer "${reviewerName}" has a lock file but its prompt "${def.prompt}" does not exist on disk. Re-run 'stamp reviewers fetch ${reviewerName} --from ${lock.source}@${lock.ref}' to restore it, or delete the lock file to un-pin the reviewer.`
|
|
5365
6166
|
);
|
|
5366
6167
|
}
|
|
5367
|
-
const promptBytes =
|
|
6168
|
+
const promptBytes = readFileSync12(promptPath);
|
|
5368
6169
|
const observedPrompt = hashPromptBytes(promptBytes);
|
|
5369
6170
|
const observedTools = hashTools(def.tools);
|
|
5370
6171
|
const observedMcp = hashMcpServers(def.mcp_servers);
|
|
@@ -5443,9 +6244,9 @@ function reviewersList() {
|
|
|
5443
6244
|
const maxNameLen = Math.max(...names.map((n) => n.length));
|
|
5444
6245
|
for (const name of names) {
|
|
5445
6246
|
const def = config2.reviewers[name];
|
|
5446
|
-
const abs =
|
|
6247
|
+
const abs = resolve2(repoRoot, def.prompt);
|
|
5447
6248
|
let annotation = "";
|
|
5448
|
-
if (!
|
|
6249
|
+
if (!existsSync19(abs)) {
|
|
5449
6250
|
annotation = " MISSING";
|
|
5450
6251
|
} else {
|
|
5451
6252
|
const size = statSync3(abs).size;
|
|
@@ -5469,7 +6270,7 @@ function reviewersEdit(name) {
|
|
|
5469
6270
|
`reviewer "${name}" is not configured. Run \`stamp reviewers list\` to see available reviewers.`
|
|
5470
6271
|
);
|
|
5471
6272
|
}
|
|
5472
|
-
const target =
|
|
6273
|
+
const target = resolve2(repoRoot, def.prompt);
|
|
5473
6274
|
launchEditor(target);
|
|
5474
6275
|
}
|
|
5475
6276
|
function reviewersAdd(name, opts = {}) {
|
|
@@ -5483,20 +6284,20 @@ function reviewersAdd(name, opts = {}) {
|
|
|
5483
6284
|
);
|
|
5484
6285
|
}
|
|
5485
6286
|
const promptRel = `.stamp/reviewers/${name}.md`;
|
|
5486
|
-
const promptAbs =
|
|
5487
|
-
if (
|
|
6287
|
+
const promptAbs = resolve2(repoRoot, promptRel);
|
|
6288
|
+
if (existsSync19(promptAbs)) {
|
|
5488
6289
|
throw new Error(
|
|
5489
6290
|
`${promptRel} already exists on disk but is not in config. Either delete the file or add it to config manually.`
|
|
5490
6291
|
);
|
|
5491
6292
|
}
|
|
5492
|
-
|
|
6293
|
+
writeFileSync13(
|
|
5493
6294
|
promptAbs,
|
|
5494
6295
|
`# ${name}
|
|
5495
6296
|
|
|
5496
6297
|
${EXAMPLE_REVIEWER_PROMPT.split("\n").slice(2).join("\n")}`
|
|
5497
6298
|
);
|
|
5498
6299
|
config2.reviewers[name] = { prompt: promptRel };
|
|
5499
|
-
|
|
6300
|
+
writeFileSync13(configPath, stringifyConfig(config2));
|
|
5500
6301
|
console.log(`reviewer "${name}" added.`);
|
|
5501
6302
|
console.log(` prompt file: ${promptRel}`);
|
|
5502
6303
|
console.log(` registered in .stamp/config.yml`);
|
|
@@ -5531,11 +6332,11 @@ function reviewersRemove(name, opts = {}) {
|
|
|
5531
6332
|
);
|
|
5532
6333
|
}
|
|
5533
6334
|
delete config2.reviewers[name];
|
|
5534
|
-
|
|
6335
|
+
writeFileSync13(configPath, stringifyConfig(config2));
|
|
5535
6336
|
console.log(`reviewer "${name}" removed from .stamp/config.yml`);
|
|
5536
6337
|
if (opts.deleteFile) {
|
|
5537
|
-
const promptAbs =
|
|
5538
|
-
if (
|
|
6338
|
+
const promptAbs = resolve2(repoRoot, def.prompt);
|
|
6339
|
+
if (existsSync19(promptAbs)) {
|
|
5539
6340
|
unlinkSync4(promptAbs);
|
|
5540
6341
|
console.log(`deleted ${def.prompt}`);
|
|
5541
6342
|
}
|
|
@@ -5566,8 +6367,8 @@ async function reviewersTest(name, diff) {
|
|
|
5566
6367
|
console.log(` prompt sourced from working tree (test/iteration use case)`);
|
|
5567
6368
|
console.log();
|
|
5568
6369
|
const def = config2.reviewers[name];
|
|
5569
|
-
const promptPath =
|
|
5570
|
-
const systemPrompt =
|
|
6370
|
+
const promptPath = join12(repoRoot, def.prompt);
|
|
6371
|
+
const systemPrompt = readFileSync13(promptPath, "utf8");
|
|
5571
6372
|
const result = await invokeReviewer({
|
|
5572
6373
|
reviewer: name,
|
|
5573
6374
|
config: config2,
|
|
@@ -5594,7 +6395,7 @@ function reviewersShow(name, opts) {
|
|
|
5594
6395
|
);
|
|
5595
6396
|
}
|
|
5596
6397
|
const dbPath = stampStateDbPath(repoRoot);
|
|
5597
|
-
if (!
|
|
6398
|
+
if (!existsSync19(dbPath)) {
|
|
5598
6399
|
console.log("No reviews recorded yet (no state.db).");
|
|
5599
6400
|
return;
|
|
5600
6401
|
}
|
|
@@ -5684,7 +6485,7 @@ async function reviewersFetch(reviewerName, opts) {
|
|
|
5684
6485
|
opts.expectMcpSha
|
|
5685
6486
|
);
|
|
5686
6487
|
const reviewersDir = stampReviewersDir(repoRoot);
|
|
5687
|
-
if (!
|
|
6488
|
+
if (!existsSync19(reviewersDir)) {
|
|
5688
6489
|
throw new Error(
|
|
5689
6490
|
`${reviewersDir} does not exist \u2014 run \`stamp init\` first.`
|
|
5690
6491
|
);
|
|
@@ -5705,7 +6506,7 @@ async function reviewersFetch(reviewerName, opts) {
|
|
|
5705
6506
|
mcpServers = validateMcpServersFromSource(parsed.mcp_servers, source, ref);
|
|
5706
6507
|
}
|
|
5707
6508
|
}
|
|
5708
|
-
const promptPath =
|
|
6509
|
+
const promptPath = join12(reviewersDir, `${reviewerName}.md`);
|
|
5709
6510
|
const promptBytes = Buffer.from(promptText, "utf8");
|
|
5710
6511
|
const promptSha = hashPromptBytes(promptBytes);
|
|
5711
6512
|
const toolsSha = hashTools(tools);
|
|
@@ -5729,7 +6530,7 @@ async function reviewersFetch(reviewerName, opts) {
|
|
|
5729
6530
|
mcpSha
|
|
5730
6531
|
);
|
|
5731
6532
|
}
|
|
5732
|
-
|
|
6533
|
+
writeFileSync13(promptPath, promptBytes);
|
|
5733
6534
|
const lock = {
|
|
5734
6535
|
version: LOCK_FILE_VERSION,
|
|
5735
6536
|
source,
|
|
@@ -5958,7 +6759,7 @@ function buildConfigYamlHint(reviewerName, tools, mcpServers) {
|
|
|
5958
6759
|
}
|
|
5959
6760
|
function launchEditor(path2) {
|
|
5960
6761
|
const editor = process.env["EDITOR"] ?? process.env["VISUAL"] ?? (process.platform === "win32" ? "notepad" : "vi");
|
|
5961
|
-
const result =
|
|
6762
|
+
const result = spawnSync10(editor, [path2], { stdio: "inherit" });
|
|
5962
6763
|
if (result.error) {
|
|
5963
6764
|
throw new Error(
|
|
5964
6765
|
`failed to launch editor "${editor}": ${result.error.message}`
|
|
@@ -5970,11 +6771,11 @@ function launchEditor(path2) {
|
|
|
5970
6771
|
}
|
|
5971
6772
|
|
|
5972
6773
|
// src/commands/status.ts
|
|
5973
|
-
import { existsSync as
|
|
6774
|
+
import { existsSync as existsSync20 } from "fs";
|
|
5974
6775
|
function runStatus(opts) {
|
|
5975
6776
|
const repoRoot = findRepoRoot();
|
|
5976
6777
|
const configPath = stampConfigFile(repoRoot);
|
|
5977
|
-
if (!
|
|
6778
|
+
if (!existsSync20(configPath)) {
|
|
5978
6779
|
throw new Error(
|
|
5979
6780
|
`no .stamp/config.yml at ${configPath}. Run \`stamp init\` first.`
|
|
5980
6781
|
);
|
|
@@ -6042,17 +6843,17 @@ function printGate(result, base_sha, head_sha) {
|
|
|
6042
6843
|
}
|
|
6043
6844
|
|
|
6044
6845
|
// src/commands/update.ts
|
|
6045
|
-
import { spawnSync as
|
|
6846
|
+
import { spawnSync as spawnSync11 } from "child_process";
|
|
6046
6847
|
|
|
6047
6848
|
// src/lib/version.ts
|
|
6048
|
-
import { readFileSync as
|
|
6049
|
-
import { dirname as dirname5, join as
|
|
6849
|
+
import { readFileSync as readFileSync14 } from "fs";
|
|
6850
|
+
import { dirname as dirname5, join as join13 } from "path";
|
|
6050
6851
|
import { fileURLToPath } from "url";
|
|
6051
6852
|
function readPackageVersion() {
|
|
6052
6853
|
const here = dirname5(fileURLToPath(import.meta.url));
|
|
6053
6854
|
for (let dir = here, i = 0; i < 6; i++) {
|
|
6054
6855
|
try {
|
|
6055
|
-
const raw =
|
|
6856
|
+
const raw = readFileSync14(join13(dir, "package.json"), "utf8");
|
|
6056
6857
|
const pkg = JSON.parse(raw);
|
|
6057
6858
|
if (pkg.name === "@openthink/stamp" && pkg.version) return pkg.version;
|
|
6058
6859
|
} catch {
|
|
@@ -6083,7 +6884,7 @@ function runUpdate() {
|
|
|
6083
6884
|
`);
|
|
6084
6885
|
process.stdout.write(`checking npm registry for latest...
|
|
6085
6886
|
`);
|
|
6086
|
-
const viewResult =
|
|
6887
|
+
const viewResult = spawnSync11("npm", ["view", PKG_NAME, "version"], {
|
|
6087
6888
|
encoding: "utf8"
|
|
6088
6889
|
});
|
|
6089
6890
|
if (viewResult.error || viewResult.status !== 0) {
|
|
@@ -6117,7 +6918,7 @@ function runUpdate() {
|
|
|
6117
6918
|
}
|
|
6118
6919
|
process.stdout.write(`installing ${PKG_NAME}@${latest}...
|
|
6119
6920
|
`);
|
|
6120
|
-
const installResult =
|
|
6921
|
+
const installResult = spawnSync11(
|
|
6121
6922
|
"npm",
|
|
6122
6923
|
["install", "-g", `${PKG_NAME}@${latest}`],
|
|
6123
6924
|
{ stdio: "inherit" }
|
|
@@ -6138,9 +6939,9 @@ through that tool instead \u2014 this command only uses 'npm install -g'.`
|
|
|
6138
6939
|
}
|
|
6139
6940
|
|
|
6140
6941
|
// src/commands/verify.ts
|
|
6141
|
-
import { execFileSync as execFileSync2, spawnSync as
|
|
6942
|
+
import { execFileSync as execFileSync2, spawnSync as spawnSync12 } from "child_process";
|
|
6142
6943
|
function loadConfigAtSha(sha, repoRoot) {
|
|
6143
|
-
const result =
|
|
6944
|
+
const result = spawnSync12(
|
|
6144
6945
|
"git",
|
|
6145
6946
|
["show", `${sha}:.stamp/config.yml`],
|
|
6146
6947
|
{ cwd: repoRoot, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }
|
|
@@ -6629,7 +7430,7 @@ serverRepo.command("restore <name>").description("restore the most recent soft-d
|
|
|
6629
7430
|
}
|
|
6630
7431
|
);
|
|
6631
7432
|
program.command("review").description(
|
|
6632
|
-
"run configured reviewer(s) against a diff. Reviewer config + prompts are sourced from the merge-base tree (security: prevents feature-branch self-review). For lock-file drift checks, use `stamp reviewers verify` (which exits 3 on drift). Reviewer execution budgets
|
|
7433
|
+
"run configured reviewer(s) against a diff. Reviewer config + prompts are sourced from the merge-base tree (security: prevents feature-branch self-review). For lock-file drift checks, use `stamp reviewers verify` (which exits 3 on drift). Reviewer execution budgets resolve narrowest-wins: `.stamp/config.yml` per-reviewer fields (`reviewers.<name>.max_turns` / `timeout_ms`, committed, sourced from the merge-base tree), then env overrides (`STAMP_REVIEWER_MAX_TURNS` default 8, `STAMP_REVIEWER_TIMEOUT_MS` default 300000), then defaults. On failure a structured turn trace is written to `.git/stamp/failed-runs/` \u2014 see docs/troubleshooting.md."
|
|
6633
7434
|
).requiredOption("--diff <revspec>", "git revspec to review, e.g. main..HEAD").option("--only <reviewer>", "run a single reviewer by name").option(
|
|
6634
7435
|
"--allow-large",
|
|
6635
7436
|
"bypass the 200KB diff size cap (raise STAMP_REVIEW_DIFF_CAP_BYTES for a different threshold)"
|
|
@@ -6739,6 +7540,88 @@ function wrap(fn) {
|
|
|
6739
7540
|
process.exit(1);
|
|
6740
7541
|
}
|
|
6741
7542
|
}
|
|
7543
|
+
var invites = program.command("invites").description("mint and accept single-use invite tokens for teammate onboarding");
|
|
7544
|
+
invites.command("mint <short-name>").description("mint an invite for a teammate (15-min TTL, admin/owner only)").option("--role <admin|member>", "role to grant on accept", "member").action((shortName, opts) => {
|
|
7545
|
+
try {
|
|
7546
|
+
if (opts.role !== "admin" && opts.role !== "member") {
|
|
7547
|
+
throw new Error(
|
|
7548
|
+
`--role must be 'admin' or 'member' (got ${JSON.stringify(opts.role)})`
|
|
7549
|
+
);
|
|
7550
|
+
}
|
|
7551
|
+
runInvitesMint({ shortName, role: opts.role });
|
|
7552
|
+
} catch (err) {
|
|
7553
|
+
handleCliError(err);
|
|
7554
|
+
}
|
|
7555
|
+
});
|
|
7556
|
+
invites.command("accept <share-url-or-token>").description("redeem an invite token; auto-detects local keys, prompts to confirm").option("--server <host:port>", "server endpoint when passing a bare token (no URL)").option("--ssh-pubkey <path>", "override SSH pubkey path (default ~/.ssh/id_ed25519.pub)").option("--stamp-pubkey <path>", "override stamp signing pubkey path (default ~/.stamp/keys/ed25519.pub)").option("--short-name <name>", "override the short_name (default derived from user@host)").option("--yes", "skip the confirmation prompt (required for non-TTY stdin)").action(
|
|
7557
|
+
async (urlOrToken, opts) => {
|
|
7558
|
+
try {
|
|
7559
|
+
await runInvitesAccept({
|
|
7560
|
+
urlOrToken,
|
|
7561
|
+
server: opts.server,
|
|
7562
|
+
sshPubkeyPath: opts.sshPubkey,
|
|
7563
|
+
stampPubkeyPath: opts.stampPubkey,
|
|
7564
|
+
shortName: opts.shortName,
|
|
7565
|
+
yes: opts.yes
|
|
7566
|
+
});
|
|
7567
|
+
} catch (err) {
|
|
7568
|
+
handleCliError(err);
|
|
7569
|
+
}
|
|
7570
|
+
}
|
|
7571
|
+
);
|
|
7572
|
+
var users = program.command("users").description("list, promote, demote, and remove enrolled users on the stamp server");
|
|
7573
|
+
users.command("list").description("list enrolled users (everyone authenticated may run this)").option("--json", "emit the raw server JSON instead of the formatted table").action((opts) => {
|
|
7574
|
+
try {
|
|
7575
|
+
runUsersList({ json: opts.json });
|
|
7576
|
+
} catch (err) {
|
|
7577
|
+
handleCliError(err);
|
|
7578
|
+
}
|
|
7579
|
+
});
|
|
7580
|
+
users.command("promote <short-name>").description("promote a user; admins may not promote to admin/owner except for the no-owners bootstrap path").requiredOption("--to <admin|owner>", "target role").action((shortName, opts) => {
|
|
7581
|
+
try {
|
|
7582
|
+
if (opts.to !== "admin" && opts.to !== "owner") {
|
|
7583
|
+
throw new Error(
|
|
7584
|
+
`promote --to must be 'admin' or 'owner' (got ${JSON.stringify(opts.to)})`
|
|
7585
|
+
);
|
|
7586
|
+
}
|
|
7587
|
+
runUsersPromote({ shortName, to: opts.to });
|
|
7588
|
+
} catch (err) {
|
|
7589
|
+
handleCliError(err);
|
|
7590
|
+
}
|
|
7591
|
+
});
|
|
7592
|
+
users.command("demote <short-name>").description("demote a user (admin/owner only; last-owner guard prevents zeroing out ownership)").requiredOption("--to <admin|member>", "target role").action((shortName, opts) => {
|
|
7593
|
+
try {
|
|
7594
|
+
if (opts.to !== "admin" && opts.to !== "member") {
|
|
7595
|
+
throw new Error(
|
|
7596
|
+
`demote --to must be 'admin' or 'member' (got ${JSON.stringify(opts.to)})`
|
|
7597
|
+
);
|
|
7598
|
+
}
|
|
7599
|
+
runUsersDemote({ shortName, to: opts.to });
|
|
7600
|
+
} catch (err) {
|
|
7601
|
+
handleCliError(err);
|
|
7602
|
+
}
|
|
7603
|
+
});
|
|
7604
|
+
users.command("remove <short-name>").description("remove a user from the membership DB (admins may remove members only; cannot remove self)").action((shortName) => {
|
|
7605
|
+
try {
|
|
7606
|
+
runUsersRemove({ shortName });
|
|
7607
|
+
} catch (err) {
|
|
7608
|
+
handleCliError(err);
|
|
7609
|
+
}
|
|
7610
|
+
});
|
|
7611
|
+
var trust = program.command("trust").description("manage per-repo stamp signing trust (committed under .stamp/trusted-keys/)");
|
|
7612
|
+
trust.command("grant <short-name>").description("stage a trust-grant for an enrolled user on a new branch; review + merge through the usual gate").option("--repo <path>", "repo root to operate on (default: cwd)").option("--force-dirty", "stage the grant even if the working tree has uncommitted changes").action(
|
|
7613
|
+
(shortName, opts) => {
|
|
7614
|
+
try {
|
|
7615
|
+
runTrustGrant({
|
|
7616
|
+
shortName,
|
|
7617
|
+
repoPath: opts.repo,
|
|
7618
|
+
forceDirty: opts.forceDirty
|
|
7619
|
+
});
|
|
7620
|
+
} catch (err) {
|
|
7621
|
+
handleCliError(err);
|
|
7622
|
+
}
|
|
7623
|
+
}
|
|
7624
|
+
);
|
|
6742
7625
|
var reviewers = program.command("reviewers").description("manage reviewer prompts");
|
|
6743
7626
|
reviewers.command("list").description("list configured reviewers and their prompt file status").action(() => wrap(() => reviewersList()));
|
|
6744
7627
|
reviewers.command("add <name>").description("scaffold a new reviewer: create prompt file, register in config, open in editor").option("--no-edit", "skip opening $EDITOR after scaffolding").action(
|