@openthink/stamp 1.4.0 → 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/dist/index.js +911 -134
- 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
|
@@ -1373,13 +1373,13 @@ function readLineSync() {
|
|
|
1373
1373
|
function runMerge(opts) {
|
|
1374
1374
|
const repoRoot = findRepoRoot();
|
|
1375
1375
|
const config2 = loadConfig(stampConfigFile(repoRoot));
|
|
1376
|
-
const
|
|
1376
|
+
const currentBranch3 = git(
|
|
1377
1377
|
["rev-parse", "--abbrev-ref", "HEAD"],
|
|
1378
1378
|
repoRoot
|
|
1379
1379
|
).trim();
|
|
1380
|
-
if (
|
|
1380
|
+
if (currentBranch3 !== opts.into) {
|
|
1381
1381
|
throw new Error(
|
|
1382
|
-
`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.`
|
|
1383
1383
|
);
|
|
1384
1384
|
}
|
|
1385
1385
|
const dirty = git(
|
|
@@ -3812,60 +3812,109 @@ function resolveMode(userMode, remoteClass) {
|
|
|
3812
3812
|
}
|
|
3813
3813
|
}
|
|
3814
3814
|
|
|
3815
|
-
// src/commands/
|
|
3816
|
-
import { spawnSync as spawnSync6 } from "child_process";
|
|
3817
|
-
import { existsSync as existsSync11, mkdtempSync, readFileSync as readFileSync8, rmSync, writeFileSync as writeFileSync9 } from "fs";
|
|
3818
|
-
import { tmpdir } from "os";
|
|
3819
|
-
import { join as join6, resolve as resolvePath } from "path";
|
|
3820
|
-
import { parse as parseYaml5 } from "yaml";
|
|
3821
|
-
|
|
3822
|
-
// src/commands/server.ts
|
|
3815
|
+
// src/commands/invites.ts
|
|
3823
3816
|
import { spawnSync as spawnSync5 } from "child_process";
|
|
3824
|
-
import {
|
|
3825
|
-
import {
|
|
3826
|
-
import {
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
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";
|
|
3835
3830
|
}
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
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 };
|
|
3840
3858
|
}
|
|
3841
|
-
if (
|
|
3842
|
-
throw new
|
|
3843
|
-
`
|
|
3859
|
+
if (!TOKEN_RE.test(trimmed)) {
|
|
3860
|
+
throw new ShareUrlError(
|
|
3861
|
+
`expected a stamp+invite:// URL or a bare token (got ${JSON.stringify(input)})`
|
|
3844
3862
|
);
|
|
3845
3863
|
}
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
`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"
|
|
3850
3867
|
);
|
|
3851
3868
|
}
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
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");
|
|
3857
3885
|
}
|
|
3858
|
-
if (
|
|
3859
|
-
throw new Error(
|
|
3860
|
-
`computePerRepoKeyPath: owner must match [A-Za-z0-9-]+ (got "${owner}" in "${githubRepo}")`
|
|
3861
|
-
);
|
|
3886
|
+
if (trimmed.startsWith("#")) {
|
|
3887
|
+
throw new Error("ssh pubkey line is a comment");
|
|
3862
3888
|
}
|
|
3863
|
-
|
|
3889
|
+
const parts = trimmed.split(/\s+/);
|
|
3890
|
+
if (parts.length < 2) {
|
|
3864
3891
|
throw new Error(
|
|
3865
|
-
|
|
3892
|
+
"ssh pubkey line must have at least <algorithm> <base64> tokens"
|
|
3866
3893
|
);
|
|
3867
3894
|
}
|
|
3868
|
-
|
|
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}`;
|
|
3869
3918
|
}
|
|
3870
3919
|
|
|
3871
3920
|
// src/commands/serverRepo.ts
|
|
@@ -4046,12 +4095,301 @@ function validateGithubRepoSpec(spec) {
|
|
|
4046
4095
|
}
|
|
4047
4096
|
function prompt(question) {
|
|
4048
4097
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
4049
|
-
return new Promise((
|
|
4098
|
+
return new Promise((resolve3) => {
|
|
4050
4099
|
rl.question(question, (answer) => {
|
|
4051
4100
|
rl.close();
|
|
4052
|
-
|
|
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
|
|
4053
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
|
|
4054
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`;
|
|
4055
4393
|
}
|
|
4056
4394
|
|
|
4057
4395
|
// src/commands/server.ts
|
|
@@ -4084,7 +4422,7 @@ function runServerConfig(opts) {
|
|
|
4084
4422
|
}
|
|
4085
4423
|
function showConfig() {
|
|
4086
4424
|
const path2 = userServerConfigPath();
|
|
4087
|
-
if (!
|
|
4425
|
+
if (!existsSync11(path2)) {
|
|
4088
4426
|
console.log(`note: no stamp server configured (${path2} does not exist)`);
|
|
4089
4427
|
console.log(`note: run \`stamp server config <host:port>\` to create one`);
|
|
4090
4428
|
return;
|
|
@@ -4102,7 +4440,7 @@ function showConfig() {
|
|
|
4102
4440
|
}
|
|
4103
4441
|
function unsetConfig() {
|
|
4104
4442
|
const path2 = userServerConfigPath();
|
|
4105
|
-
if (!
|
|
4443
|
+
if (!existsSync11(path2)) {
|
|
4106
4444
|
console.log(`note: ${path2} does not exist; nothing to remove`);
|
|
4107
4445
|
return;
|
|
4108
4446
|
}
|
|
@@ -4110,7 +4448,7 @@ function unsetConfig() {
|
|
|
4110
4448
|
console.log(`removed ${path2}`);
|
|
4111
4449
|
}
|
|
4112
4450
|
function fetchServerPubkey(server2, mirror) {
|
|
4113
|
-
const
|
|
4451
|
+
const sshArgs2 = [
|
|
4114
4452
|
"-p",
|
|
4115
4453
|
String(server2.port),
|
|
4116
4454
|
"--",
|
|
@@ -4118,9 +4456,9 @@ function fetchServerPubkey(server2, mirror) {
|
|
|
4118
4456
|
"stamp-server-pubkey"
|
|
4119
4457
|
];
|
|
4120
4458
|
if (mirror) {
|
|
4121
|
-
|
|
4459
|
+
sshArgs2.push(`${mirror.owner}/${mirror.repo}`);
|
|
4122
4460
|
}
|
|
4123
|
-
const result =
|
|
4461
|
+
const result = spawnSync6("ssh", sshArgs2, {
|
|
4124
4462
|
stdio: ["ignore", "pipe", "inherit"],
|
|
4125
4463
|
encoding: "utf8"
|
|
4126
4464
|
});
|
|
@@ -4168,7 +4506,7 @@ function writeConfig(opts) {
|
|
|
4168
4506
|
});
|
|
4169
4507
|
const path2 = userServerConfigPath();
|
|
4170
4508
|
const dir = dirname4(path2);
|
|
4171
|
-
if (!
|
|
4509
|
+
if (!existsSync11(dir)) mkdirSync4(dir, { recursive: true, mode: 448 });
|
|
4172
4510
|
const tmp = `${path2}.tmp.${process.pid}`;
|
|
4173
4511
|
writeFileSync8(tmp, yaml, { mode: 384 });
|
|
4174
4512
|
renameSync3(tmp, path2);
|
|
@@ -4230,7 +4568,7 @@ See docs/quickstart-server.md for how to deploy a stamp server first.`
|
|
|
4230
4568
|
return;
|
|
4231
4569
|
}
|
|
4232
4570
|
const cloneTarget = resolvePath(opts.into ?? opts.name);
|
|
4233
|
-
if (
|
|
4571
|
+
if (existsSync12(cloneTarget)) {
|
|
4234
4572
|
throw new Error(
|
|
4235
4573
|
`clone destination already exists: ${cloneTarget}. Move or remove it, or pass --into <other-path>.`
|
|
4236
4574
|
);
|
|
@@ -4307,7 +4645,7 @@ function printPlan2(args) {
|
|
|
4307
4645
|
console.log(bar);
|
|
4308
4646
|
}
|
|
4309
4647
|
function provisionBareRepoOnServer(server2, name) {
|
|
4310
|
-
const result =
|
|
4648
|
+
const result = spawnSync7(
|
|
4311
4649
|
"ssh",
|
|
4312
4650
|
[
|
|
4313
4651
|
"-p",
|
|
@@ -4333,7 +4671,7 @@ function createGithubMirrorRepo(owner, repo, privateRepo) {
|
|
|
4333
4671
|
);
|
|
4334
4672
|
}
|
|
4335
4673
|
const visibility = privateRepo ? "--private" : "--public";
|
|
4336
|
-
const result =
|
|
4674
|
+
const result = spawnSync7(
|
|
4337
4675
|
"gh",
|
|
4338
4676
|
["repo", "create", `${owner}/${repo}`, visibility],
|
|
4339
4677
|
{ stdio: ["ignore", "inherit", "inherit"] }
|
|
@@ -4480,9 +4818,9 @@ async function runMigrateExisting(opts, server2) {
|
|
|
4480
4818
|
console.log("\n(dry run \u2014 no changes made)");
|
|
4481
4819
|
return;
|
|
4482
4820
|
}
|
|
4483
|
-
const stagingDir = mkdtempSync(
|
|
4484
|
-
const bareCloneDir =
|
|
4485
|
-
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`);
|
|
4486
4824
|
try {
|
|
4487
4825
|
console.log(`
|
|
4488
4826
|
Building bare-clone tarball of existing repo`);
|
|
@@ -4518,9 +4856,9 @@ function ensureCwdIsGitRepo(cwd) {
|
|
|
4518
4856
|
}
|
|
4519
4857
|
}
|
|
4520
4858
|
function ensureStampInitDone(cwd) {
|
|
4521
|
-
if (!
|
|
4859
|
+
if (!existsSync12(join7(cwd, ".stamp", "config.yml"))) {
|
|
4522
4860
|
throw new Error(
|
|
4523
|
-
`--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.`
|
|
4524
4862
|
);
|
|
4525
4863
|
}
|
|
4526
4864
|
}
|
|
@@ -4550,7 +4888,7 @@ function ensureWorkingTreeClean(cwd) {
|
|
|
4550
4888
|
}
|
|
4551
4889
|
}
|
|
4552
4890
|
function runTarGz(parentDir, dirName, outputPath) {
|
|
4553
|
-
const result =
|
|
4891
|
+
const result = spawnSync7("tar", ["-czf", outputPath, "-C", parentDir, dirName], {
|
|
4554
4892
|
stdio: ["ignore", "inherit", "inherit"]
|
|
4555
4893
|
});
|
|
4556
4894
|
if (result.status !== 0) {
|
|
@@ -4560,7 +4898,7 @@ function runTarGz(parentDir, dirName, outputPath) {
|
|
|
4560
4898
|
}
|
|
4561
4899
|
}
|
|
4562
4900
|
function scpToServer(server2, localPath, remotePath) {
|
|
4563
|
-
const result =
|
|
4901
|
+
const result = spawnSync7(
|
|
4564
4902
|
"scp",
|
|
4565
4903
|
[
|
|
4566
4904
|
"-P",
|
|
@@ -4578,7 +4916,7 @@ function scpToServer(server2, localPath, remotePath) {
|
|
|
4578
4916
|
}
|
|
4579
4917
|
}
|
|
4580
4918
|
function sshRunNewStampRepoFromTarball(server2, name, remoteTarballPath) {
|
|
4581
|
-
const result =
|
|
4919
|
+
const result = spawnSync7(
|
|
4582
4920
|
"ssh",
|
|
4583
4921
|
[
|
|
4584
4922
|
"-p",
|
|
@@ -4645,15 +4983,15 @@ mirror.yml was added to .stamp/. Commit it through the normal stamp flow:`);
|
|
|
4645
4983
|
}
|
|
4646
4984
|
}
|
|
4647
4985
|
function readMirrorYmlGithubRepo(repoRoot) {
|
|
4648
|
-
const path2 =
|
|
4649
|
-
if (!
|
|
4986
|
+
const path2 = join7(repoRoot, ".stamp", "mirror.yml");
|
|
4987
|
+
if (!existsSync12(path2)) {
|
|
4650
4988
|
throw new Error(
|
|
4651
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\`.`
|
|
4652
4990
|
);
|
|
4653
4991
|
}
|
|
4654
4992
|
let raw;
|
|
4655
4993
|
try {
|
|
4656
|
-
raw =
|
|
4994
|
+
raw = readFileSync9(path2, "utf8");
|
|
4657
4995
|
} catch (err) {
|
|
4658
4996
|
throw new Error(
|
|
4659
4997
|
`could not read ${path2}: ${err instanceof Error ? err.message : String(err)}`
|
|
@@ -4884,9 +5222,366 @@ Direct \`git push origin main\` from any non-stamp source will be rejected.`
|
|
|
4884
5222
|
}
|
|
4885
5223
|
}
|
|
4886
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
|
+
|
|
4887
5582
|
// src/commands/keys.ts
|
|
4888
|
-
import { existsSync as
|
|
4889
|
-
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";
|
|
4890
5585
|
function keysGenerate() {
|
|
4891
5586
|
const existing = loadUserKeypair();
|
|
4892
5587
|
if (existing) {
|
|
@@ -4919,18 +5614,18 @@ function keysList() {
|
|
|
4919
5614
|
const repoRoot = findRepoRoot();
|
|
4920
5615
|
const trustedDir = stampTrustedKeysDir(repoRoot);
|
|
4921
5616
|
console.log(`repo trusted keys: ${trustedDir}/`);
|
|
4922
|
-
if (!
|
|
5617
|
+
if (!existsSync14(trustedDir)) {
|
|
4923
5618
|
console.log(" (directory does not exist \u2014 run `stamp init`)");
|
|
4924
5619
|
return;
|
|
4925
5620
|
}
|
|
4926
|
-
const pubFiles =
|
|
5621
|
+
const pubFiles = readdirSync3(trustedDir).filter((f) => f.endsWith(".pub"));
|
|
4927
5622
|
if (pubFiles.length === 0) {
|
|
4928
5623
|
console.log(" (none)");
|
|
4929
5624
|
return;
|
|
4930
5625
|
}
|
|
4931
5626
|
for (const file of pubFiles.sort()) {
|
|
4932
5627
|
try {
|
|
4933
|
-
const pem =
|
|
5628
|
+
const pem = readFileSync11(join9(trustedDir, file), "utf8");
|
|
4934
5629
|
const fp = fingerprintFromPem(pem);
|
|
4935
5630
|
const marker = local && fp === local.fingerprint ? " (you)" : "";
|
|
4936
5631
|
console.log(` ${fp}${marker} [${file}]`);
|
|
@@ -4949,15 +5644,15 @@ function keysExport() {
|
|
|
4949
5644
|
function keysTrust(pubFile) {
|
|
4950
5645
|
const repoRoot = findRepoRoot();
|
|
4951
5646
|
const trustedDir = stampTrustedKeysDir(repoRoot);
|
|
4952
|
-
if (!
|
|
5647
|
+
if (!existsSync14(trustedDir)) {
|
|
4953
5648
|
throw new Error(
|
|
4954
5649
|
`no ${trustedDir} \u2014 run \`stamp init\` first to create the trust store`
|
|
4955
5650
|
);
|
|
4956
5651
|
}
|
|
4957
|
-
if (!
|
|
5652
|
+
if (!existsSync14(pubFile)) {
|
|
4958
5653
|
throw new Error(`public key file not found: ${pubFile}`);
|
|
4959
5654
|
}
|
|
4960
|
-
const pem =
|
|
5655
|
+
const pem = readFileSync11(pubFile, "utf8");
|
|
4961
5656
|
let fingerprint;
|
|
4962
5657
|
try {
|
|
4963
5658
|
fingerprint = fingerprintFromPem(pem);
|
|
@@ -4967,12 +5662,12 @@ function keysTrust(pubFile) {
|
|
|
4967
5662
|
);
|
|
4968
5663
|
}
|
|
4969
5664
|
const filename = publicKeyFingerprintFilename(fingerprint);
|
|
4970
|
-
const dest =
|
|
4971
|
-
if (
|
|
5665
|
+
const dest = join9(trustedDir, filename);
|
|
5666
|
+
if (existsSync14(dest)) {
|
|
4972
5667
|
console.log(`${fingerprint} is already trusted (${basename(dest)})`);
|
|
4973
5668
|
return;
|
|
4974
5669
|
}
|
|
4975
|
-
|
|
5670
|
+
writeFileSync11(dest, pem);
|
|
4976
5671
|
console.log(`trusted ${fingerprint}`);
|
|
4977
5672
|
console.log(` \u2192 ${dest}`);
|
|
4978
5673
|
console.log();
|
|
@@ -4980,11 +5675,11 @@ function keysTrust(pubFile) {
|
|
|
4980
5675
|
}
|
|
4981
5676
|
|
|
4982
5677
|
// src/commands/log.ts
|
|
4983
|
-
import { existsSync as
|
|
5678
|
+
import { existsSync as existsSync15 } from "fs";
|
|
4984
5679
|
function runLog(opts) {
|
|
4985
5680
|
const repoRoot = findRepoRoot();
|
|
4986
5681
|
const configPath = stampConfigFile(repoRoot);
|
|
4987
|
-
if (!
|
|
5682
|
+
if (!existsSync15(configPath)) {
|
|
4988
5683
|
throw new Error(
|
|
4989
5684
|
`no .stamp/config.yml at ${configPath}. Run \`stamp init\` first.`
|
|
4990
5685
|
);
|
|
@@ -5101,7 +5796,7 @@ function printCommitDetail(sha, repoRoot) {
|
|
|
5101
5796
|
}
|
|
5102
5797
|
function collectReviewProse(repoRoot, payload) {
|
|
5103
5798
|
const dbPath = stampStateDbPath(repoRoot);
|
|
5104
|
-
if (!
|
|
5799
|
+
if (!existsSync15(dbPath)) return [];
|
|
5105
5800
|
const db = openDb(dbPath);
|
|
5106
5801
|
try {
|
|
5107
5802
|
const rows = latestReviews(db, payload.base_sha, payload.head_sha);
|
|
@@ -5115,7 +5810,7 @@ function printReviewHistory(repoRoot, limit, diff) {
|
|
|
5115
5810
|
const configPath = stampConfigFile(repoRoot);
|
|
5116
5811
|
loadConfig(configPath);
|
|
5117
5812
|
const dbPath = stampStateDbPath(repoRoot);
|
|
5118
|
-
if (!
|
|
5813
|
+
if (!existsSync15(dbPath)) {
|
|
5119
5814
|
console.log("No reviews recorded yet.");
|
|
5120
5815
|
return;
|
|
5121
5816
|
}
|
|
@@ -5156,8 +5851,8 @@ function printReviewHistory(repoRoot, limit, diff) {
|
|
|
5156
5851
|
}
|
|
5157
5852
|
|
|
5158
5853
|
// src/commands/prune.ts
|
|
5159
|
-
import { existsSync as
|
|
5160
|
-
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";
|
|
5161
5856
|
|
|
5162
5857
|
// src/lib/duration.ts
|
|
5163
5858
|
function parseRetentionDuration(input) {
|
|
@@ -5186,16 +5881,16 @@ function runPrune(opts) {
|
|
|
5186
5881
|
);
|
|
5187
5882
|
const repoRoot = findRepoRoot();
|
|
5188
5883
|
const dbPath = stampStateDbPath(repoRoot);
|
|
5189
|
-
const parsesDir =
|
|
5190
|
-
const runsDir =
|
|
5884
|
+
const parsesDir = join10(gitCommonDir(repoRoot), "stamp", "failed-parses");
|
|
5885
|
+
const runsDir = join10(gitCommonDir(repoRoot), "stamp", "failed-runs");
|
|
5191
5886
|
const spoolCutoffMs = Date.now() - durationMs;
|
|
5192
|
-
if (!
|
|
5887
|
+
if (!existsSync16(dbPath) && !existsSync16(parsesDir) && !existsSync16(runsDir)) {
|
|
5193
5888
|
console.log(
|
|
5194
5889
|
`note: nothing to prune (none of ${dbPath}, ${parsesDir}, ${runsDir} exist \u2014 all are created on first \`stamp review\`)`
|
|
5195
5890
|
);
|
|
5196
5891
|
return;
|
|
5197
5892
|
}
|
|
5198
|
-
const db =
|
|
5893
|
+
const db = existsSync16(dbPath) ? openDb(dbPath) : null;
|
|
5199
5894
|
try {
|
|
5200
5895
|
if (opts.dryRun) {
|
|
5201
5896
|
let any2 = false;
|
|
@@ -5265,10 +5960,10 @@ function runPrune(opts) {
|
|
|
5265
5960
|
}
|
|
5266
5961
|
}
|
|
5267
5962
|
function peekSpools(spoolDir, cutoffMs) {
|
|
5268
|
-
if (!
|
|
5963
|
+
if (!existsSync16(spoolDir)) return [];
|
|
5269
5964
|
const out = [];
|
|
5270
|
-
for (const entry of
|
|
5271
|
-
const filepath =
|
|
5965
|
+
for (const entry of readdirSync4(spoolDir)) {
|
|
5966
|
+
const filepath = join10(spoolDir, entry);
|
|
5272
5967
|
let stat;
|
|
5273
5968
|
try {
|
|
5274
5969
|
stat = statSync2(filepath);
|
|
@@ -5302,7 +5997,7 @@ function printPerReviewer(rows) {
|
|
|
5302
5997
|
}
|
|
5303
5998
|
|
|
5304
5999
|
// src/commands/config.ts
|
|
5305
|
-
import { existsSync as
|
|
6000
|
+
import { existsSync as existsSync17 } from "fs";
|
|
5306
6001
|
function runConfigReviewersSet(opts) {
|
|
5307
6002
|
if (!isValidReviewerName(opts.reviewer)) {
|
|
5308
6003
|
throw new UsageError(
|
|
@@ -5375,7 +6070,7 @@ function runConfigReviewersClear(opts) {
|
|
|
5375
6070
|
}
|
|
5376
6071
|
function runConfigReviewersShow() {
|
|
5377
6072
|
const path2 = userConfigPath();
|
|
5378
|
-
if (!
|
|
6073
|
+
if (!existsSync17(path2)) {
|
|
5379
6074
|
console.log(`note: no per-user stamp config (${path2} does not exist).`);
|
|
5380
6075
|
console.log(
|
|
5381
6076
|
` Defaults will apply on next \`stamp init\` or \`stamp review\`:`
|
|
@@ -5420,30 +6115,30 @@ function loadOrEmpty() {
|
|
|
5420
6115
|
}
|
|
5421
6116
|
|
|
5422
6117
|
// src/commands/reviewers.ts
|
|
5423
|
-
import { spawnSync as
|
|
6118
|
+
import { spawnSync as spawnSync10 } from "child_process";
|
|
5424
6119
|
import {
|
|
5425
|
-
existsSync as
|
|
5426
|
-
readFileSync as
|
|
6120
|
+
existsSync as existsSync19,
|
|
6121
|
+
readFileSync as readFileSync13,
|
|
5427
6122
|
statSync as statSync3,
|
|
5428
6123
|
unlinkSync as unlinkSync4,
|
|
5429
|
-
writeFileSync as
|
|
6124
|
+
writeFileSync as writeFileSync13
|
|
5430
6125
|
} from "fs";
|
|
5431
|
-
import { join as
|
|
6126
|
+
import { join as join12, relative, resolve as resolve2 } from "path";
|
|
5432
6127
|
import { parse as parseYaml6, stringify as stringifyYaml3 } from "yaml";
|
|
5433
6128
|
|
|
5434
6129
|
// src/lib/reviewerLock.ts
|
|
5435
|
-
import { existsSync as
|
|
5436
|
-
import { join as
|
|
6130
|
+
import { existsSync as existsSync18, readFileSync as readFileSync12, writeFileSync as writeFileSync12 } from "fs";
|
|
6131
|
+
import { join as join11 } from "path";
|
|
5437
6132
|
var LOCK_FILE_VERSION = 1;
|
|
5438
6133
|
var LOCK_DRIFT_EXIT = 3;
|
|
5439
6134
|
function lockFilePath(repoRoot, reviewerName) {
|
|
5440
|
-
return
|
|
6135
|
+
return join11(repoRoot, ".stamp", "reviewers", `${reviewerName}.lock.json`);
|
|
5441
6136
|
}
|
|
5442
6137
|
function readLockFile(repoRoot, reviewerName) {
|
|
5443
6138
|
const path2 = lockFilePath(repoRoot, reviewerName);
|
|
5444
|
-
if (!
|
|
6139
|
+
if (!existsSync18(path2)) return null;
|
|
5445
6140
|
try {
|
|
5446
|
-
const raw =
|
|
6141
|
+
const raw = readFileSync12(path2, "utf8");
|
|
5447
6142
|
const parsed = JSON.parse(raw);
|
|
5448
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") {
|
|
5449
6144
|
throw new Error(`malformed lock file at ${path2}`);
|
|
@@ -5457,20 +6152,20 @@ function readLockFile(repoRoot, reviewerName) {
|
|
|
5457
6152
|
}
|
|
5458
6153
|
function writeLockFile(repoRoot, reviewerName, lock) {
|
|
5459
6154
|
const path2 = lockFilePath(repoRoot, reviewerName);
|
|
5460
|
-
|
|
6155
|
+
writeFileSync12(path2, JSON.stringify(lock, null, 2) + "\n", "utf8");
|
|
5461
6156
|
}
|
|
5462
6157
|
function checkReviewerDrift(repoRoot, reviewerName, def) {
|
|
5463
6158
|
const lock = readLockFile(repoRoot, reviewerName);
|
|
5464
6159
|
if (!lock) {
|
|
5465
6160
|
return unpinnedResult();
|
|
5466
6161
|
}
|
|
5467
|
-
const promptPath =
|
|
5468
|
-
if (!
|
|
6162
|
+
const promptPath = join11(repoRoot, def.prompt);
|
|
6163
|
+
if (!existsSync18(promptPath)) {
|
|
5469
6164
|
throw new Error(
|
|
5470
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.`
|
|
5471
6166
|
);
|
|
5472
6167
|
}
|
|
5473
|
-
const promptBytes =
|
|
6168
|
+
const promptBytes = readFileSync12(promptPath);
|
|
5474
6169
|
const observedPrompt = hashPromptBytes(promptBytes);
|
|
5475
6170
|
const observedTools = hashTools(def.tools);
|
|
5476
6171
|
const observedMcp = hashMcpServers(def.mcp_servers);
|
|
@@ -5549,9 +6244,9 @@ function reviewersList() {
|
|
|
5549
6244
|
const maxNameLen = Math.max(...names.map((n) => n.length));
|
|
5550
6245
|
for (const name of names) {
|
|
5551
6246
|
const def = config2.reviewers[name];
|
|
5552
|
-
const abs =
|
|
6247
|
+
const abs = resolve2(repoRoot, def.prompt);
|
|
5553
6248
|
let annotation = "";
|
|
5554
|
-
if (!
|
|
6249
|
+
if (!existsSync19(abs)) {
|
|
5555
6250
|
annotation = " MISSING";
|
|
5556
6251
|
} else {
|
|
5557
6252
|
const size = statSync3(abs).size;
|
|
@@ -5575,7 +6270,7 @@ function reviewersEdit(name) {
|
|
|
5575
6270
|
`reviewer "${name}" is not configured. Run \`stamp reviewers list\` to see available reviewers.`
|
|
5576
6271
|
);
|
|
5577
6272
|
}
|
|
5578
|
-
const target =
|
|
6273
|
+
const target = resolve2(repoRoot, def.prompt);
|
|
5579
6274
|
launchEditor(target);
|
|
5580
6275
|
}
|
|
5581
6276
|
function reviewersAdd(name, opts = {}) {
|
|
@@ -5589,20 +6284,20 @@ function reviewersAdd(name, opts = {}) {
|
|
|
5589
6284
|
);
|
|
5590
6285
|
}
|
|
5591
6286
|
const promptRel = `.stamp/reviewers/${name}.md`;
|
|
5592
|
-
const promptAbs =
|
|
5593
|
-
if (
|
|
6287
|
+
const promptAbs = resolve2(repoRoot, promptRel);
|
|
6288
|
+
if (existsSync19(promptAbs)) {
|
|
5594
6289
|
throw new Error(
|
|
5595
6290
|
`${promptRel} already exists on disk but is not in config. Either delete the file or add it to config manually.`
|
|
5596
6291
|
);
|
|
5597
6292
|
}
|
|
5598
|
-
|
|
6293
|
+
writeFileSync13(
|
|
5599
6294
|
promptAbs,
|
|
5600
6295
|
`# ${name}
|
|
5601
6296
|
|
|
5602
6297
|
${EXAMPLE_REVIEWER_PROMPT.split("\n").slice(2).join("\n")}`
|
|
5603
6298
|
);
|
|
5604
6299
|
config2.reviewers[name] = { prompt: promptRel };
|
|
5605
|
-
|
|
6300
|
+
writeFileSync13(configPath, stringifyConfig(config2));
|
|
5606
6301
|
console.log(`reviewer "${name}" added.`);
|
|
5607
6302
|
console.log(` prompt file: ${promptRel}`);
|
|
5608
6303
|
console.log(` registered in .stamp/config.yml`);
|
|
@@ -5637,11 +6332,11 @@ function reviewersRemove(name, opts = {}) {
|
|
|
5637
6332
|
);
|
|
5638
6333
|
}
|
|
5639
6334
|
delete config2.reviewers[name];
|
|
5640
|
-
|
|
6335
|
+
writeFileSync13(configPath, stringifyConfig(config2));
|
|
5641
6336
|
console.log(`reviewer "${name}" removed from .stamp/config.yml`);
|
|
5642
6337
|
if (opts.deleteFile) {
|
|
5643
|
-
const promptAbs =
|
|
5644
|
-
if (
|
|
6338
|
+
const promptAbs = resolve2(repoRoot, def.prompt);
|
|
6339
|
+
if (existsSync19(promptAbs)) {
|
|
5645
6340
|
unlinkSync4(promptAbs);
|
|
5646
6341
|
console.log(`deleted ${def.prompt}`);
|
|
5647
6342
|
}
|
|
@@ -5672,8 +6367,8 @@ async function reviewersTest(name, diff) {
|
|
|
5672
6367
|
console.log(` prompt sourced from working tree (test/iteration use case)`);
|
|
5673
6368
|
console.log();
|
|
5674
6369
|
const def = config2.reviewers[name];
|
|
5675
|
-
const promptPath =
|
|
5676
|
-
const systemPrompt =
|
|
6370
|
+
const promptPath = join12(repoRoot, def.prompt);
|
|
6371
|
+
const systemPrompt = readFileSync13(promptPath, "utf8");
|
|
5677
6372
|
const result = await invokeReviewer({
|
|
5678
6373
|
reviewer: name,
|
|
5679
6374
|
config: config2,
|
|
@@ -5700,7 +6395,7 @@ function reviewersShow(name, opts) {
|
|
|
5700
6395
|
);
|
|
5701
6396
|
}
|
|
5702
6397
|
const dbPath = stampStateDbPath(repoRoot);
|
|
5703
|
-
if (!
|
|
6398
|
+
if (!existsSync19(dbPath)) {
|
|
5704
6399
|
console.log("No reviews recorded yet (no state.db).");
|
|
5705
6400
|
return;
|
|
5706
6401
|
}
|
|
@@ -5790,7 +6485,7 @@ async function reviewersFetch(reviewerName, opts) {
|
|
|
5790
6485
|
opts.expectMcpSha
|
|
5791
6486
|
);
|
|
5792
6487
|
const reviewersDir = stampReviewersDir(repoRoot);
|
|
5793
|
-
if (!
|
|
6488
|
+
if (!existsSync19(reviewersDir)) {
|
|
5794
6489
|
throw new Error(
|
|
5795
6490
|
`${reviewersDir} does not exist \u2014 run \`stamp init\` first.`
|
|
5796
6491
|
);
|
|
@@ -5811,7 +6506,7 @@ async function reviewersFetch(reviewerName, opts) {
|
|
|
5811
6506
|
mcpServers = validateMcpServersFromSource(parsed.mcp_servers, source, ref);
|
|
5812
6507
|
}
|
|
5813
6508
|
}
|
|
5814
|
-
const promptPath =
|
|
6509
|
+
const promptPath = join12(reviewersDir, `${reviewerName}.md`);
|
|
5815
6510
|
const promptBytes = Buffer.from(promptText, "utf8");
|
|
5816
6511
|
const promptSha = hashPromptBytes(promptBytes);
|
|
5817
6512
|
const toolsSha = hashTools(tools);
|
|
@@ -5835,7 +6530,7 @@ async function reviewersFetch(reviewerName, opts) {
|
|
|
5835
6530
|
mcpSha
|
|
5836
6531
|
);
|
|
5837
6532
|
}
|
|
5838
|
-
|
|
6533
|
+
writeFileSync13(promptPath, promptBytes);
|
|
5839
6534
|
const lock = {
|
|
5840
6535
|
version: LOCK_FILE_VERSION,
|
|
5841
6536
|
source,
|
|
@@ -6064,7 +6759,7 @@ function buildConfigYamlHint(reviewerName, tools, mcpServers) {
|
|
|
6064
6759
|
}
|
|
6065
6760
|
function launchEditor(path2) {
|
|
6066
6761
|
const editor = process.env["EDITOR"] ?? process.env["VISUAL"] ?? (process.platform === "win32" ? "notepad" : "vi");
|
|
6067
|
-
const result =
|
|
6762
|
+
const result = spawnSync10(editor, [path2], { stdio: "inherit" });
|
|
6068
6763
|
if (result.error) {
|
|
6069
6764
|
throw new Error(
|
|
6070
6765
|
`failed to launch editor "${editor}": ${result.error.message}`
|
|
@@ -6076,11 +6771,11 @@ function launchEditor(path2) {
|
|
|
6076
6771
|
}
|
|
6077
6772
|
|
|
6078
6773
|
// src/commands/status.ts
|
|
6079
|
-
import { existsSync as
|
|
6774
|
+
import { existsSync as existsSync20 } from "fs";
|
|
6080
6775
|
function runStatus(opts) {
|
|
6081
6776
|
const repoRoot = findRepoRoot();
|
|
6082
6777
|
const configPath = stampConfigFile(repoRoot);
|
|
6083
|
-
if (!
|
|
6778
|
+
if (!existsSync20(configPath)) {
|
|
6084
6779
|
throw new Error(
|
|
6085
6780
|
`no .stamp/config.yml at ${configPath}. Run \`stamp init\` first.`
|
|
6086
6781
|
);
|
|
@@ -6148,17 +6843,17 @@ function printGate(result, base_sha, head_sha) {
|
|
|
6148
6843
|
}
|
|
6149
6844
|
|
|
6150
6845
|
// src/commands/update.ts
|
|
6151
|
-
import { spawnSync as
|
|
6846
|
+
import { spawnSync as spawnSync11 } from "child_process";
|
|
6152
6847
|
|
|
6153
6848
|
// src/lib/version.ts
|
|
6154
|
-
import { readFileSync as
|
|
6155
|
-
import { dirname as dirname5, join as
|
|
6849
|
+
import { readFileSync as readFileSync14 } from "fs";
|
|
6850
|
+
import { dirname as dirname5, join as join13 } from "path";
|
|
6156
6851
|
import { fileURLToPath } from "url";
|
|
6157
6852
|
function readPackageVersion() {
|
|
6158
6853
|
const here = dirname5(fileURLToPath(import.meta.url));
|
|
6159
6854
|
for (let dir = here, i = 0; i < 6; i++) {
|
|
6160
6855
|
try {
|
|
6161
|
-
const raw =
|
|
6856
|
+
const raw = readFileSync14(join13(dir, "package.json"), "utf8");
|
|
6162
6857
|
const pkg = JSON.parse(raw);
|
|
6163
6858
|
if (pkg.name === "@openthink/stamp" && pkg.version) return pkg.version;
|
|
6164
6859
|
} catch {
|
|
@@ -6189,7 +6884,7 @@ function runUpdate() {
|
|
|
6189
6884
|
`);
|
|
6190
6885
|
process.stdout.write(`checking npm registry for latest...
|
|
6191
6886
|
`);
|
|
6192
|
-
const viewResult =
|
|
6887
|
+
const viewResult = spawnSync11("npm", ["view", PKG_NAME, "version"], {
|
|
6193
6888
|
encoding: "utf8"
|
|
6194
6889
|
});
|
|
6195
6890
|
if (viewResult.error || viewResult.status !== 0) {
|
|
@@ -6223,7 +6918,7 @@ function runUpdate() {
|
|
|
6223
6918
|
}
|
|
6224
6919
|
process.stdout.write(`installing ${PKG_NAME}@${latest}...
|
|
6225
6920
|
`);
|
|
6226
|
-
const installResult =
|
|
6921
|
+
const installResult = spawnSync11(
|
|
6227
6922
|
"npm",
|
|
6228
6923
|
["install", "-g", `${PKG_NAME}@${latest}`],
|
|
6229
6924
|
{ stdio: "inherit" }
|
|
@@ -6244,9 +6939,9 @@ through that tool instead \u2014 this command only uses 'npm install -g'.`
|
|
|
6244
6939
|
}
|
|
6245
6940
|
|
|
6246
6941
|
// src/commands/verify.ts
|
|
6247
|
-
import { execFileSync as execFileSync2, spawnSync as
|
|
6942
|
+
import { execFileSync as execFileSync2, spawnSync as spawnSync12 } from "child_process";
|
|
6248
6943
|
function loadConfigAtSha(sha, repoRoot) {
|
|
6249
|
-
const result =
|
|
6944
|
+
const result = spawnSync12(
|
|
6250
6945
|
"git",
|
|
6251
6946
|
["show", `${sha}:.stamp/config.yml`],
|
|
6252
6947
|
{ cwd: repoRoot, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }
|
|
@@ -6845,6 +7540,88 @@ function wrap(fn) {
|
|
|
6845
7540
|
process.exit(1);
|
|
6846
7541
|
}
|
|
6847
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
|
+
);
|
|
6848
7625
|
var reviewers = program.command("reviewers").description("manage reviewer prompts");
|
|
6849
7626
|
reviewers.command("list").description("list configured reviewers and their prompt file status").action(() => wrap(() => reviewersList()));
|
|
6850
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(
|