@openthink/stamp 1.2.0 → 1.3.1
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 +27 -0
- package/dist/hooks/post-receive.cjs +91 -9
- package/dist/hooks/post-receive.cjs.map +1 -1
- package/dist/index.js +1166 -442
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -56,10 +56,13 @@ import { dirname as dirname3, join as join3 } from "path";
|
|
|
56
56
|
// src/lib/agentsMd.ts
|
|
57
57
|
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
58
58
|
import { join } from "path";
|
|
59
|
-
var STAMP_BEGIN = "<!-- stamp:begin (managed by stamp
|
|
59
|
+
var STAMP_BEGIN = "<!-- stamp:begin (managed by `stamp init` \u2014 do not edit between markers) -->";
|
|
60
60
|
var STAMP_END = "<!-- stamp:end -->";
|
|
61
|
-
var
|
|
62
|
-
var
|
|
61
|
+
var STAMP_BEGIN_LEGACY = "<!-- stamp:begin (managed by stamp-cli \u2014 do not edit between markers) -->";
|
|
62
|
+
var STAMP_CLAUDE_BEGIN_LEGACY = "<!-- stamp:claude:begin (managed by stamp-cli \u2014 do not edit between markers) -->";
|
|
63
|
+
var STAMP_CLAUDE_END_LEGACY = "<!-- stamp:claude:end -->";
|
|
64
|
+
var STAMP_BEGIN_PREFIX = "<!-- stamp:begin ";
|
|
65
|
+
var STAMP_CLAUDE_BEGIN_PREFIX = "<!-- stamp:claude:begin ";
|
|
63
66
|
var REVIEW_LOOP_HEURISTIC = `### Knowing when to stop the review loop (diminishing returns)
|
|
64
67
|
|
|
65
68
|
Each \`stamp review\` run is non-trivial \u2014 reviewer LLM calls, your context, and amend
|
|
@@ -248,6 +251,20 @@ attestation, so the audit trail is preserved even without server-side rejection.
|
|
|
248
251
|
|
|
249
252
|
${REVIEW_LOOP_HEURISTIC}
|
|
250
253
|
`;
|
|
254
|
+
function findManagedBlock(text, openPrefix, closeMarker) {
|
|
255
|
+
let searchStart = 0;
|
|
256
|
+
while (searchStart < text.length) {
|
|
257
|
+
const candidateIdx = text.indexOf(openPrefix, searchStart);
|
|
258
|
+
if (candidateIdx === -1) return null;
|
|
259
|
+
if (candidateIdx === 0 || text[candidateIdx - 1] === "\n") {
|
|
260
|
+
const closeStart = text.indexOf(closeMarker, candidateIdx);
|
|
261
|
+
if (closeStart === -1) return null;
|
|
262
|
+
return { beginIdx: candidateIdx, afterEnd: closeStart + closeMarker.length };
|
|
263
|
+
}
|
|
264
|
+
searchStart = candidateIdx + 1;
|
|
265
|
+
}
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
251
268
|
function injectStampSection(existing, mode = "server-gated") {
|
|
252
269
|
const body = mode === "server-gated" ? STAMP_AGENTS_SECTION_SERVER_GATED : STAMP_AGENTS_SECTION_LOCAL_ONLY;
|
|
253
270
|
const stampBlock = `${STAMP_BEGIN}
|
|
@@ -263,12 +280,10 @@ Guidance for AI agents working in this repository.
|
|
|
263
280
|
${stampBlock}
|
|
264
281
|
`;
|
|
265
282
|
}
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
const afterStart = endIdx + STAMP_END.length;
|
|
271
|
-
const after = existing.slice(afterStart);
|
|
283
|
+
const found = findManagedBlock(existing, STAMP_BEGIN_PREFIX, STAMP_END);
|
|
284
|
+
if (found) {
|
|
285
|
+
const before = existing.slice(0, found.beginIdx);
|
|
286
|
+
const after = existing.slice(found.afterEnd);
|
|
272
287
|
return `${before}${stampBlock}${after}`;
|
|
273
288
|
}
|
|
274
289
|
return `${existing.trimEnd()}
|
|
@@ -285,7 +300,7 @@ function ensureAgentsMd(repoRoot, mode = "server-gated") {
|
|
|
285
300
|
const existing = readFileSync(path2, "utf8");
|
|
286
301
|
const updated = injectStampSection(existing, mode);
|
|
287
302
|
if (updated === existing) return "unchanged";
|
|
288
|
-
const action = existing.includes(STAMP_BEGIN) ? "replaced" : "appended";
|
|
303
|
+
const action = existing.includes(STAMP_BEGIN) || existing.includes(STAMP_BEGIN_LEGACY) ? "replaced" : "appended";
|
|
289
304
|
writeFileSync(path2, updated);
|
|
290
305
|
return action;
|
|
291
306
|
}
|
|
@@ -306,6 +321,8 @@ stamp merge feature --into main # signs the merge
|
|
|
306
321
|
git push origin main # OR \`stamp push main\` if origin is a stamp server
|
|
307
322
|
\`\`\`
|
|
308
323
|
|
|
324
|
+
Key commands: \`stamp provision\` \u2014 provision a new repo; \`stamp review\` \u2014 run reviewers; \`stamp merge\` \u2014 sign a merge; \`stamp push\` \u2014 push to a stamp server.
|
|
325
|
+
|
|
309
326
|
**The full reference is at [\`AGENTS.md\`](./AGENTS.md) at the repo root** \u2014
|
|
310
327
|
read it before any git command. It covers the mode (server-gated vs.
|
|
311
328
|
local-only), what NOT to do, where things live, and how to recover when stamp
|
|
@@ -316,11 +333,11 @@ blocks you.
|
|
|
316
333
|
(there's nothing to review against). Recent \`stamp init\` runs do this commit
|
|
317
334
|
automatically. Every subsequent change goes through the stamp flow.`;
|
|
318
335
|
function injectClaudeSection(existing) {
|
|
319
|
-
const stampBlock = `${
|
|
336
|
+
const stampBlock = `${STAMP_BEGIN}
|
|
320
337
|
|
|
321
338
|
${STAMP_CLAUDE_SECTION.trimEnd()}
|
|
322
339
|
|
|
323
|
-
${
|
|
340
|
+
${STAMP_END}`;
|
|
324
341
|
if (existing === void 0 || existing.trim() === "") {
|
|
325
342
|
return `# CLAUDE.md
|
|
326
343
|
|
|
@@ -329,12 +346,10 @@ Project-specific instructions for Claude Code (auto-loaded into the model's cont
|
|
|
329
346
|
${stampBlock}
|
|
330
347
|
`;
|
|
331
348
|
}
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
const
|
|
336
|
-
const afterStart = endIdx + STAMP_CLAUDE_END.length;
|
|
337
|
-
const after = existing.slice(afterStart);
|
|
349
|
+
const found = findManagedBlock(existing, STAMP_BEGIN_PREFIX, STAMP_END) ?? findManagedBlock(existing, STAMP_CLAUDE_BEGIN_PREFIX, STAMP_CLAUDE_END_LEGACY);
|
|
350
|
+
if (found) {
|
|
351
|
+
const before = existing.slice(0, found.beginIdx);
|
|
352
|
+
const after = existing.slice(found.afterEnd);
|
|
338
353
|
return `${before}${stampBlock}${after}`;
|
|
339
354
|
}
|
|
340
355
|
return `${existing.trimEnd()}
|
|
@@ -351,7 +366,7 @@ function ensureClaudeMd(repoRoot) {
|
|
|
351
366
|
const existing = readFileSync(path2, "utf8");
|
|
352
367
|
const updated = injectClaudeSection(existing);
|
|
353
368
|
if (updated === existing) return "unchanged";
|
|
354
|
-
const action = existing.includes(
|
|
369
|
+
const action = existing.includes(STAMP_BEGIN) || existing.includes(STAMP_BEGIN_LEGACY) || existing.includes(STAMP_CLAUDE_BEGIN_LEGACY) ? "replaced" : "appended";
|
|
355
370
|
writeFileSync(path2, updated);
|
|
356
371
|
return action;
|
|
357
372
|
}
|
|
@@ -2832,11 +2847,12 @@ function branchExists(name, cwd) {
|
|
|
2832
2847
|
}
|
|
2833
2848
|
|
|
2834
2849
|
// src/commands/init.ts
|
|
2835
|
-
import { existsSync as
|
|
2836
|
-
import { join as
|
|
2850
|
+
import { existsSync as existsSync9, writeFileSync as writeFileSync7 } from "fs";
|
|
2851
|
+
import { join as join5 } from "path";
|
|
2837
2852
|
|
|
2838
2853
|
// src/lib/ghRuleset.ts
|
|
2839
2854
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
2855
|
+
var STAMP_MIRROR_DEPLOY_KEY_TITLE = "stamp-mirror";
|
|
2840
2856
|
function checkGhAvailable() {
|
|
2841
2857
|
const v = spawnSync3("gh", ["--version"], {
|
|
2842
2858
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -2980,6 +2996,330 @@ function applyStampRuleset(owner, repo, actor) {
|
|
|
2980
2996
|
return { status: "created" };
|
|
2981
2997
|
}
|
|
2982
2998
|
}
|
|
2999
|
+
function findDeployKey(owner, repo, title) {
|
|
3000
|
+
const r = spawnSync3(
|
|
3001
|
+
"gh",
|
|
3002
|
+
[
|
|
3003
|
+
"api",
|
|
3004
|
+
`/repos/${owner}/${repo}/keys`,
|
|
3005
|
+
"--jq",
|
|
3006
|
+
// JSON.stringify produces a valid jq string literal (double-quoted,
|
|
3007
|
+
// with backslash/quote escapes), so a title containing quotes or
|
|
3008
|
+
// backslashes can't break the jq filter or smuggle a different
|
|
3009
|
+
// selector.
|
|
3010
|
+
`[.[] | select(.title == ${JSON.stringify(title)})][0].id // empty`
|
|
3011
|
+
],
|
|
3012
|
+
{ stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" }
|
|
3013
|
+
);
|
|
3014
|
+
if (r.status !== 0) return null;
|
|
3015
|
+
const trimmed = r.stdout.trim();
|
|
3016
|
+
if (!trimmed) return null;
|
|
3017
|
+
const id = Number(trimmed);
|
|
3018
|
+
return Number.isFinite(id) ? id : null;
|
|
3019
|
+
}
|
|
3020
|
+
function registerDeployKey(owner, repo, title, publicKey) {
|
|
3021
|
+
const ctx = `${owner}/${repo} (deploy key "${title}")`;
|
|
3022
|
+
const existing = findDeployKey(owner, repo, title);
|
|
3023
|
+
if (existing !== null) {
|
|
3024
|
+
return { status: "exists", keyId: existing };
|
|
3025
|
+
}
|
|
3026
|
+
const body = { title, key: publicKey, read_only: false };
|
|
3027
|
+
const r = spawnSync3(
|
|
3028
|
+
"gh",
|
|
3029
|
+
[
|
|
3030
|
+
"api",
|
|
3031
|
+
"-X",
|
|
3032
|
+
"POST",
|
|
3033
|
+
`/repos/${owner}/${repo}/keys`,
|
|
3034
|
+
"--input",
|
|
3035
|
+
"-"
|
|
3036
|
+
],
|
|
3037
|
+
{
|
|
3038
|
+
input: JSON.stringify(body),
|
|
3039
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
3040
|
+
encoding: "utf8"
|
|
3041
|
+
}
|
|
3042
|
+
);
|
|
3043
|
+
if (r.status !== 0) {
|
|
3044
|
+
const stderr = (r.stderr ?? "").trim();
|
|
3045
|
+
const stdout = (r.stdout ?? "").trim();
|
|
3046
|
+
const detail = stderr || stdout || `gh api exited ${r.status}`;
|
|
3047
|
+
return {
|
|
3048
|
+
status: "failed",
|
|
3049
|
+
error: `${ctx}: ${detail}`
|
|
3050
|
+
};
|
|
3051
|
+
}
|
|
3052
|
+
let parsed;
|
|
3053
|
+
try {
|
|
3054
|
+
parsed = JSON.parse(r.stdout);
|
|
3055
|
+
} catch (err) {
|
|
3056
|
+
return {
|
|
3057
|
+
status: "failed",
|
|
3058
|
+
error: `${ctx}: registered, but couldn't parse keyId from gh response (${err instanceof Error ? err.message : String(err)}). A keyId is required to wire the key into a Ruleset bypass actor; inspect with \`gh api /repos/${owner}/${repo}/keys\`.`
|
|
3059
|
+
};
|
|
3060
|
+
}
|
|
3061
|
+
const keyId = typeof parsed.id === "number" ? parsed.id : NaN;
|
|
3062
|
+
if (!Number.isFinite(keyId)) {
|
|
3063
|
+
return {
|
|
3064
|
+
status: "failed",
|
|
3065
|
+
error: `${ctx}: registered, but gh response did not include a numeric keyId. A keyId is required to wire the key into a Ruleset bypass actor; inspect with \`gh api /repos/${owner}/${repo}/keys\`.`
|
|
3066
|
+
};
|
|
3067
|
+
}
|
|
3068
|
+
return { status: "created", keyId };
|
|
3069
|
+
}
|
|
3070
|
+
function fetchDeployKeyPublic(owner, repo, keyId) {
|
|
3071
|
+
const r = spawnSync3(
|
|
3072
|
+
"gh",
|
|
3073
|
+
["api", `/repos/${owner}/${repo}/keys/${keyId}`, "--jq", ".key"],
|
|
3074
|
+
{ stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" }
|
|
3075
|
+
);
|
|
3076
|
+
if (r.status !== 0) return null;
|
|
3077
|
+
const trimmed = r.stdout.trim();
|
|
3078
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
3079
|
+
}
|
|
3080
|
+
function deleteDeployKey(owner, repo, keyId) {
|
|
3081
|
+
const r = spawnSync3(
|
|
3082
|
+
"gh",
|
|
3083
|
+
["api", "-X", "DELETE", `/repos/${owner}/${repo}/keys/${keyId}`],
|
|
3084
|
+
{ stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" }
|
|
3085
|
+
);
|
|
3086
|
+
if (r.status === 0) return { status: "deleted" };
|
|
3087
|
+
const stderr = (r.stderr ?? "").trim();
|
|
3088
|
+
if (stderr.includes("HTTP 404") || /Not Found/i.test(stderr)) {
|
|
3089
|
+
return { status: "deleted" };
|
|
3090
|
+
}
|
|
3091
|
+
return {
|
|
3092
|
+
status: "failed",
|
|
3093
|
+
error: `${owner}/${repo} keyId=${keyId}: ${stderr || `gh api exited ${r.status}`}`
|
|
3094
|
+
};
|
|
3095
|
+
}
|
|
3096
|
+
function getRulesetBypassActors(owner, repo, rulesetId) {
|
|
3097
|
+
const r = spawnSync3(
|
|
3098
|
+
"gh",
|
|
3099
|
+
[
|
|
3100
|
+
"api",
|
|
3101
|
+
`/repos/${owner}/${repo}/rulesets/${rulesetId}`,
|
|
3102
|
+
"--jq",
|
|
3103
|
+
".bypass_actors"
|
|
3104
|
+
],
|
|
3105
|
+
{ stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" }
|
|
3106
|
+
);
|
|
3107
|
+
if (r.status !== 0) return null;
|
|
3108
|
+
try {
|
|
3109
|
+
const parsed = JSON.parse(r.stdout);
|
|
3110
|
+
if (!Array.isArray(parsed)) return null;
|
|
3111
|
+
return parsed.filter((e) => {
|
|
3112
|
+
if (typeof e !== "object" || e === null) return false;
|
|
3113
|
+
const o = e;
|
|
3114
|
+
return (typeof o["actor_id"] === "number" || o["actor_id"] === null) && typeof o["actor_type"] === "string" && typeof o["bypass_mode"] === "string";
|
|
3115
|
+
});
|
|
3116
|
+
} catch {
|
|
3117
|
+
return null;
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
function replaceBypassActors(owner, repo, rulesetId, desiredActors) {
|
|
3121
|
+
const current = getRulesetBypassActors(owner, repo, rulesetId);
|
|
3122
|
+
if (current === null) {
|
|
3123
|
+
return {
|
|
3124
|
+
status: "failed",
|
|
3125
|
+
error: `${owner}/${repo} ruleset ${rulesetId}: could not read current bypass_actors`
|
|
3126
|
+
};
|
|
3127
|
+
}
|
|
3128
|
+
if (bypassActorListsEqual(current, desiredActors)) {
|
|
3129
|
+
return { status: "unchanged" };
|
|
3130
|
+
}
|
|
3131
|
+
const body = { bypass_actors: desiredActors };
|
|
3132
|
+
const r = spawnSync3(
|
|
3133
|
+
"gh",
|
|
3134
|
+
[
|
|
3135
|
+
"api",
|
|
3136
|
+
"-X",
|
|
3137
|
+
"PUT",
|
|
3138
|
+
`/repos/${owner}/${repo}/rulesets/${rulesetId}`,
|
|
3139
|
+
"--input",
|
|
3140
|
+
"-"
|
|
3141
|
+
],
|
|
3142
|
+
{
|
|
3143
|
+
input: JSON.stringify(body),
|
|
3144
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
3145
|
+
encoding: "utf8"
|
|
3146
|
+
}
|
|
3147
|
+
);
|
|
3148
|
+
if (r.status !== 0) {
|
|
3149
|
+
const stderr = (r.stderr ?? "").trim();
|
|
3150
|
+
const stdout = (r.stdout ?? "").trim();
|
|
3151
|
+
return {
|
|
3152
|
+
status: "failed",
|
|
3153
|
+
error: `${owner}/${repo} ruleset ${rulesetId}: ${stderr || stdout || `gh api exited ${r.status}`}`
|
|
3154
|
+
};
|
|
3155
|
+
}
|
|
3156
|
+
return { status: "updated" };
|
|
3157
|
+
}
|
|
3158
|
+
function bypassActorListsEqual(a, b) {
|
|
3159
|
+
if (a.length !== b.length) return false;
|
|
3160
|
+
const aSet = new Set(a.map(normalizeBypassActor));
|
|
3161
|
+
return b.every((e) => aSet.has(normalizeBypassActor(e)));
|
|
3162
|
+
}
|
|
3163
|
+
function normalizeBypassActor(e) {
|
|
3164
|
+
const id = e.actor_type === "OrganizationAdmin" && (e.actor_id === null || e.actor_id === 1) ? "ORGADMIN" : String(e.actor_id);
|
|
3165
|
+
return `${e.actor_type}:${id}:${e.bypass_mode}`;
|
|
3166
|
+
}
|
|
3167
|
+
function computeDesiredBypassActors(current, deployKeyId, flags) {
|
|
3168
|
+
const out = [];
|
|
3169
|
+
for (const a of current) {
|
|
3170
|
+
if (a.actor_type === "OrganizationAdmin" && flags.removeOrgadmin) {
|
|
3171
|
+
continue;
|
|
3172
|
+
}
|
|
3173
|
+
if (a.actor_type === "DeployKey") {
|
|
3174
|
+
continue;
|
|
3175
|
+
}
|
|
3176
|
+
out.push(a);
|
|
3177
|
+
}
|
|
3178
|
+
out.push({
|
|
3179
|
+
actor_id: deployKeyId,
|
|
3180
|
+
actor_type: "DeployKey",
|
|
3181
|
+
bypass_mode: "always"
|
|
3182
|
+
});
|
|
3183
|
+
return out;
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
// src/lib/oteamConfig.ts
|
|
3187
|
+
import { existsSync as existsSync7, readFileSync as readFileSync6, renameSync as renameSync2, writeFileSync as writeFileSync6 } from "fs";
|
|
3188
|
+
import { homedir } from "os";
|
|
3189
|
+
import { join as join4 } from "path";
|
|
3190
|
+
var OTEAM_CONFIG_PATH = join4(homedir(), ".open-team", "config.json");
|
|
3191
|
+
function readOteamConfig(configPath = OTEAM_CONFIG_PATH) {
|
|
3192
|
+
if (!existsSync7(configPath)) return null;
|
|
3193
|
+
try {
|
|
3194
|
+
const parsed = JSON.parse(readFileSync6(configPath, "utf8"));
|
|
3195
|
+
if (Array.isArray(parsed)) {
|
|
3196
|
+
throw new Error("config must be a JSON object, not an array");
|
|
3197
|
+
}
|
|
3198
|
+
return parsed;
|
|
3199
|
+
} catch (err) {
|
|
3200
|
+
throw new Error(
|
|
3201
|
+
`${configPath}: ${err instanceof Error ? err.message : String(err)}`
|
|
3202
|
+
);
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
function patchStampHost(host, configPath = OTEAM_CONFIG_PATH) {
|
|
3206
|
+
let config2 = {};
|
|
3207
|
+
if (existsSync7(configPath)) {
|
|
3208
|
+
try {
|
|
3209
|
+
const parsed = JSON.parse(readFileSync6(configPath, "utf8"));
|
|
3210
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
3211
|
+
config2 = parsed;
|
|
3212
|
+
}
|
|
3213
|
+
} catch (err) {
|
|
3214
|
+
throw new Error(
|
|
3215
|
+
`${configPath}: ${err instanceof Error ? err.message : String(err)}`
|
|
3216
|
+
);
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
const existing = config2.stamp;
|
|
3220
|
+
const stamp = typeof existing === "object" && existing !== null ? { ...existing } : {};
|
|
3221
|
+
stamp["host"] = host;
|
|
3222
|
+
config2["stamp"] = stamp;
|
|
3223
|
+
const tmp = `${configPath}.tmp`;
|
|
3224
|
+
try {
|
|
3225
|
+
writeFileSync6(tmp, JSON.stringify(config2, null, 2) + "\n", "utf8");
|
|
3226
|
+
renameSync2(tmp, configPath);
|
|
3227
|
+
} catch (err) {
|
|
3228
|
+
throw new Error(
|
|
3229
|
+
`${configPath}: ${err instanceof Error ? err.message : String(err)}`
|
|
3230
|
+
);
|
|
3231
|
+
}
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
// src/lib/serverConfig.ts
|
|
3235
|
+
import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
|
|
3236
|
+
import { parse as parseYaml4 } from "yaml";
|
|
3237
|
+
var DEFAULT_USER = "git";
|
|
3238
|
+
var DEFAULT_REPO_ROOT = "/srv/git";
|
|
3239
|
+
var USER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
|
|
3240
|
+
var HOST_RE = /^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$/;
|
|
3241
|
+
var REPO_ROOT_RE = /^(\/[A-Za-z0-9_-][A-Za-z0-9._-]*)+\/?$/;
|
|
3242
|
+
function describeShape2(field) {
|
|
3243
|
+
switch (field) {
|
|
3244
|
+
case "user":
|
|
3245
|
+
return "alphanumerics + . _ -, must not start with -";
|
|
3246
|
+
case "host":
|
|
3247
|
+
return "hostname-shaped (alphanumerics + . -, must start and end with alphanumeric)";
|
|
3248
|
+
case "repo_root_prefix":
|
|
3249
|
+
return "absolute path with alphanumeric/. _ - segments, no .. components";
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3252
|
+
function validateField(field, value, contextPath) {
|
|
3253
|
+
const re = field === "user" ? USER_RE : field === "host" ? HOST_RE : REPO_ROOT_RE;
|
|
3254
|
+
if (!re.test(value)) {
|
|
3255
|
+
throw new Error(
|
|
3256
|
+
`${contextPath}: '${field}' has an invalid shape (got ${JSON.stringify(value)}). Allowed: ${describeShape2(field)}.`
|
|
3257
|
+
);
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
function loadServerConfig() {
|
|
3261
|
+
const path2 = userServerConfigPath();
|
|
3262
|
+
if (!existsSync8(path2)) return null;
|
|
3263
|
+
let raw;
|
|
3264
|
+
try {
|
|
3265
|
+
raw = readFileSync7(path2, "utf8");
|
|
3266
|
+
} catch (err) {
|
|
3267
|
+
throw new Error(
|
|
3268
|
+
`failed to read ${path2}: ${err instanceof Error ? err.message : String(err)}`
|
|
3269
|
+
);
|
|
3270
|
+
}
|
|
3271
|
+
return parseServerConfig(raw, path2);
|
|
3272
|
+
}
|
|
3273
|
+
function parseServerConfig(raw, contextPath = "<inline>") {
|
|
3274
|
+
const parsed = parseYaml4(raw);
|
|
3275
|
+
if (!parsed || typeof parsed !== "object") {
|
|
3276
|
+
throw new Error(`${contextPath}: must be a YAML mapping with at least 'host' and 'port'`);
|
|
3277
|
+
}
|
|
3278
|
+
const obj = parsed;
|
|
3279
|
+
if (typeof obj.host !== "string" || !obj.host.trim()) {
|
|
3280
|
+
throw new Error(`${contextPath}: 'host' is required and must be a non-empty string`);
|
|
3281
|
+
}
|
|
3282
|
+
if (typeof obj.port !== "number" || !Number.isInteger(obj.port) || obj.port < 1 || obj.port > 65535) {
|
|
3283
|
+
throw new Error(`${contextPath}: 'port' is required and must be an integer 1..65535`);
|
|
3284
|
+
}
|
|
3285
|
+
const host = obj.host.trim();
|
|
3286
|
+
validateField("host", host, contextPath);
|
|
3287
|
+
const user = typeof obj.user === "string" && obj.user.trim() ? obj.user.trim() : DEFAULT_USER;
|
|
3288
|
+
validateField("user", user, contextPath);
|
|
3289
|
+
const repoRootPrefix = typeof obj.repo_root_prefix === "string" && obj.repo_root_prefix.trim() ? obj.repo_root_prefix.trim() : DEFAULT_REPO_ROOT;
|
|
3290
|
+
validateField("repo_root_prefix", repoRootPrefix, contextPath);
|
|
3291
|
+
return {
|
|
3292
|
+
host,
|
|
3293
|
+
port: obj.port,
|
|
3294
|
+
user,
|
|
3295
|
+
repoRootPrefix
|
|
3296
|
+
};
|
|
3297
|
+
}
|
|
3298
|
+
function parseServerFlag(value, context = "--server") {
|
|
3299
|
+
const m = value.trim().match(/^([^:]+):(\d+)$/);
|
|
3300
|
+
if (!m) {
|
|
3301
|
+
throw new Error(
|
|
3302
|
+
`${context} must be in the form <host>:<port> (got "${value}")`
|
|
3303
|
+
);
|
|
3304
|
+
}
|
|
3305
|
+
const port = Number(m[2]);
|
|
3306
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
3307
|
+
throw new Error(
|
|
3308
|
+
`${context}: port must be an integer 1..65535 (got "${m[2]}")`
|
|
3309
|
+
);
|
|
3310
|
+
}
|
|
3311
|
+
const host = m[1];
|
|
3312
|
+
validateField("host", host, context);
|
|
3313
|
+
return {
|
|
3314
|
+
host,
|
|
3315
|
+
port,
|
|
3316
|
+
user: DEFAULT_USER,
|
|
3317
|
+
repoRootPrefix: DEFAULT_REPO_ROOT
|
|
3318
|
+
};
|
|
3319
|
+
}
|
|
3320
|
+
function bareRepoSshUrl(cfg, repoName) {
|
|
3321
|
+
return `ssh://${cfg.user}@${cfg.host}:${cfg.port}${cfg.repoRootPrefix}/${repoName}.git`;
|
|
3322
|
+
}
|
|
2983
3323
|
|
|
2984
3324
|
// src/commands/init.ts
|
|
2985
3325
|
function runInit(opts = {}) {
|
|
@@ -3004,41 +3344,41 @@ That command handles the bare-repo creation, clone, bootstrap merge, GitHub mirr
|
|
|
3004
3344
|
For local-only / advisory use against this GitHub repo: re-run with \`stamp init --mode local-only\`. That mode is honest about the lack of server-side enforcement (signed merges still work, but \`git push origin main\` will not be rejected by the remote).`
|
|
3005
3345
|
);
|
|
3006
3346
|
}
|
|
3007
|
-
const alreadyHasConfig =
|
|
3347
|
+
const alreadyHasConfig = existsSync9(configFile);
|
|
3008
3348
|
ensureDir(configDir);
|
|
3009
3349
|
ensureDir(reviewersDir);
|
|
3010
3350
|
ensureDir(trustedKeysDir);
|
|
3011
3351
|
if (!alreadyHasConfig) {
|
|
3012
3352
|
if (opts.minimal) {
|
|
3013
|
-
|
|
3014
|
-
|
|
3353
|
+
writeFileSync7(configFile, stringifyConfig(MINIMAL_CONFIG));
|
|
3354
|
+
writeFileSync7(join5(reviewersDir, "example.md"), EXAMPLE_REVIEWER_PROMPT);
|
|
3015
3355
|
} else {
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3356
|
+
writeFileSync7(configFile, stringifyConfig(DEFAULT_CONFIG));
|
|
3357
|
+
writeFileSync7(
|
|
3358
|
+
join5(reviewersDir, "security.md"),
|
|
3019
3359
|
DEFAULT_SECURITY_PROMPT
|
|
3020
3360
|
);
|
|
3021
|
-
|
|
3022
|
-
|
|
3361
|
+
writeFileSync7(
|
|
3362
|
+
join5(reviewersDir, "standards.md"),
|
|
3023
3363
|
DEFAULT_STANDARDS_PROMPT
|
|
3024
3364
|
);
|
|
3025
|
-
|
|
3026
|
-
|
|
3365
|
+
writeFileSync7(
|
|
3366
|
+
join5(reviewersDir, "product.md"),
|
|
3027
3367
|
DEFAULT_PRODUCT_PROMPT
|
|
3028
3368
|
);
|
|
3029
3369
|
}
|
|
3030
3370
|
}
|
|
3031
3371
|
const { keypair, created: keyCreated } = ensureUserKeypair();
|
|
3032
3372
|
const userCfg = loadOrCreateUserConfig();
|
|
3033
|
-
const pubKeyPath =
|
|
3373
|
+
const pubKeyPath = join5(
|
|
3034
3374
|
trustedKeysDir,
|
|
3035
3375
|
publicKeyFingerprintFilename(keypair.fingerprint)
|
|
3036
3376
|
);
|
|
3037
|
-
const keyDeposited = !
|
|
3377
|
+
const keyDeposited = !existsSync9(pubKeyPath);
|
|
3038
3378
|
if (keyDeposited) {
|
|
3039
|
-
|
|
3379
|
+
writeFileSync7(pubKeyPath, keypair.publicKeyPem);
|
|
3040
3380
|
}
|
|
3041
|
-
const dbExisted =
|
|
3381
|
+
const dbExisted = existsSync9(stateDbPath);
|
|
3042
3382
|
const db = openDb(stateDbPath);
|
|
3043
3383
|
db.close();
|
|
3044
3384
|
const agentsMdAction = opts.agentsMd === false ? "skipped" : ensureAgentsMd(repoRoot, effectiveMode);
|
|
@@ -3078,6 +3418,9 @@ For local-only / advisory use against this GitHub repo: re-run with \`stamp init
|
|
|
3078
3418
|
if (opts.bootstrapCommit !== false) {
|
|
3079
3419
|
printBootstrapCommitResult(runBootstrapCommit(repoRoot, scaffoldOrSync));
|
|
3080
3420
|
}
|
|
3421
|
+
if (opts.oteam !== false) {
|
|
3422
|
+
maybeOfferOteamHostFill();
|
|
3423
|
+
}
|
|
3081
3424
|
const ghProtectOpt = opts.ghProtect !== false;
|
|
3082
3425
|
if (ghProtectOpt && remoteClass.shape === "forge-direct" && remoteClass.forge === "github.com" && remoteClass.url) {
|
|
3083
3426
|
applyGitHubRulesetWithReporting(remoteClass.url);
|
|
@@ -3168,8 +3511,8 @@ function runBootstrapCommit(repoRoot, scaffoldOrSync) {
|
|
|
3168
3511
|
return { kind: "skipped-already-tracked" };
|
|
3169
3512
|
}
|
|
3170
3513
|
const toAdd = [".stamp"];
|
|
3171
|
-
if (
|
|
3172
|
-
if (
|
|
3514
|
+
if (existsSync9(join5(repoRoot, "AGENTS.md"))) toAdd.push("AGENTS.md");
|
|
3515
|
+
if (existsSync9(join5(repoRoot, "CLAUDE.md"))) toAdd.push("CLAUDE.md");
|
|
3173
3516
|
runGit(["add", ...toAdd], repoRoot);
|
|
3174
3517
|
let hasStagedChanges = false;
|
|
3175
3518
|
try {
|
|
@@ -3295,6 +3638,44 @@ function applyGitHubRulesetWithReporting(remoteUrl) {
|
|
|
3295
3638
|
break;
|
|
3296
3639
|
}
|
|
3297
3640
|
}
|
|
3641
|
+
function maybeOfferOteamHostFill() {
|
|
3642
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return;
|
|
3643
|
+
let oteamCfg;
|
|
3644
|
+
try {
|
|
3645
|
+
oteamCfg = readOteamConfig();
|
|
3646
|
+
} catch {
|
|
3647
|
+
return;
|
|
3648
|
+
}
|
|
3649
|
+
if (oteamCfg === null) return;
|
|
3650
|
+
const cfg = oteamCfg;
|
|
3651
|
+
const stamp = cfg.stamp;
|
|
3652
|
+
if (stamp?.host) return;
|
|
3653
|
+
let serverCfg;
|
|
3654
|
+
try {
|
|
3655
|
+
serverCfg = loadServerConfig();
|
|
3656
|
+
} catch {
|
|
3657
|
+
return;
|
|
3658
|
+
}
|
|
3659
|
+
if (!serverCfg) return;
|
|
3660
|
+
const host = serverCfg.host;
|
|
3661
|
+
process.stdout.write(
|
|
3662
|
+
`Set oteam's \`stamp.host\` to "${host}"? [y/N] `
|
|
3663
|
+
);
|
|
3664
|
+
const answer = readLineSync().trim().toLowerCase();
|
|
3665
|
+
if (answer !== "y" && answer !== "yes") return;
|
|
3666
|
+
try {
|
|
3667
|
+
patchStampHost(host);
|
|
3668
|
+
console.log(
|
|
3669
|
+
`oteam config: stamp.host set to "${host}" in ~/.open-team/config.json`
|
|
3670
|
+
);
|
|
3671
|
+
console.log();
|
|
3672
|
+
} catch (err) {
|
|
3673
|
+
console.log(
|
|
3674
|
+
`warning: could not patch ~/.open-team/config.json: ${err instanceof Error ? err.message : String(err)}`
|
|
3675
|
+
);
|
|
3676
|
+
console.log();
|
|
3677
|
+
}
|
|
3678
|
+
}
|
|
3298
3679
|
function resolveMode(userMode, remoteClass) {
|
|
3299
3680
|
const warnings = [];
|
|
3300
3681
|
if (userMode === "local-only") {
|
|
@@ -3337,104 +3718,401 @@ function resolveMode(userMode, remoteClass) {
|
|
|
3337
3718
|
}
|
|
3338
3719
|
|
|
3339
3720
|
// src/commands/provision.ts
|
|
3340
|
-
import { spawnSync as
|
|
3341
|
-
import { existsSync as
|
|
3721
|
+
import { spawnSync as spawnSync6 } from "child_process";
|
|
3722
|
+
import { existsSync as existsSync11, mkdtempSync, readFileSync as readFileSync8, rmSync, writeFileSync as writeFileSync9 } from "fs";
|
|
3342
3723
|
import { tmpdir } from "os";
|
|
3343
|
-
import { join as
|
|
3724
|
+
import { join as join6, resolve as resolvePath } from "path";
|
|
3725
|
+
import { parse as parseYaml5 } from "yaml";
|
|
3344
3726
|
|
|
3345
|
-
// src/
|
|
3346
|
-
import {
|
|
3347
|
-
import {
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
var
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3727
|
+
// src/commands/server.ts
|
|
3728
|
+
import { spawnSync as spawnSync5 } from "child_process";
|
|
3729
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync4, renameSync as renameSync3, unlinkSync as unlinkSync2, writeFileSync as writeFileSync8 } from "fs";
|
|
3730
|
+
import { dirname as dirname4 } from "path";
|
|
3731
|
+
import { stringify as stringifyYaml2 } from "yaml";
|
|
3732
|
+
|
|
3733
|
+
// src/lib/perRepoKey.ts
|
|
3734
|
+
var VALID_OWNER = /^[A-Za-z0-9-]+$/;
|
|
3735
|
+
var VALID_REPO = /^[A-Za-z0-9._-]+$/;
|
|
3736
|
+
var SSH_CLIENT_KEY_DIR = "/srv/git/.ssh-client-keys";
|
|
3737
|
+
function computePerRepoKeyPath(githubRepo) {
|
|
3738
|
+
if (typeof githubRepo !== "string" || githubRepo.length === 0) {
|
|
3739
|
+
throw new Error("computePerRepoKeyPath: githubRepo must be a non-empty string");
|
|
3740
|
+
}
|
|
3741
|
+
if (githubRepo.startsWith("-")) {
|
|
3742
|
+
throw new Error(
|
|
3743
|
+
`computePerRepoKeyPath: githubRepo must not start with '-': ${githubRepo}`
|
|
3744
|
+
);
|
|
3745
|
+
}
|
|
3746
|
+
if (githubRepo.includes("..")) {
|
|
3747
|
+
throw new Error(
|
|
3748
|
+
`computePerRepoKeyPath: githubRepo must not contain '..': ${githubRepo}`
|
|
3749
|
+
);
|
|
3750
|
+
}
|
|
3751
|
+
const slashCount = (githubRepo.match(/\//g) ?? []).length;
|
|
3752
|
+
if (slashCount !== 1) {
|
|
3753
|
+
throw new Error(
|
|
3754
|
+
`computePerRepoKeyPath: githubRepo must be exactly <owner>/<repo>: ${githubRepo}`
|
|
3755
|
+
);
|
|
3756
|
+
}
|
|
3757
|
+
const [owner, repo] = githubRepo.split("/");
|
|
3758
|
+
if (!owner || !repo) {
|
|
3759
|
+
throw new Error(
|
|
3760
|
+
`computePerRepoKeyPath: owner and repo halves must both be non-empty: ${githubRepo}`
|
|
3761
|
+
);
|
|
3361
3762
|
}
|
|
3763
|
+
if (!VALID_OWNER.test(owner)) {
|
|
3764
|
+
throw new Error(
|
|
3765
|
+
`computePerRepoKeyPath: owner must match [A-Za-z0-9-]+ (got "${owner}" in "${githubRepo}")`
|
|
3766
|
+
);
|
|
3767
|
+
}
|
|
3768
|
+
if (!VALID_REPO.test(repo)) {
|
|
3769
|
+
throw new Error(
|
|
3770
|
+
`computePerRepoKeyPath: repo must match [A-Za-z0-9._-]+ (got "${repo}" in "${githubRepo}")`
|
|
3771
|
+
);
|
|
3772
|
+
}
|
|
3773
|
+
return `${SSH_CLIENT_KEY_DIR}/${owner}_${repo}_ed25519`;
|
|
3362
3774
|
}
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3775
|
+
|
|
3776
|
+
// src/commands/serverRepo.ts
|
|
3777
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
3778
|
+
import { createInterface } from "readline";
|
|
3779
|
+
async function runServerRepoDelete(opts) {
|
|
3780
|
+
const name = normalizeRepoName(opts.name);
|
|
3781
|
+
if (opts.alsoGithub !== void 0) validateGithubRepoSpec(opts.alsoGithub);
|
|
3782
|
+
const server2 = resolveServer(opts.server);
|
|
3783
|
+
const action = opts.purge ? "PURGE (irreversible)" : "soft-delete (recoverable via restore)";
|
|
3784
|
+
console.log(`About to ${action} bare repo: ${name}`);
|
|
3785
|
+
console.log(`On server: ${server2.user}@${server2.host}:${server2.port}`);
|
|
3786
|
+
if (opts.alsoGithub) {
|
|
3787
|
+
console.log(
|
|
3788
|
+
`Also: gh repo delete ${opts.alsoGithub} (PERMANENT, no GitHub-side undo)`
|
|
3789
|
+
);
|
|
3790
|
+
}
|
|
3791
|
+
console.log();
|
|
3792
|
+
if (!opts.yes) {
|
|
3793
|
+
const expected = opts.purge ? `purge ${name}` : `delete ${name}`;
|
|
3794
|
+
const got = await prompt(`Type "${expected}" to confirm: `);
|
|
3795
|
+
if (got.trim() !== expected) {
|
|
3796
|
+
console.log("note: aborted");
|
|
3797
|
+
return;
|
|
3798
|
+
}
|
|
3799
|
+
}
|
|
3800
|
+
const args = ["delete-stamp-repo", name];
|
|
3801
|
+
if (opts.purge) args.push("--purge");
|
|
3802
|
+
const result = spawnSync4(
|
|
3803
|
+
"ssh",
|
|
3804
|
+
["-p", String(server2.port), "--", `${server2.user}@${server2.host}`, ...args],
|
|
3805
|
+
{ stdio: ["ignore", "inherit", "inherit"] }
|
|
3806
|
+
);
|
|
3807
|
+
if (result.status !== 0) {
|
|
3366
3808
|
throw new Error(
|
|
3367
|
-
|
|
3809
|
+
`server-side delete failed (exit ${result.status}). The bare repo on the stamp server was NOT touched. If you see "command not found", the server image is older than 0.7.3 \u2014 redeploy it first.`
|
|
3810
|
+
);
|
|
3811
|
+
}
|
|
3812
|
+
if (!opts.purge) {
|
|
3813
|
+
console.log();
|
|
3814
|
+
console.log(`Recovery:`);
|
|
3815
|
+
console.log(` stamp server-repos restore ${name} # bring it back`);
|
|
3816
|
+
console.log(` stamp server-repos delete ${name} --purge # nuke for real`);
|
|
3817
|
+
}
|
|
3818
|
+
if (opts.alsoGithub) {
|
|
3819
|
+
if (!opts.yes) {
|
|
3820
|
+
const expected = `delete github ${opts.alsoGithub}`;
|
|
3821
|
+
const got = await prompt(
|
|
3822
|
+
`Server-side done. To ALSO delete the GitHub mirror, type "${expected}" (or anything else to skip): `
|
|
3823
|
+
);
|
|
3824
|
+
if (got.trim() !== expected) {
|
|
3825
|
+
console.log(
|
|
3826
|
+
`note: skipped GitHub delete; mirror at https://github.com/${opts.alsoGithub} is intact`
|
|
3827
|
+
);
|
|
3828
|
+
return;
|
|
3829
|
+
}
|
|
3830
|
+
}
|
|
3831
|
+
const ghResult = spawnSync4(
|
|
3832
|
+
"gh",
|
|
3833
|
+
["repo", "delete", opts.alsoGithub, "--yes"],
|
|
3834
|
+
{ stdio: ["ignore", "inherit", "inherit"] }
|
|
3368
3835
|
);
|
|
3836
|
+
if (ghResult.status !== 0) {
|
|
3837
|
+
throw new Error(
|
|
3838
|
+
`GitHub repo delete failed (exit ${ghResult.status}). Server-side delete already succeeded; the GitHub mirror is still present at https://github.com/${opts.alsoGithub}.`
|
|
3839
|
+
);
|
|
3840
|
+
}
|
|
3369
3841
|
}
|
|
3370
3842
|
}
|
|
3371
|
-
function
|
|
3372
|
-
const
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3843
|
+
async function runServerRepoRestore(opts) {
|
|
3844
|
+
const name = normalizeRepoName(opts.name);
|
|
3845
|
+
const asName = opts.asName !== void 0 ? normalizeRepoName(opts.asName) : void 0;
|
|
3846
|
+
if (opts.from !== void 0) validateTrashEntryName(opts.from);
|
|
3847
|
+
const server2 = resolveServer(opts.server);
|
|
3848
|
+
const args = ["restore-stamp-repo", name];
|
|
3849
|
+
if (opts.from) {
|
|
3850
|
+
args.push("--from", opts.from);
|
|
3851
|
+
}
|
|
3852
|
+
if (asName) {
|
|
3853
|
+
args.push("--as", asName);
|
|
3854
|
+
}
|
|
3855
|
+
const result = spawnSync4(
|
|
3856
|
+
"ssh",
|
|
3857
|
+
["-p", String(server2.port), "--", `${server2.user}@${server2.host}`, ...args],
|
|
3858
|
+
{ stdio: ["ignore", "inherit", "inherit"] }
|
|
3859
|
+
);
|
|
3860
|
+
if (result.status !== 0) {
|
|
3378
3861
|
throw new Error(
|
|
3379
|
-
`failed
|
|
3862
|
+
`server-side restore failed (exit ${result.status}). Run \`stamp server-repos list --trash\` to see what's available.`
|
|
3380
3863
|
);
|
|
3381
3864
|
}
|
|
3382
|
-
return parseServerConfig(raw, path2);
|
|
3383
3865
|
}
|
|
3384
|
-
function
|
|
3385
|
-
const
|
|
3386
|
-
if (
|
|
3387
|
-
|
|
3866
|
+
function runServerRepoList(opts) {
|
|
3867
|
+
const server2 = resolveServer(opts.server);
|
|
3868
|
+
if (opts.trash) {
|
|
3869
|
+
const result2 = spawnSync4(
|
|
3870
|
+
"ssh",
|
|
3871
|
+
["-p", String(server2.port), "--", `${server2.user}@${server2.host}`, "list-trash"],
|
|
3872
|
+
{ stdio: ["ignore", "inherit", "inherit"] }
|
|
3873
|
+
);
|
|
3874
|
+
if (result2.status !== 0) {
|
|
3875
|
+
throw new Error(
|
|
3876
|
+
`list --trash failed (exit ${result2.status}). If you see "command not found", the server image is older than 0.7.3 \u2014 redeploy it first.`
|
|
3877
|
+
);
|
|
3878
|
+
}
|
|
3879
|
+
return;
|
|
3388
3880
|
}
|
|
3389
|
-
const
|
|
3390
|
-
|
|
3391
|
-
|
|
3881
|
+
const result = spawnSync4(
|
|
3882
|
+
"ssh",
|
|
3883
|
+
[
|
|
3884
|
+
"-p",
|
|
3885
|
+
String(server2.port),
|
|
3886
|
+
"--",
|
|
3887
|
+
`${server2.user}@${server2.host}`,
|
|
3888
|
+
"ls",
|
|
3889
|
+
"-1",
|
|
3890
|
+
"/srv/git/"
|
|
3891
|
+
],
|
|
3892
|
+
{ stdio: ["ignore", "pipe", "inherit"], encoding: "utf8" }
|
|
3893
|
+
);
|
|
3894
|
+
if (result.status !== 0) {
|
|
3895
|
+
throw new Error(`list failed (exit ${result.status}).`);
|
|
3392
3896
|
}
|
|
3393
|
-
|
|
3394
|
-
|
|
3897
|
+
const entries = filterLiveBareRepoNames(result.stdout);
|
|
3898
|
+
if (entries.length === 0) {
|
|
3899
|
+
console.log("(no live bare repos)");
|
|
3900
|
+
return;
|
|
3395
3901
|
}
|
|
3396
|
-
const
|
|
3397
|
-
validateField("host", host, contextPath);
|
|
3398
|
-
const user = typeof obj.user === "string" && obj.user.trim() ? obj.user.trim() : DEFAULT_USER;
|
|
3399
|
-
validateField("user", user, contextPath);
|
|
3400
|
-
const repoRootPrefix = typeof obj.repo_root_prefix === "string" && obj.repo_root_prefix.trim() ? obj.repo_root_prefix.trim() : DEFAULT_REPO_ROOT;
|
|
3401
|
-
validateField("repo_root_prefix", repoRootPrefix, contextPath);
|
|
3402
|
-
return {
|
|
3403
|
-
host,
|
|
3404
|
-
port: obj.port,
|
|
3405
|
-
user,
|
|
3406
|
-
repoRootPrefix
|
|
3407
|
-
};
|
|
3902
|
+
for (const e of entries) console.log(e);
|
|
3408
3903
|
}
|
|
3409
|
-
function
|
|
3410
|
-
const
|
|
3411
|
-
if (!
|
|
3904
|
+
function resolveServer(serverFlag) {
|
|
3905
|
+
const server2 = serverFlag ? parseServerFlag(serverFlag) : loadServerConfig();
|
|
3906
|
+
if (!server2) {
|
|
3412
3907
|
throw new Error(
|
|
3413
|
-
|
|
3908
|
+
`no stamp server configured. Either:
|
|
3909
|
+
- create ~/.stamp/server.yml with at least:
|
|
3910
|
+
host: <ssh-host>
|
|
3911
|
+
port: <ssh-port>
|
|
3912
|
+
- or pass --server <host>:<port> on the command line.`
|
|
3414
3913
|
);
|
|
3415
3914
|
}
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3915
|
+
return server2;
|
|
3916
|
+
}
|
|
3917
|
+
var UsageError = class extends Error {
|
|
3918
|
+
constructor(message) {
|
|
3919
|
+
super(message);
|
|
3920
|
+
this.name = "UsageError";
|
|
3921
|
+
}
|
|
3922
|
+
};
|
|
3923
|
+
function normalizeRepoName(name) {
|
|
3924
|
+
const canonical = name.endsWith(".git") ? name.slice(0, -4) : name;
|
|
3925
|
+
validateRepoName(canonical);
|
|
3926
|
+
return canonical;
|
|
3927
|
+
}
|
|
3928
|
+
function filterLiveBareRepoNames(rawOutput) {
|
|
3929
|
+
return rawOutput.split("\n").map((s) => s.trim()).filter(Boolean).filter((s) => s.endsWith(".git")).map((s) => s.slice(0, -4)).filter((s) => s.length > 0);
|
|
3930
|
+
}
|
|
3931
|
+
function validateRepoName(name) {
|
|
3932
|
+
if (!/^[A-Za-z0-9_][A-Za-z0-9._-]*$/.test(name) || name.includes("..")) {
|
|
3933
|
+
throw new UsageError(
|
|
3934
|
+
`repo name must start with [A-Za-z0-9_], match [A-Za-z0-9._-]+, and not contain '..' (got "${name}")`
|
|
3420
3935
|
);
|
|
3421
3936
|
}
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3937
|
+
}
|
|
3938
|
+
function validateTrashEntryName(entry) {
|
|
3939
|
+
if (!/^[0-9]{8}T[0-9]{6}Z-[A-Za-z0-9_][A-Za-z0-9._-]*\.git$/.test(entry)) {
|
|
3940
|
+
throw new UsageError(
|
|
3941
|
+
`--from must match <YYYYMMDDTHHMMSSZ>-<name>.git (got "${entry}"). Run \`stamp server-repos list --trash\` to see valid entry names.`
|
|
3942
|
+
);
|
|
3943
|
+
}
|
|
3944
|
+
}
|
|
3945
|
+
function validateGithubRepoSpec(spec) {
|
|
3946
|
+
if (!/^[A-Za-z0-9_][A-Za-z0-9-]*\/[A-Za-z0-9_][A-Za-z0-9._-]*$/.test(spec)) {
|
|
3947
|
+
throw new UsageError(
|
|
3948
|
+
`--also-github must be <owner>/<repo> with no leading '-' on either segment (got "${spec}")`
|
|
3949
|
+
);
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3952
|
+
function prompt(question) {
|
|
3953
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
3954
|
+
return new Promise((resolve2) => {
|
|
3955
|
+
rl.question(question, (answer) => {
|
|
3956
|
+
rl.close();
|
|
3957
|
+
resolve2(answer);
|
|
3958
|
+
});
|
|
3959
|
+
});
|
|
3960
|
+
}
|
|
3961
|
+
|
|
3962
|
+
// src/commands/server.ts
|
|
3963
|
+
function formatServerConfigYaml(opts) {
|
|
3964
|
+
const body = {
|
|
3965
|
+
host: opts.host,
|
|
3966
|
+
port: opts.port
|
|
3429
3967
|
};
|
|
3968
|
+
if (opts.user && opts.user.trim()) body.user = opts.user.trim();
|
|
3969
|
+
if (opts.repoRootPrefix && opts.repoRootPrefix.trim()) {
|
|
3970
|
+
body.repo_root_prefix = opts.repoRootPrefix.trim();
|
|
3971
|
+
}
|
|
3972
|
+
return stringifyYaml2(body);
|
|
3430
3973
|
}
|
|
3431
|
-
function
|
|
3432
|
-
|
|
3974
|
+
function runServerConfig(opts) {
|
|
3975
|
+
const modes = [opts.hostPort, opts.show, opts.unset].filter(Boolean).length;
|
|
3976
|
+
if (modes !== 1) {
|
|
3977
|
+
throw new UsageError(
|
|
3978
|
+
"stamp server config: provide exactly one of <host:port>, --show, or --unset"
|
|
3979
|
+
);
|
|
3980
|
+
}
|
|
3981
|
+
if ((opts.show || opts.unset) && (opts.user || opts.repoRootPrefix)) {
|
|
3982
|
+
throw new UsageError(
|
|
3983
|
+
"stamp server config: --user and --repo-root-prefix only apply when writing (they conflict with --show / --unset)"
|
|
3984
|
+
);
|
|
3985
|
+
}
|
|
3986
|
+
if (opts.show) return showConfig();
|
|
3987
|
+
if (opts.unset) return unsetConfig();
|
|
3988
|
+
return writeConfig(opts);
|
|
3989
|
+
}
|
|
3990
|
+
function showConfig() {
|
|
3991
|
+
const path2 = userServerConfigPath();
|
|
3992
|
+
if (!existsSync10(path2)) {
|
|
3993
|
+
console.log(`note: no stamp server configured (${path2} does not exist)`);
|
|
3994
|
+
console.log(`note: run \`stamp server config <host:port>\` to create one`);
|
|
3995
|
+
return;
|
|
3996
|
+
}
|
|
3997
|
+
const cfg = loadServerConfig();
|
|
3998
|
+
if (!cfg) {
|
|
3999
|
+
console.log(`note: no stamp server configured`);
|
|
4000
|
+
return;
|
|
4001
|
+
}
|
|
4002
|
+
console.log(`config: ${path2}`);
|
|
4003
|
+
console.log(`host: ${cfg.host}`);
|
|
4004
|
+
console.log(`port: ${cfg.port}`);
|
|
4005
|
+
console.log(`user: ${cfg.user}`);
|
|
4006
|
+
console.log(`repo_root_prefix: ${cfg.repoRootPrefix}`);
|
|
4007
|
+
}
|
|
4008
|
+
function unsetConfig() {
|
|
4009
|
+
const path2 = userServerConfigPath();
|
|
4010
|
+
if (!existsSync10(path2)) {
|
|
4011
|
+
console.log(`note: ${path2} does not exist; nothing to remove`);
|
|
4012
|
+
return;
|
|
4013
|
+
}
|
|
4014
|
+
unlinkSync2(path2);
|
|
4015
|
+
console.log(`removed ${path2}`);
|
|
4016
|
+
}
|
|
4017
|
+
function fetchServerPubkey(server2, mirror) {
|
|
4018
|
+
const sshArgs = [
|
|
4019
|
+
"-p",
|
|
4020
|
+
String(server2.port),
|
|
4021
|
+
"--",
|
|
4022
|
+
`${server2.user}@${server2.host}`,
|
|
4023
|
+
"stamp-server-pubkey"
|
|
4024
|
+
];
|
|
4025
|
+
if (mirror) {
|
|
4026
|
+
sshArgs.push(`${mirror.owner}/${mirror.repo}`);
|
|
4027
|
+
}
|
|
4028
|
+
const result = spawnSync5("ssh", sshArgs, {
|
|
4029
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
4030
|
+
encoding: "utf8"
|
|
4031
|
+
});
|
|
4032
|
+
if (result.status !== 0) {
|
|
4033
|
+
const target = mirror ? ` for ${mirror.owner}/${mirror.repo}` : "";
|
|
4034
|
+
throw new Error(
|
|
4035
|
+
`stamp server pubkey${target} failed (exit ${result.status}) against ${server2.user}@${server2.host}:${server2.port}. If you see "command not found", the server image predates the deploy-key feature \u2014 redeploy it first.`
|
|
4036
|
+
);
|
|
4037
|
+
}
|
|
4038
|
+
return result.stdout.trim();
|
|
4039
|
+
}
|
|
4040
|
+
function runServerPubkey(opts) {
|
|
4041
|
+
const server2 = resolveServer(opts.server);
|
|
4042
|
+
let mirror;
|
|
4043
|
+
if (opts.repo) {
|
|
4044
|
+
try {
|
|
4045
|
+
computePerRepoKeyPath(opts.repo);
|
|
4046
|
+
} catch (err) {
|
|
4047
|
+
throw new UsageError(
|
|
4048
|
+
`--repo ${err instanceof Error ? err.message.replace(/^computePerRepoKeyPath:\s*/, "") : String(err)}`
|
|
4049
|
+
);
|
|
4050
|
+
}
|
|
4051
|
+
const slashIdx = opts.repo.indexOf("/");
|
|
4052
|
+
mirror = {
|
|
4053
|
+
owner: opts.repo.slice(0, slashIdx),
|
|
4054
|
+
repo: opts.repo.slice(slashIdx + 1)
|
|
4055
|
+
};
|
|
4056
|
+
}
|
|
4057
|
+
const pubkey = fetchServerPubkey(server2, mirror);
|
|
4058
|
+
process.stdout.write(`${pubkey}
|
|
4059
|
+
`);
|
|
4060
|
+
}
|
|
4061
|
+
function writeConfig(opts) {
|
|
4062
|
+
let parsed;
|
|
4063
|
+
try {
|
|
4064
|
+
parsed = parseServerFlag(opts.hostPort, "stamp server config: <host:port>");
|
|
4065
|
+
} catch (err) {
|
|
4066
|
+
throw new UsageError(err instanceof Error ? err.message : String(err));
|
|
4067
|
+
}
|
|
4068
|
+
const yaml = formatServerConfigYaml({
|
|
4069
|
+
host: parsed.host,
|
|
4070
|
+
port: parsed.port,
|
|
4071
|
+
user: opts.user,
|
|
4072
|
+
repoRootPrefix: opts.repoRootPrefix
|
|
4073
|
+
});
|
|
4074
|
+
const path2 = userServerConfigPath();
|
|
4075
|
+
const dir = dirname4(path2);
|
|
4076
|
+
if (!existsSync10(dir)) mkdirSync4(dir, { recursive: true, mode: 448 });
|
|
4077
|
+
const tmp = `${path2}.tmp.${process.pid}`;
|
|
4078
|
+
writeFileSync8(tmp, yaml, { mode: 384 });
|
|
4079
|
+
renameSync3(tmp, path2);
|
|
4080
|
+
console.log(`wrote ${path2}`);
|
|
4081
|
+
console.log(`host: ${parsed.host}`);
|
|
4082
|
+
console.log(`port: ${parsed.port}`);
|
|
4083
|
+
if (opts.user && opts.user.trim()) {
|
|
4084
|
+
console.log(`user: ${opts.user.trim()}`);
|
|
4085
|
+
}
|
|
4086
|
+
if (opts.repoRootPrefix && opts.repoRootPrefix.trim()) {
|
|
4087
|
+
console.log(`repo_root_prefix: ${opts.repoRootPrefix.trim()}`);
|
|
4088
|
+
}
|
|
3433
4089
|
}
|
|
3434
4090
|
|
|
3435
4091
|
// src/commands/provision.ts
|
|
3436
4092
|
async function runProvision(opts) {
|
|
3437
|
-
|
|
4093
|
+
if (opts.migrateExisting && opts.migrateBypass) {
|
|
4094
|
+
throw new Error(
|
|
4095
|
+
`--migrate-existing and --migrate-bypass are mutually exclusive: the first moves a forge-direct repo to server-gated topology, the second changes the bypass-actor shape on an already-server-gated repo.`
|
|
4096
|
+
);
|
|
4097
|
+
}
|
|
4098
|
+
if (opts.removeOrgadmin && !opts.migrateBypass) {
|
|
4099
|
+
throw new Error(
|
|
4100
|
+
`--remove-orgadmin is only meaningful with --migrate-bypass`
|
|
4101
|
+
);
|
|
4102
|
+
}
|
|
4103
|
+
if (!opts.migrateBypass) {
|
|
4104
|
+
if (!opts.name) {
|
|
4105
|
+
throw new Error(
|
|
4106
|
+
`stamp provision requires a <name> argument (the bare repo name on the stamp server). If you meant to migrate an already-server-gated repo's Ruleset bypass instead, pass --migrate-bypass (identifies the target via cwd's .stamp/mirror.yml; no <name> needed).`
|
|
4107
|
+
);
|
|
4108
|
+
}
|
|
4109
|
+
validateRepoName2(opts.name);
|
|
4110
|
+
} else if (opts.name) {
|
|
4111
|
+
console.log(
|
|
4112
|
+
`note: <name> argument ignored under --migrate-bypass; the target is identified by .stamp/mirror.yml in the cwd.`
|
|
4113
|
+
);
|
|
4114
|
+
console.log();
|
|
4115
|
+
}
|
|
3438
4116
|
if (opts.org !== void 0) validateOrgName(opts.org);
|
|
3439
4117
|
const server2 = opts.server ? parseServerFlag(opts.server) : loadServerConfig();
|
|
3440
4118
|
if (!server2) {
|
|
@@ -3448,12 +4126,16 @@ async function runProvision(opts) {
|
|
|
3448
4126
|
See docs/quickstart-server.md for how to deploy a stamp server first.`
|
|
3449
4127
|
);
|
|
3450
4128
|
}
|
|
4129
|
+
if (opts.migrateBypass) {
|
|
4130
|
+
await runMigrateBypass(opts, server2);
|
|
4131
|
+
return;
|
|
4132
|
+
}
|
|
3451
4133
|
if (opts.migrateExisting) {
|
|
3452
4134
|
await runMigrateExisting(opts, server2);
|
|
3453
4135
|
return;
|
|
3454
4136
|
}
|
|
3455
4137
|
const cloneTarget = resolvePath(opts.into ?? opts.name);
|
|
3456
|
-
if (
|
|
4138
|
+
if (existsSync11(cloneTarget)) {
|
|
3457
4139
|
throw new Error(
|
|
3458
4140
|
`clone destination already exists: ${cloneTarget}. Move or remove it, or pass --into <other-path>.`
|
|
3459
4141
|
);
|
|
@@ -3481,11 +4163,11 @@ Provisioning bare repo on ${server2.host}:${server2.port}`);
|
|
|
3481
4163
|
process.chdir(cloneTarget);
|
|
3482
4164
|
await runBootstrap({});
|
|
3483
4165
|
if (mirrorRepo && !opts.noRuleset) {
|
|
3484
|
-
applyMirrorRuleset(mirrorRepo);
|
|
4166
|
+
applyMirrorRuleset(mirrorRepo, server2);
|
|
3485
4167
|
}
|
|
3486
4168
|
printSuccess({ cloneTarget, server: server2, repoName: opts.name, mirrorRepo });
|
|
3487
4169
|
}
|
|
3488
|
-
function
|
|
4170
|
+
function validateRepoName2(name) {
|
|
3489
4171
|
if (!/^[A-Za-z0-9_][A-Za-z0-9._-]*$/.test(name)) {
|
|
3490
4172
|
throw new Error(
|
|
3491
4173
|
`repo name must start with [A-Za-z0-9_] and match [A-Za-z0-9._-]+ (got "${name}")`
|
|
@@ -3518,13 +4200,19 @@ function printPlan2(args) {
|
|
|
3518
4200
|
}
|
|
3519
4201
|
if (args.opts.org && !args.opts.noMirror && !args.opts.noRuleset) {
|
|
3520
4202
|
console.log(fmt("GitHub Ruleset", "apply stamp-mirror-only on the mirror repo"));
|
|
4203
|
+
console.log(
|
|
4204
|
+
fmt(
|
|
4205
|
+
"bypass actor",
|
|
4206
|
+
`org repo \u2192 stamp-server deploy key "${STAMP_MIRROR_DEPLOY_KEY_TITLE}" (auto-registered or reused); personal repo \u2192 your gh-authed user`
|
|
4207
|
+
)
|
|
4208
|
+
);
|
|
3521
4209
|
} else {
|
|
3522
4210
|
console.log(fmt("GitHub Ruleset", "skipped"));
|
|
3523
4211
|
}
|
|
3524
4212
|
console.log(bar);
|
|
3525
4213
|
}
|
|
3526
4214
|
function provisionBareRepoOnServer(server2, name) {
|
|
3527
|
-
const result =
|
|
4215
|
+
const result = spawnSync6(
|
|
3528
4216
|
"ssh",
|
|
3529
4217
|
[
|
|
3530
4218
|
"-p",
|
|
@@ -3550,7 +4238,7 @@ function createGithubMirrorRepo(owner, repo, privateRepo) {
|
|
|
3550
4238
|
);
|
|
3551
4239
|
}
|
|
3552
4240
|
const visibility = privateRepo ? "--private" : "--public";
|
|
3553
|
-
const result =
|
|
4241
|
+
const result = spawnSync6(
|
|
3554
4242
|
"gh",
|
|
3555
4243
|
["repo", "create", `${owner}/${repo}`, visibility],
|
|
3556
4244
|
{ stdio: ["ignore", "inherit", "inherit"] }
|
|
@@ -3573,28 +4261,67 @@ function writeMirrorYml(cloneTarget, mirror) {
|
|
|
3573
4261
|
# - "v*"
|
|
3574
4262
|
`;
|
|
3575
4263
|
const path2 = `${cloneTarget}/.stamp/mirror.yml`;
|
|
3576
|
-
|
|
4264
|
+
writeFileSync9(path2, yml);
|
|
3577
4265
|
console.log(`Wrote mirror.yml \u2192 .stamp/mirror.yml (${mirror.owner}/${mirror.repo})`);
|
|
3578
4266
|
}
|
|
3579
|
-
function applyMirrorRuleset(mirror) {
|
|
3580
|
-
const
|
|
3581
|
-
if (
|
|
4267
|
+
function applyMirrorRuleset(mirror, server2) {
|
|
4268
|
+
const existing = findExistingStampRuleset(mirror.owner, mirror.repo);
|
|
4269
|
+
if (existing !== null) {
|
|
3582
4270
|
console.log(
|
|
3583
|
-
`
|
|
4271
|
+
`GitHub Ruleset: stamp-mirror-only already present on ${mirror.owner}/${mirror.repo}. Not modified.`
|
|
3584
4272
|
);
|
|
3585
|
-
console.log(` Try \`gh auth status\` and re-apply manually via docs/github-ruleset-setup.md.`);
|
|
3586
4273
|
return;
|
|
3587
4274
|
}
|
|
3588
4275
|
const ownerType = lookupRepoOwnerType(mirror.owner, mirror.repo);
|
|
3589
4276
|
if (ownerType === null) {
|
|
3590
4277
|
console.log(
|
|
3591
|
-
`
|
|
4278
|
+
`warning: GitHub Ruleset auto-apply skipped \u2014 couldn't determine whether ${mirror.owner}/${mirror.repo} is a personal or org repo.`
|
|
3592
4279
|
);
|
|
3593
|
-
console.log(`
|
|
4280
|
+
console.log(` For manual setup, see docs/github-ruleset-setup.md.`);
|
|
3594
4281
|
return;
|
|
3595
4282
|
}
|
|
3596
|
-
|
|
3597
|
-
|
|
4283
|
+
let actor;
|
|
4284
|
+
let actorDescription;
|
|
4285
|
+
if (ownerType === "Organization") {
|
|
4286
|
+
let pubkey;
|
|
4287
|
+
try {
|
|
4288
|
+
pubkey = fetchServerPubkey(server2, mirror);
|
|
4289
|
+
} catch (err) {
|
|
4290
|
+
console.log(
|
|
4291
|
+
`warning: GitHub Ruleset auto-apply skipped \u2014 couldn't fetch stamp server pubkey: ${err instanceof Error ? err.message : String(err)}`
|
|
4292
|
+
);
|
|
4293
|
+
console.log(` For manual setup, see docs/github-ruleset-setup.md.`);
|
|
4294
|
+
return;
|
|
4295
|
+
}
|
|
4296
|
+
const reg = registerDeployKey(
|
|
4297
|
+
mirror.owner,
|
|
4298
|
+
mirror.repo,
|
|
4299
|
+
STAMP_MIRROR_DEPLOY_KEY_TITLE,
|
|
4300
|
+
pubkey
|
|
4301
|
+
);
|
|
4302
|
+
if (reg.status === "failed") {
|
|
4303
|
+
console.log(`warning: GitHub Ruleset auto-apply skipped \u2014 deploy-key registration failed: ${reg.error}`);
|
|
4304
|
+
console.log(` For manual setup, see docs/github-ruleset-setup.md.`);
|
|
4305
|
+
return;
|
|
4306
|
+
}
|
|
4307
|
+
const verb = reg.status === "created" ? "registered" : "reused";
|
|
4308
|
+
console.log(
|
|
4309
|
+
`Deploy key: ${verb} "${STAMP_MIRROR_DEPLOY_KEY_TITLE}" on ${mirror.owner}/${mirror.repo} (id ${reg.keyId}).`
|
|
4310
|
+
);
|
|
4311
|
+
actor = { type: "DeployKey", id: reg.keyId };
|
|
4312
|
+
actorDescription = `stamp-server deploy key "${STAMP_MIRROR_DEPLOY_KEY_TITLE}", id ${reg.keyId}`;
|
|
4313
|
+
} else {
|
|
4314
|
+
const user = lookupAuthenticatedUserId();
|
|
4315
|
+
if (!user) {
|
|
4316
|
+
console.log(
|
|
4317
|
+
`warning: GitHub Ruleset auto-apply skipped \u2014 couldn't look up the gh-authenticated user.`
|
|
4318
|
+
);
|
|
4319
|
+
console.log(` Try \`gh auth status\` and re-apply manually via docs/github-ruleset-setup.md.`);
|
|
4320
|
+
return;
|
|
4321
|
+
}
|
|
4322
|
+
actor = { type: "User", id: user.id };
|
|
4323
|
+
actorDescription = `${user.login}, id ${user.id}`;
|
|
4324
|
+
}
|
|
3598
4325
|
const result = applyStampRuleset(mirror.owner, mirror.repo, actor);
|
|
3599
4326
|
switch (result.status) {
|
|
3600
4327
|
case "created":
|
|
@@ -3658,9 +4385,9 @@ async function runMigrateExisting(opts, server2) {
|
|
|
3658
4385
|
console.log("\n(dry run \u2014 no changes made)");
|
|
3659
4386
|
return;
|
|
3660
4387
|
}
|
|
3661
|
-
const stagingDir = mkdtempSync(
|
|
3662
|
-
const bareCloneDir =
|
|
3663
|
-
const tarballPath =
|
|
4388
|
+
const stagingDir = mkdtempSync(join6(tmpdir(), "stamp-migrate-"));
|
|
4389
|
+
const bareCloneDir = join6(stagingDir, `${opts.name}.git`);
|
|
4390
|
+
const tarballPath = join6(stagingDir, `${opts.name}.tar.gz`);
|
|
3664
4391
|
try {
|
|
3665
4392
|
console.log(`
|
|
3666
4393
|
Building bare-clone tarball of existing repo`);
|
|
@@ -3682,7 +4409,7 @@ Building bare-clone tarball of existing repo`);
|
|
|
3682
4409
|
writeMirrorYml(repoRoot, mirrorParse);
|
|
3683
4410
|
}
|
|
3684
4411
|
if (!opts.noMirror && !opts.noRuleset) {
|
|
3685
|
-
applyMirrorRuleset(mirrorParse);
|
|
4412
|
+
applyMirrorRuleset(mirrorParse, server2);
|
|
3686
4413
|
}
|
|
3687
4414
|
printMigrateSuccess({ repoRoot, server: server2, repoName: opts.name, mirror: mirrorParse, opts });
|
|
3688
4415
|
}
|
|
@@ -3696,9 +4423,9 @@ function ensureCwdIsGitRepo(cwd) {
|
|
|
3696
4423
|
}
|
|
3697
4424
|
}
|
|
3698
4425
|
function ensureStampInitDone(cwd) {
|
|
3699
|
-
if (!
|
|
4426
|
+
if (!existsSync11(join6(cwd, ".stamp", "config.yml"))) {
|
|
3700
4427
|
throw new Error(
|
|
3701
|
-
`--migrate-existing expects this repo to already be stamp-init'd (${
|
|
4428
|
+
`--migrate-existing expects this repo to already be stamp-init'd (${join6(cwd, ".stamp/config.yml")} not found). Run \`stamp init --mode local-only\` first, calibrate your reviewers, then re-run with --migrate-existing.`
|
|
3702
4429
|
);
|
|
3703
4430
|
}
|
|
3704
4431
|
}
|
|
@@ -3728,7 +4455,7 @@ function ensureWorkingTreeClean(cwd) {
|
|
|
3728
4455
|
}
|
|
3729
4456
|
}
|
|
3730
4457
|
function runTarGz(parentDir, dirName, outputPath) {
|
|
3731
|
-
const result =
|
|
4458
|
+
const result = spawnSync6("tar", ["-czf", outputPath, "-C", parentDir, dirName], {
|
|
3732
4459
|
stdio: ["ignore", "inherit", "inherit"]
|
|
3733
4460
|
});
|
|
3734
4461
|
if (result.status !== 0) {
|
|
@@ -3738,7 +4465,7 @@ function runTarGz(parentDir, dirName, outputPath) {
|
|
|
3738
4465
|
}
|
|
3739
4466
|
}
|
|
3740
4467
|
function scpToServer(server2, localPath, remotePath) {
|
|
3741
|
-
const result =
|
|
4468
|
+
const result = spawnSync6(
|
|
3742
4469
|
"scp",
|
|
3743
4470
|
[
|
|
3744
4471
|
"-P",
|
|
@@ -3756,7 +4483,7 @@ function scpToServer(server2, localPath, remotePath) {
|
|
|
3756
4483
|
}
|
|
3757
4484
|
}
|
|
3758
4485
|
function sshRunNewStampRepoFromTarball(server2, name, remoteTarballPath) {
|
|
3759
|
-
const result =
|
|
4486
|
+
const result = spawnSync6(
|
|
3760
4487
|
"ssh",
|
|
3761
4488
|
[
|
|
3762
4489
|
"-p",
|
|
@@ -3822,196 +4549,249 @@ mirror.yml was added to .stamp/. Commit it through the normal stamp flow:`);
|
|
|
3822
4549
|
console.log(` stamp push main`);
|
|
3823
4550
|
}
|
|
3824
4551
|
}
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
const name = normalizeRepoName(opts.name);
|
|
3831
|
-
if (opts.alsoGithub !== void 0) validateGithubRepoSpec(opts.alsoGithub);
|
|
3832
|
-
const server2 = resolveServer(opts.server);
|
|
3833
|
-
const action = opts.purge ? "PURGE (irreversible)" : "soft-delete (recoverable via restore)";
|
|
3834
|
-
console.log(`About to ${action} bare repo: ${name}`);
|
|
3835
|
-
console.log(`On server: ${server2.user}@${server2.host}:${server2.port}`);
|
|
3836
|
-
if (opts.alsoGithub) {
|
|
3837
|
-
console.log(
|
|
3838
|
-
`Also: gh repo delete ${opts.alsoGithub} (PERMANENT, no GitHub-side undo)`
|
|
4552
|
+
function readMirrorYmlGithubRepo(repoRoot) {
|
|
4553
|
+
const path2 = join6(repoRoot, ".stamp", "mirror.yml");
|
|
4554
|
+
if (!existsSync11(path2)) {
|
|
4555
|
+
throw new Error(
|
|
4556
|
+
`${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\`.`
|
|
3839
4557
|
);
|
|
3840
4558
|
}
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
if (got.trim() !== expected) {
|
|
3846
|
-
console.log("note: aborted");
|
|
3847
|
-
return;
|
|
3848
|
-
}
|
|
3849
|
-
}
|
|
3850
|
-
const args = ["delete-stamp-repo", name];
|
|
3851
|
-
if (opts.purge) args.push("--purge");
|
|
3852
|
-
const result = spawnSync5(
|
|
3853
|
-
"ssh",
|
|
3854
|
-
["-p", String(server2.port), "--", `${server2.user}@${server2.host}`, ...args],
|
|
3855
|
-
{ stdio: ["ignore", "inherit", "inherit"] }
|
|
3856
|
-
);
|
|
3857
|
-
if (result.status !== 0) {
|
|
4559
|
+
let raw;
|
|
4560
|
+
try {
|
|
4561
|
+
raw = readFileSync8(path2, "utf8");
|
|
4562
|
+
} catch (err) {
|
|
3858
4563
|
throw new Error(
|
|
3859
|
-
`
|
|
4564
|
+
`could not read ${path2}: ${err instanceof Error ? err.message : String(err)}`
|
|
3860
4565
|
);
|
|
3861
4566
|
}
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
if (opts.alsoGithub) {
|
|
3869
|
-
if (!opts.yes) {
|
|
3870
|
-
const expected = `delete github ${opts.alsoGithub}`;
|
|
3871
|
-
const got = await prompt(
|
|
3872
|
-
`Server-side done. To ALSO delete the GitHub mirror, type "${expected}" (or anything else to skip): `
|
|
3873
|
-
);
|
|
3874
|
-
if (got.trim() !== expected) {
|
|
3875
|
-
console.log(
|
|
3876
|
-
`note: skipped GitHub delete; mirror at https://github.com/${opts.alsoGithub} is intact`
|
|
3877
|
-
);
|
|
3878
|
-
return;
|
|
3879
|
-
}
|
|
3880
|
-
}
|
|
3881
|
-
const ghResult = spawnSync5(
|
|
3882
|
-
"gh",
|
|
3883
|
-
["repo", "delete", opts.alsoGithub, "--yes"],
|
|
3884
|
-
{ stdio: ["ignore", "inherit", "inherit"] }
|
|
4567
|
+
let parsed;
|
|
4568
|
+
try {
|
|
4569
|
+
parsed = parseYaml5(raw);
|
|
4570
|
+
} catch (err) {
|
|
4571
|
+
throw new Error(
|
|
4572
|
+
`${path2} failed to parse as YAML: ${err instanceof Error ? err.message : String(err)}`
|
|
3885
4573
|
);
|
|
3886
|
-
if (ghResult.status !== 0) {
|
|
3887
|
-
throw new Error(
|
|
3888
|
-
`GitHub repo delete failed (exit ${ghResult.status}). Server-side delete already succeeded; the GitHub mirror is still present at https://github.com/${opts.alsoGithub}.`
|
|
3889
|
-
);
|
|
3890
|
-
}
|
|
3891
|
-
}
|
|
3892
|
-
}
|
|
3893
|
-
async function runServerRepoRestore(opts) {
|
|
3894
|
-
const name = normalizeRepoName(opts.name);
|
|
3895
|
-
const asName = opts.asName !== void 0 ? normalizeRepoName(opts.asName) : void 0;
|
|
3896
|
-
if (opts.from !== void 0) validateTrashEntryName(opts.from);
|
|
3897
|
-
const server2 = resolveServer(opts.server);
|
|
3898
|
-
const args = ["restore-stamp-repo", name];
|
|
3899
|
-
if (opts.from) {
|
|
3900
|
-
args.push("--from", opts.from);
|
|
3901
4574
|
}
|
|
3902
|
-
if (
|
|
3903
|
-
|
|
4575
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
4576
|
+
throw new Error(`${path2} is empty or not a map`);
|
|
3904
4577
|
}
|
|
3905
|
-
const
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
{
|
|
3909
|
-
|
|
3910
|
-
|
|
4578
|
+
const obj = parsed;
|
|
4579
|
+
const gh = obj["github"];
|
|
4580
|
+
if (!gh || typeof gh !== "object" || Array.isArray(gh)) {
|
|
4581
|
+
throw new Error(`${path2} has no usable 'github' map`);
|
|
4582
|
+
}
|
|
4583
|
+
const repoStr = gh["repo"];
|
|
4584
|
+
if (typeof repoStr !== "string" || !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(repoStr)) {
|
|
3911
4585
|
throw new Error(
|
|
3912
|
-
|
|
4586
|
+
`${path2} github.repo is missing or not of form 'owner/repo' (got ${JSON.stringify(repoStr)})`
|
|
3913
4587
|
);
|
|
3914
4588
|
}
|
|
4589
|
+
const slashIdx = repoStr.indexOf("/");
|
|
4590
|
+
return {
|
|
4591
|
+
owner: repoStr.slice(0, slashIdx),
|
|
4592
|
+
repo: repoStr.slice(slashIdx + 1)
|
|
4593
|
+
};
|
|
3915
4594
|
}
|
|
3916
|
-
function
|
|
3917
|
-
const
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
4595
|
+
async function runMigrateBypass(opts, server2) {
|
|
4596
|
+
const repoRoot = process.cwd();
|
|
4597
|
+
const mirror = readMirrorYmlGithubRepo(repoRoot);
|
|
4598
|
+
const ghCheck = checkGhAvailable();
|
|
4599
|
+
if (!ghCheck.available) {
|
|
4600
|
+
throw new Error(
|
|
4601
|
+
`--migrate-bypass requires gh: ${ghCheck.reason}. Install/authenticate gh, then re-run.`
|
|
3923
4602
|
);
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
}
|
|
4603
|
+
}
|
|
4604
|
+
printMigrateBypassPlan({ mirror, server: server2, opts });
|
|
4605
|
+
if (opts.dryRun) {
|
|
4606
|
+
console.log("\n(dry run \u2014 no changes made)");
|
|
3929
4607
|
return;
|
|
3930
4608
|
}
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
4609
|
+
console.log(`
|
|
4610
|
+
Fetching per-repo deploy key from stamp server`);
|
|
4611
|
+
let pubkey;
|
|
4612
|
+
try {
|
|
4613
|
+
pubkey = fetchServerPubkey(server2, mirror);
|
|
4614
|
+
} catch (err) {
|
|
4615
|
+
throw new Error(
|
|
4616
|
+
`failed to fetch per-repo pubkey for ${mirror.owner}/${mirror.repo} from ${server2.user}@${server2.host}:${server2.port}: ${err instanceof Error ? err.message : String(err)}`
|
|
4617
|
+
);
|
|
4618
|
+
}
|
|
4619
|
+
console.log(`Checking existing deploy keys on ${mirror.owner}/${mirror.repo}`);
|
|
4620
|
+
const existingKeyId = findDeployKey(
|
|
4621
|
+
mirror.owner,
|
|
4622
|
+
mirror.repo,
|
|
4623
|
+
STAMP_MIRROR_DEPLOY_KEY_TITLE
|
|
3943
4624
|
);
|
|
3944
|
-
|
|
3945
|
-
|
|
4625
|
+
let deployKeyId;
|
|
4626
|
+
if (existingKeyId !== null) {
|
|
4627
|
+
const existingBody = fetchDeployKeyPublic(
|
|
4628
|
+
mirror.owner,
|
|
4629
|
+
mirror.repo,
|
|
4630
|
+
existingKeyId
|
|
4631
|
+
);
|
|
4632
|
+
if (existingBody === pubkey) {
|
|
4633
|
+
console.log(
|
|
4634
|
+
`Deploy key: "${STAMP_MIRROR_DEPLOY_KEY_TITLE}" already matches per-repo pubkey (keyId ${existingKeyId}). No change.`
|
|
4635
|
+
);
|
|
4636
|
+
deployKeyId = existingKeyId;
|
|
4637
|
+
} else {
|
|
4638
|
+
console.log(
|
|
4639
|
+
`Deploy key: "${STAMP_MIRROR_DEPLOY_KEY_TITLE}" is registered but doesn't match the per-repo pubkey (keyId ${existingKeyId}). Deleting before re-registering.`
|
|
4640
|
+
);
|
|
4641
|
+
const del = deleteDeployKey(mirror.owner, mirror.repo, existingKeyId);
|
|
4642
|
+
if (del.status === "failed") {
|
|
4643
|
+
throw new Error(`deploy-key cleanup failed: ${del.error}`);
|
|
4644
|
+
}
|
|
4645
|
+
deployKeyId = registerStampMirrorKey(mirror, pubkey);
|
|
4646
|
+
}
|
|
4647
|
+
} else {
|
|
4648
|
+
deployKeyId = registerStampMirrorKey(mirror, pubkey);
|
|
3946
4649
|
}
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
4650
|
+
console.log(`Looking up stamp-mirror-only ruleset on ${mirror.owner}/${mirror.repo}`);
|
|
4651
|
+
const rulesetId = findExistingStampRuleset(mirror.owner, mirror.repo);
|
|
4652
|
+
if (rulesetId === null) {
|
|
4653
|
+
console.log(
|
|
4654
|
+
`note: no \`stamp-mirror-only\` Ruleset on ${mirror.owner}/${mirror.repo}. Deploy key is registered; no bypass list to update.`
|
|
4655
|
+
);
|
|
4656
|
+
console.log(
|
|
4657
|
+
` If this repo is server-gated only (no GitHub-side enforcement), that's expected and you're done.`
|
|
4658
|
+
);
|
|
4659
|
+
console.log(
|
|
4660
|
+
` If you EXPECTED a Ruleset, it may use a non-canonical name (e.g. think-cli's \`Protect Main\`) \u2014 rename to \`stamp-mirror-only\` in the GitHub UI and re-run, or migrate by hand.`
|
|
4661
|
+
);
|
|
4662
|
+
if (opts.removeOrgadmin) {
|
|
4663
|
+
console.log(
|
|
4664
|
+
` --remove-orgadmin requested but there's no Ruleset bypass list to modify; ignoring.`
|
|
4665
|
+
);
|
|
4666
|
+
}
|
|
4667
|
+
printMigrateBypassSuccess({
|
|
4668
|
+
mirror,
|
|
4669
|
+
server: server2,
|
|
4670
|
+
opts,
|
|
4671
|
+
rulesetUpdated: false
|
|
4672
|
+
});
|
|
3950
4673
|
return;
|
|
3951
4674
|
}
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
const server2 = serverFlag ? parseServerFlag(serverFlag) : loadServerConfig();
|
|
3956
|
-
if (!server2) {
|
|
4675
|
+
console.log(`Reading current bypass_actors on ruleset ${rulesetId}`);
|
|
4676
|
+
const current = getRulesetBypassActors(mirror.owner, mirror.repo, rulesetId);
|
|
4677
|
+
if (current === null) {
|
|
3957
4678
|
throw new Error(
|
|
3958
|
-
`
|
|
3959
|
-
- create ~/.stamp/server.yml with at least:
|
|
3960
|
-
host: <ssh-host>
|
|
3961
|
-
port: <ssh-port>
|
|
3962
|
-
- or pass --server <host>:<port> on the command line.`
|
|
4679
|
+
`could not read bypass_actors on ${mirror.owner}/${mirror.repo} ruleset ${rulesetId}`
|
|
3963
4680
|
);
|
|
3964
4681
|
}
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
4682
|
+
const desired = computeDesiredBypassActors(current, deployKeyId, {
|
|
4683
|
+
removeOrgadmin: opts.removeOrgadmin === true
|
|
4684
|
+
});
|
|
4685
|
+
const result = replaceBypassActors(
|
|
4686
|
+
mirror.owner,
|
|
4687
|
+
mirror.repo,
|
|
4688
|
+
rulesetId,
|
|
4689
|
+
desired
|
|
4690
|
+
);
|
|
4691
|
+
if (result.status === "failed") {
|
|
4692
|
+
throw new Error(`ruleset bypass update failed: ${result.error}`);
|
|
3971
4693
|
}
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
4694
|
+
if (result.status === "updated") {
|
|
4695
|
+
console.log(
|
|
4696
|
+
`Ruleset bypass: updated to [${desired.map((a) => a.actor_type).join(", ")}].`
|
|
4697
|
+
);
|
|
4698
|
+
} else {
|
|
4699
|
+
console.log(`Ruleset bypass: already up to date. No change.`);
|
|
4700
|
+
}
|
|
4701
|
+
printMigrateBypassSuccess({ mirror, server: server2, opts, rulesetUpdated: true });
|
|
3977
4702
|
}
|
|
3978
|
-
function
|
|
3979
|
-
|
|
4703
|
+
function registerStampMirrorKey(mirror, pubkey) {
|
|
4704
|
+
const reg = registerDeployKey(
|
|
4705
|
+
mirror.owner,
|
|
4706
|
+
mirror.repo,
|
|
4707
|
+
STAMP_MIRROR_DEPLOY_KEY_TITLE,
|
|
4708
|
+
pubkey
|
|
4709
|
+
);
|
|
4710
|
+
if (reg.status === "failed") {
|
|
4711
|
+
throw new Error(`deploy-key registration failed: ${reg.error}`);
|
|
4712
|
+
}
|
|
4713
|
+
console.log(
|
|
4714
|
+
`Deploy key: registered "${STAMP_MIRROR_DEPLOY_KEY_TITLE}" on ${mirror.owner}/${mirror.repo} (id ${reg.keyId}).`
|
|
4715
|
+
);
|
|
4716
|
+
return reg.keyId;
|
|
3980
4717
|
}
|
|
3981
|
-
function
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
4718
|
+
function printMigrateBypassPlan(args) {
|
|
4719
|
+
const bar = "\u2500".repeat(72);
|
|
4720
|
+
console.log(bar);
|
|
4721
|
+
console.log("stamp provision --migrate-bypass \u2014 plan");
|
|
4722
|
+
console.log(bar);
|
|
4723
|
+
console.log(fmt("mirror", `${args.mirror.owner}/${args.mirror.repo}`));
|
|
4724
|
+
console.log(fmt("stamp server", `${args.server.user}@${args.server.host}:${args.server.port}`));
|
|
4725
|
+
console.log(
|
|
4726
|
+
fmt(
|
|
4727
|
+
"deploy key",
|
|
4728
|
+
`fetch per-repo pubkey from server; register as "${STAMP_MIRROR_DEPLOY_KEY_TITLE}" on the mirror (replacing any prior entry under that title)`
|
|
4729
|
+
)
|
|
4730
|
+
);
|
|
4731
|
+
console.log(
|
|
4732
|
+
fmt(
|
|
4733
|
+
"ruleset",
|
|
4734
|
+
`add DeployKey actor to stamp-mirror-only bypass list` + (args.opts.removeOrgadmin ? `; remove OrganizationAdmin (--remove-orgadmin)` : `; preserve OrganizationAdmin`)
|
|
4735
|
+
)
|
|
4736
|
+
);
|
|
4737
|
+
console.log(bar);
|
|
4738
|
+
if (args.opts.removeOrgadmin) {
|
|
4739
|
+
console.log(
|
|
4740
|
+
`warning: --remove-orgadmin strips the OrganizationAdmin bypass before any push-verification step runs. Verify the DeployKey transport works (one stamp push) before running this.`
|
|
3985
4741
|
);
|
|
3986
4742
|
}
|
|
3987
4743
|
}
|
|
3988
|
-
function
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
4744
|
+
function printMigrateBypassSuccess(args) {
|
|
4745
|
+
const bar = "\u2500".repeat(72);
|
|
4746
|
+
console.log(`
|
|
4747
|
+
${bar}`);
|
|
4748
|
+
console.log(
|
|
4749
|
+
args.rulesetUpdated ? `\u2713 bypass migrated` : `\u2713 deploy key registered (no ruleset to migrate)`
|
|
4750
|
+
);
|
|
4751
|
+
console.log(bar);
|
|
4752
|
+
console.log(fmt("mirror", `${args.mirror.owner}/${args.mirror.repo}`));
|
|
4753
|
+
if (args.rulesetUpdated) {
|
|
4754
|
+
console.log(
|
|
4755
|
+
fmt(
|
|
4756
|
+
"bypass actors",
|
|
4757
|
+
args.opts.removeOrgadmin ? `DeployKey (OrganizationAdmin removed)` : `OrganizationAdmin + DeployKey`
|
|
4758
|
+
)
|
|
4759
|
+
);
|
|
4760
|
+
} else {
|
|
4761
|
+
console.log(
|
|
4762
|
+
fmt(
|
|
4763
|
+
"bypass actors",
|
|
4764
|
+
`n/a (no stamp-mirror-only Ruleset on this repo \u2014 server-gated only)`
|
|
4765
|
+
)
|
|
3992
4766
|
);
|
|
3993
4767
|
}
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
|
|
3998
|
-
|
|
4768
|
+
console.log(bar);
|
|
4769
|
+
if (!args.rulesetUpdated) {
|
|
4770
|
+
console.log(
|
|
4771
|
+
`
|
|
4772
|
+
Deploy key is registered; the next stamp push's mirror leg will use it.
|
|
4773
|
+
No GitHub Ruleset was found on this repo, so there's no bypass enforcement
|
|
4774
|
+
to verify. If you want GitHub-side protection, apply the stamp-mirror-only
|
|
4775
|
+
Ruleset separately (see docs/github-ruleset-setup.md).`
|
|
4776
|
+
);
|
|
4777
|
+
} else if (!args.opts.removeOrgadmin) {
|
|
4778
|
+
console.log(
|
|
4779
|
+
`
|
|
4780
|
+
Next: do a stamp merge + push to verify the DeployKey transport works,
|
|
4781
|
+
then re-run with --remove-orgadmin to drop the OrganizationAdmin fallback.`
|
|
4782
|
+
);
|
|
4783
|
+
} else {
|
|
4784
|
+
console.log(
|
|
4785
|
+
`
|
|
4786
|
+
The stamp-mirror-only Ruleset now bypasses ONLY via the per-repo deploy key.
|
|
4787
|
+
Direct \`git push origin main\` from any non-stamp source will be rejected.`
|
|
3999
4788
|
);
|
|
4000
4789
|
}
|
|
4001
4790
|
}
|
|
4002
|
-
function prompt(question) {
|
|
4003
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
4004
|
-
return new Promise((resolve2) => {
|
|
4005
|
-
rl.question(question, (answer) => {
|
|
4006
|
-
rl.close();
|
|
4007
|
-
resolve2(answer);
|
|
4008
|
-
});
|
|
4009
|
-
});
|
|
4010
|
-
}
|
|
4011
4791
|
|
|
4012
4792
|
// src/commands/keys.ts
|
|
4013
|
-
import { existsSync as
|
|
4014
|
-
import { basename, join as
|
|
4793
|
+
import { existsSync as existsSync12, readdirSync as readdirSync2, readFileSync as readFileSync9, writeFileSync as writeFileSync10 } from "fs";
|
|
4794
|
+
import { basename, join as join7 } from "path";
|
|
4015
4795
|
function keysGenerate() {
|
|
4016
4796
|
const existing = loadUserKeypair();
|
|
4017
4797
|
if (existing) {
|
|
@@ -4044,7 +4824,7 @@ function keysList() {
|
|
|
4044
4824
|
const repoRoot = findRepoRoot();
|
|
4045
4825
|
const trustedDir = stampTrustedKeysDir(repoRoot);
|
|
4046
4826
|
console.log(`repo trusted keys: ${trustedDir}/`);
|
|
4047
|
-
if (!
|
|
4827
|
+
if (!existsSync12(trustedDir)) {
|
|
4048
4828
|
console.log(" (directory does not exist \u2014 run `stamp init`)");
|
|
4049
4829
|
return;
|
|
4050
4830
|
}
|
|
@@ -4055,7 +4835,7 @@ function keysList() {
|
|
|
4055
4835
|
}
|
|
4056
4836
|
for (const file of pubFiles.sort()) {
|
|
4057
4837
|
try {
|
|
4058
|
-
const pem =
|
|
4838
|
+
const pem = readFileSync9(join7(trustedDir, file), "utf8");
|
|
4059
4839
|
const fp = fingerprintFromPem(pem);
|
|
4060
4840
|
const marker = local && fp === local.fingerprint ? " (you)" : "";
|
|
4061
4841
|
console.log(` ${fp}${marker} [${file}]`);
|
|
@@ -4074,15 +4854,15 @@ function keysExport() {
|
|
|
4074
4854
|
function keysTrust(pubFile) {
|
|
4075
4855
|
const repoRoot = findRepoRoot();
|
|
4076
4856
|
const trustedDir = stampTrustedKeysDir(repoRoot);
|
|
4077
|
-
if (!
|
|
4857
|
+
if (!existsSync12(trustedDir)) {
|
|
4078
4858
|
throw new Error(
|
|
4079
4859
|
`no ${trustedDir} \u2014 run \`stamp init\` first to create the trust store`
|
|
4080
4860
|
);
|
|
4081
4861
|
}
|
|
4082
|
-
if (!
|
|
4862
|
+
if (!existsSync12(pubFile)) {
|
|
4083
4863
|
throw new Error(`public key file not found: ${pubFile}`);
|
|
4084
4864
|
}
|
|
4085
|
-
const pem =
|
|
4865
|
+
const pem = readFileSync9(pubFile, "utf8");
|
|
4086
4866
|
let fingerprint;
|
|
4087
4867
|
try {
|
|
4088
4868
|
fingerprint = fingerprintFromPem(pem);
|
|
@@ -4092,12 +4872,12 @@ function keysTrust(pubFile) {
|
|
|
4092
4872
|
);
|
|
4093
4873
|
}
|
|
4094
4874
|
const filename = publicKeyFingerprintFilename(fingerprint);
|
|
4095
|
-
const dest =
|
|
4096
|
-
if (
|
|
4875
|
+
const dest = join7(trustedDir, filename);
|
|
4876
|
+
if (existsSync12(dest)) {
|
|
4097
4877
|
console.log(`${fingerprint} is already trusted (${basename(dest)})`);
|
|
4098
4878
|
return;
|
|
4099
4879
|
}
|
|
4100
|
-
|
|
4880
|
+
writeFileSync10(dest, pem);
|
|
4101
4881
|
console.log(`trusted ${fingerprint}`);
|
|
4102
4882
|
console.log(` \u2192 ${dest}`);
|
|
4103
4883
|
console.log();
|
|
@@ -4105,11 +4885,11 @@ function keysTrust(pubFile) {
|
|
|
4105
4885
|
}
|
|
4106
4886
|
|
|
4107
4887
|
// src/commands/log.ts
|
|
4108
|
-
import { existsSync as
|
|
4888
|
+
import { existsSync as existsSync13 } from "fs";
|
|
4109
4889
|
function runLog(opts) {
|
|
4110
4890
|
const repoRoot = findRepoRoot();
|
|
4111
4891
|
const configPath = stampConfigFile(repoRoot);
|
|
4112
|
-
if (!
|
|
4892
|
+
if (!existsSync13(configPath)) {
|
|
4113
4893
|
throw new Error(
|
|
4114
4894
|
`no .stamp/config.yml at ${configPath}. Run \`stamp init\` first.`
|
|
4115
4895
|
);
|
|
@@ -4226,7 +5006,7 @@ function printCommitDetail(sha, repoRoot) {
|
|
|
4226
5006
|
}
|
|
4227
5007
|
function collectReviewProse(repoRoot, payload) {
|
|
4228
5008
|
const dbPath = stampStateDbPath(repoRoot);
|
|
4229
|
-
if (!
|
|
5009
|
+
if (!existsSync13(dbPath)) return [];
|
|
4230
5010
|
const db = openDb(dbPath);
|
|
4231
5011
|
try {
|
|
4232
5012
|
const rows = latestReviews(db, payload.base_sha, payload.head_sha);
|
|
@@ -4240,7 +5020,7 @@ function printReviewHistory(repoRoot, limit, diff) {
|
|
|
4240
5020
|
const configPath = stampConfigFile(repoRoot);
|
|
4241
5021
|
loadConfig(configPath);
|
|
4242
5022
|
const dbPath = stampStateDbPath(repoRoot);
|
|
4243
|
-
if (!
|
|
5023
|
+
if (!existsSync13(dbPath)) {
|
|
4244
5024
|
console.log("No reviews recorded yet.");
|
|
4245
5025
|
return;
|
|
4246
5026
|
}
|
|
@@ -4281,8 +5061,8 @@ function printReviewHistory(repoRoot, limit, diff) {
|
|
|
4281
5061
|
}
|
|
4282
5062
|
|
|
4283
5063
|
// src/commands/prune.ts
|
|
4284
|
-
import { existsSync as
|
|
4285
|
-
import { join as
|
|
5064
|
+
import { existsSync as existsSync14, readdirSync as readdirSync3, statSync as statSync2, unlinkSync as unlinkSync3 } from "fs";
|
|
5065
|
+
import { join as join8 } from "path";
|
|
4286
5066
|
|
|
4287
5067
|
// src/lib/duration.ts
|
|
4288
5068
|
function parseRetentionDuration(input) {
|
|
@@ -4311,15 +5091,15 @@ function runPrune(opts) {
|
|
|
4311
5091
|
);
|
|
4312
5092
|
const repoRoot = findRepoRoot();
|
|
4313
5093
|
const dbPath = stampStateDbPath(repoRoot);
|
|
4314
|
-
const spoolDir =
|
|
5094
|
+
const spoolDir = join8(gitCommonDir(repoRoot), "stamp", "failed-parses");
|
|
4315
5095
|
const spoolCutoffMs = Date.now() - durationMs;
|
|
4316
|
-
if (!
|
|
5096
|
+
if (!existsSync14(dbPath) && !existsSync14(spoolDir)) {
|
|
4317
5097
|
console.log(
|
|
4318
5098
|
`note: nothing to prune (neither ${dbPath} nor ${spoolDir} exists \u2014 both are created on first \`stamp review\`)`
|
|
4319
5099
|
);
|
|
4320
5100
|
return;
|
|
4321
5101
|
}
|
|
4322
|
-
const db =
|
|
5102
|
+
const db = existsSync14(dbPath) ? openDb(dbPath) : null;
|
|
4323
5103
|
try {
|
|
4324
5104
|
if (opts.dryRun) {
|
|
4325
5105
|
let any2 = false;
|
|
@@ -4379,10 +5159,10 @@ function runPrune(opts) {
|
|
|
4379
5159
|
}
|
|
4380
5160
|
}
|
|
4381
5161
|
function peekFailedParseSpools(spoolDir, cutoffMs) {
|
|
4382
|
-
if (!
|
|
5162
|
+
if (!existsSync14(spoolDir)) return [];
|
|
4383
5163
|
const out = [];
|
|
4384
5164
|
for (const entry of readdirSync3(spoolDir)) {
|
|
4385
|
-
const filepath =
|
|
5165
|
+
const filepath = join8(spoolDir, entry);
|
|
4386
5166
|
let stat;
|
|
4387
5167
|
try {
|
|
4388
5168
|
stat = statSync2(filepath);
|
|
@@ -4399,7 +5179,7 @@ function pruneFailedParseSpools(spoolDir, cutoffMs) {
|
|
|
4399
5179
|
let deleted = 0;
|
|
4400
5180
|
for (const filepath of targets) {
|
|
4401
5181
|
try {
|
|
4402
|
-
|
|
5182
|
+
unlinkSync3(filepath);
|
|
4403
5183
|
deleted++;
|
|
4404
5184
|
} catch {
|
|
4405
5185
|
}
|
|
@@ -4415,96 +5195,8 @@ function printPerReviewer(rows) {
|
|
|
4415
5195
|
}
|
|
4416
5196
|
}
|
|
4417
5197
|
|
|
4418
|
-
// src/commands/server.ts
|
|
4419
|
-
import { existsSync as existsSync13, mkdirSync as mkdirSync4, renameSync as renameSync2, unlinkSync as unlinkSync3, writeFileSync as writeFileSync9 } from "fs";
|
|
4420
|
-
import { dirname as dirname4 } from "path";
|
|
4421
|
-
import { stringify as stringifyYaml2 } from "yaml";
|
|
4422
|
-
function formatServerConfigYaml(opts) {
|
|
4423
|
-
const body = {
|
|
4424
|
-
host: opts.host,
|
|
4425
|
-
port: opts.port
|
|
4426
|
-
};
|
|
4427
|
-
if (opts.user && opts.user.trim()) body.user = opts.user.trim();
|
|
4428
|
-
if (opts.repoRootPrefix && opts.repoRootPrefix.trim()) {
|
|
4429
|
-
body.repo_root_prefix = opts.repoRootPrefix.trim();
|
|
4430
|
-
}
|
|
4431
|
-
return stringifyYaml2(body);
|
|
4432
|
-
}
|
|
4433
|
-
function runServerConfig(opts) {
|
|
4434
|
-
const modes = [opts.hostPort, opts.show, opts.unset].filter(Boolean).length;
|
|
4435
|
-
if (modes !== 1) {
|
|
4436
|
-
throw new UsageError(
|
|
4437
|
-
"stamp server config: provide exactly one of <host:port>, --show, or --unset"
|
|
4438
|
-
);
|
|
4439
|
-
}
|
|
4440
|
-
if ((opts.show || opts.unset) && (opts.user || opts.repoRootPrefix)) {
|
|
4441
|
-
throw new UsageError(
|
|
4442
|
-
"stamp server config: --user and --repo-root-prefix only apply when writing (they conflict with --show / --unset)"
|
|
4443
|
-
);
|
|
4444
|
-
}
|
|
4445
|
-
if (opts.show) return showConfig();
|
|
4446
|
-
if (opts.unset) return unsetConfig();
|
|
4447
|
-
return writeConfig(opts);
|
|
4448
|
-
}
|
|
4449
|
-
function showConfig() {
|
|
4450
|
-
const path2 = userServerConfigPath();
|
|
4451
|
-
if (!existsSync13(path2)) {
|
|
4452
|
-
console.log(`note: no stamp server configured (${path2} does not exist)`);
|
|
4453
|
-
console.log(`note: run \`stamp server config <host:port>\` to create one`);
|
|
4454
|
-
return;
|
|
4455
|
-
}
|
|
4456
|
-
const cfg = loadServerConfig();
|
|
4457
|
-
if (!cfg) {
|
|
4458
|
-
console.log(`note: no stamp server configured`);
|
|
4459
|
-
return;
|
|
4460
|
-
}
|
|
4461
|
-
console.log(`config: ${path2}`);
|
|
4462
|
-
console.log(`host: ${cfg.host}`);
|
|
4463
|
-
console.log(`port: ${cfg.port}`);
|
|
4464
|
-
console.log(`user: ${cfg.user}`);
|
|
4465
|
-
console.log(`repo_root_prefix: ${cfg.repoRootPrefix}`);
|
|
4466
|
-
}
|
|
4467
|
-
function unsetConfig() {
|
|
4468
|
-
const path2 = userServerConfigPath();
|
|
4469
|
-
if (!existsSync13(path2)) {
|
|
4470
|
-
console.log(`note: ${path2} does not exist; nothing to remove`);
|
|
4471
|
-
return;
|
|
4472
|
-
}
|
|
4473
|
-
unlinkSync3(path2);
|
|
4474
|
-
console.log(`removed ${path2}`);
|
|
4475
|
-
}
|
|
4476
|
-
function writeConfig(opts) {
|
|
4477
|
-
let parsed;
|
|
4478
|
-
try {
|
|
4479
|
-
parsed = parseServerFlag(opts.hostPort, "stamp server config: <host:port>");
|
|
4480
|
-
} catch (err) {
|
|
4481
|
-
throw new UsageError(err instanceof Error ? err.message : String(err));
|
|
4482
|
-
}
|
|
4483
|
-
const yaml = formatServerConfigYaml({
|
|
4484
|
-
host: parsed.host,
|
|
4485
|
-
port: parsed.port,
|
|
4486
|
-
user: opts.user,
|
|
4487
|
-
repoRootPrefix: opts.repoRootPrefix
|
|
4488
|
-
});
|
|
4489
|
-
const path2 = userServerConfigPath();
|
|
4490
|
-
const dir = dirname4(path2);
|
|
4491
|
-
if (!existsSync13(dir)) mkdirSync4(dir, { recursive: true, mode: 448 });
|
|
4492
|
-
const tmp = `${path2}.tmp.${process.pid}`;
|
|
4493
|
-
writeFileSync9(tmp, yaml, { mode: 384 });
|
|
4494
|
-
renameSync2(tmp, path2);
|
|
4495
|
-
console.log(`wrote ${path2}`);
|
|
4496
|
-
console.log(`host: ${parsed.host}`);
|
|
4497
|
-
console.log(`port: ${parsed.port}`);
|
|
4498
|
-
if (opts.user && opts.user.trim()) {
|
|
4499
|
-
console.log(`user: ${opts.user.trim()}`);
|
|
4500
|
-
}
|
|
4501
|
-
if (opts.repoRootPrefix && opts.repoRootPrefix.trim()) {
|
|
4502
|
-
console.log(`repo_root_prefix: ${opts.repoRootPrefix.trim()}`);
|
|
4503
|
-
}
|
|
4504
|
-
}
|
|
4505
|
-
|
|
4506
5198
|
// src/commands/config.ts
|
|
4507
|
-
import { existsSync as
|
|
5199
|
+
import { existsSync as existsSync15 } from "fs";
|
|
4508
5200
|
function runConfigReviewersSet(opts) {
|
|
4509
5201
|
if (!isValidReviewerName(opts.reviewer)) {
|
|
4510
5202
|
throw new UsageError(
|
|
@@ -4577,7 +5269,7 @@ function runConfigReviewersClear(opts) {
|
|
|
4577
5269
|
}
|
|
4578
5270
|
function runConfigReviewersShow() {
|
|
4579
5271
|
const path2 = userConfigPath();
|
|
4580
|
-
if (!
|
|
5272
|
+
if (!existsSync15(path2)) {
|
|
4581
5273
|
console.log(`note: no per-user stamp config (${path2} does not exist).`);
|
|
4582
5274
|
console.log(
|
|
4583
5275
|
` Defaults will apply on next \`stamp init\` or \`stamp review\`:`
|
|
@@ -4622,30 +5314,30 @@ function loadOrEmpty() {
|
|
|
4622
5314
|
}
|
|
4623
5315
|
|
|
4624
5316
|
// src/commands/reviewers.ts
|
|
4625
|
-
import { spawnSync as
|
|
5317
|
+
import { spawnSync as spawnSync7 } from "child_process";
|
|
4626
5318
|
import {
|
|
4627
|
-
existsSync as
|
|
4628
|
-
readFileSync as
|
|
5319
|
+
existsSync as existsSync17,
|
|
5320
|
+
readFileSync as readFileSync11,
|
|
4629
5321
|
statSync as statSync3,
|
|
4630
5322
|
unlinkSync as unlinkSync4,
|
|
4631
|
-
writeFileSync as
|
|
5323
|
+
writeFileSync as writeFileSync12
|
|
4632
5324
|
} from "fs";
|
|
4633
|
-
import { join as
|
|
4634
|
-
import { parse as
|
|
5325
|
+
import { join as join10, relative, resolve } from "path";
|
|
5326
|
+
import { parse as parseYaml6, stringify as stringifyYaml3 } from "yaml";
|
|
4635
5327
|
|
|
4636
5328
|
// src/lib/reviewerLock.ts
|
|
4637
|
-
import { existsSync as
|
|
4638
|
-
import { join as
|
|
5329
|
+
import { existsSync as existsSync16, readFileSync as readFileSync10, writeFileSync as writeFileSync11 } from "fs";
|
|
5330
|
+
import { join as join9 } from "path";
|
|
4639
5331
|
var LOCK_FILE_VERSION = 1;
|
|
4640
5332
|
var LOCK_DRIFT_EXIT = 3;
|
|
4641
5333
|
function lockFilePath(repoRoot, reviewerName) {
|
|
4642
|
-
return
|
|
5334
|
+
return join9(repoRoot, ".stamp", "reviewers", `${reviewerName}.lock.json`);
|
|
4643
5335
|
}
|
|
4644
5336
|
function readLockFile(repoRoot, reviewerName) {
|
|
4645
5337
|
const path2 = lockFilePath(repoRoot, reviewerName);
|
|
4646
|
-
if (!
|
|
5338
|
+
if (!existsSync16(path2)) return null;
|
|
4647
5339
|
try {
|
|
4648
|
-
const raw =
|
|
5340
|
+
const raw = readFileSync10(path2, "utf8");
|
|
4649
5341
|
const parsed = JSON.parse(raw);
|
|
4650
5342
|
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") {
|
|
4651
5343
|
throw new Error(`malformed lock file at ${path2}`);
|
|
@@ -4659,20 +5351,20 @@ function readLockFile(repoRoot, reviewerName) {
|
|
|
4659
5351
|
}
|
|
4660
5352
|
function writeLockFile(repoRoot, reviewerName, lock) {
|
|
4661
5353
|
const path2 = lockFilePath(repoRoot, reviewerName);
|
|
4662
|
-
|
|
5354
|
+
writeFileSync11(path2, JSON.stringify(lock, null, 2) + "\n", "utf8");
|
|
4663
5355
|
}
|
|
4664
5356
|
function checkReviewerDrift(repoRoot, reviewerName, def) {
|
|
4665
5357
|
const lock = readLockFile(repoRoot, reviewerName);
|
|
4666
5358
|
if (!lock) {
|
|
4667
5359
|
return unpinnedResult();
|
|
4668
5360
|
}
|
|
4669
|
-
const promptPath =
|
|
4670
|
-
if (!
|
|
5361
|
+
const promptPath = join9(repoRoot, def.prompt);
|
|
5362
|
+
if (!existsSync16(promptPath)) {
|
|
4671
5363
|
throw new Error(
|
|
4672
5364
|
`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.`
|
|
4673
5365
|
);
|
|
4674
5366
|
}
|
|
4675
|
-
const promptBytes =
|
|
5367
|
+
const promptBytes = readFileSync10(promptPath);
|
|
4676
5368
|
const observedPrompt = hashPromptBytes(promptBytes);
|
|
4677
5369
|
const observedTools = hashTools(def.tools);
|
|
4678
5370
|
const observedMcp = hashMcpServers(def.mcp_servers);
|
|
@@ -4753,7 +5445,7 @@ function reviewersList() {
|
|
|
4753
5445
|
const def = config2.reviewers[name];
|
|
4754
5446
|
const abs = resolve(repoRoot, def.prompt);
|
|
4755
5447
|
let annotation = "";
|
|
4756
|
-
if (!
|
|
5448
|
+
if (!existsSync17(abs)) {
|
|
4757
5449
|
annotation = " MISSING";
|
|
4758
5450
|
} else {
|
|
4759
5451
|
const size = statSync3(abs).size;
|
|
@@ -4792,19 +5484,19 @@ function reviewersAdd(name, opts = {}) {
|
|
|
4792
5484
|
}
|
|
4793
5485
|
const promptRel = `.stamp/reviewers/${name}.md`;
|
|
4794
5486
|
const promptAbs = resolve(repoRoot, promptRel);
|
|
4795
|
-
if (
|
|
5487
|
+
if (existsSync17(promptAbs)) {
|
|
4796
5488
|
throw new Error(
|
|
4797
5489
|
`${promptRel} already exists on disk but is not in config. Either delete the file or add it to config manually.`
|
|
4798
5490
|
);
|
|
4799
5491
|
}
|
|
4800
|
-
|
|
5492
|
+
writeFileSync12(
|
|
4801
5493
|
promptAbs,
|
|
4802
5494
|
`# ${name}
|
|
4803
5495
|
|
|
4804
5496
|
${EXAMPLE_REVIEWER_PROMPT.split("\n").slice(2).join("\n")}`
|
|
4805
5497
|
);
|
|
4806
5498
|
config2.reviewers[name] = { prompt: promptRel };
|
|
4807
|
-
|
|
5499
|
+
writeFileSync12(configPath, stringifyConfig(config2));
|
|
4808
5500
|
console.log(`reviewer "${name}" added.`);
|
|
4809
5501
|
console.log(` prompt file: ${promptRel}`);
|
|
4810
5502
|
console.log(` registered in .stamp/config.yml`);
|
|
@@ -4839,11 +5531,11 @@ function reviewersRemove(name, opts = {}) {
|
|
|
4839
5531
|
);
|
|
4840
5532
|
}
|
|
4841
5533
|
delete config2.reviewers[name];
|
|
4842
|
-
|
|
5534
|
+
writeFileSync12(configPath, stringifyConfig(config2));
|
|
4843
5535
|
console.log(`reviewer "${name}" removed from .stamp/config.yml`);
|
|
4844
5536
|
if (opts.deleteFile) {
|
|
4845
5537
|
const promptAbs = resolve(repoRoot, def.prompt);
|
|
4846
|
-
if (
|
|
5538
|
+
if (existsSync17(promptAbs)) {
|
|
4847
5539
|
unlinkSync4(promptAbs);
|
|
4848
5540
|
console.log(`deleted ${def.prompt}`);
|
|
4849
5541
|
}
|
|
@@ -4874,8 +5566,8 @@ async function reviewersTest(name, diff) {
|
|
|
4874
5566
|
console.log(` prompt sourced from working tree (test/iteration use case)`);
|
|
4875
5567
|
console.log();
|
|
4876
5568
|
const def = config2.reviewers[name];
|
|
4877
|
-
const promptPath =
|
|
4878
|
-
const systemPrompt =
|
|
5569
|
+
const promptPath = join10(repoRoot, def.prompt);
|
|
5570
|
+
const systemPrompt = readFileSync11(promptPath, "utf8");
|
|
4879
5571
|
const result = await invokeReviewer({
|
|
4880
5572
|
reviewer: name,
|
|
4881
5573
|
config: config2,
|
|
@@ -4902,7 +5594,7 @@ function reviewersShow(name, opts) {
|
|
|
4902
5594
|
);
|
|
4903
5595
|
}
|
|
4904
5596
|
const dbPath = stampStateDbPath(repoRoot);
|
|
4905
|
-
if (!
|
|
5597
|
+
if (!existsSync17(dbPath)) {
|
|
4906
5598
|
console.log("No reviews recorded yet (no state.db).");
|
|
4907
5599
|
return;
|
|
4908
5600
|
}
|
|
@@ -4992,7 +5684,7 @@ async function reviewersFetch(reviewerName, opts) {
|
|
|
4992
5684
|
opts.expectMcpSha
|
|
4993
5685
|
);
|
|
4994
5686
|
const reviewersDir = stampReviewersDir(repoRoot);
|
|
4995
|
-
if (!
|
|
5687
|
+
if (!existsSync17(reviewersDir)) {
|
|
4996
5688
|
throw new Error(
|
|
4997
5689
|
`${reviewersDir} does not exist \u2014 run \`stamp init\` first.`
|
|
4998
5690
|
);
|
|
@@ -5005,7 +5697,7 @@ async function reviewersFetch(reviewerName, opts) {
|
|
|
5005
5697
|
let tools;
|
|
5006
5698
|
let mcpServers;
|
|
5007
5699
|
if (configYaml !== null) {
|
|
5008
|
-
const parsed =
|
|
5700
|
+
const parsed = parseYaml6(configYaml) ?? {};
|
|
5009
5701
|
if (Array.isArray(parsed.tools)) {
|
|
5010
5702
|
tools = parseToolsLoose(parsed.tools);
|
|
5011
5703
|
}
|
|
@@ -5013,7 +5705,7 @@ async function reviewersFetch(reviewerName, opts) {
|
|
|
5013
5705
|
mcpServers = validateMcpServersFromSource(parsed.mcp_servers, source, ref);
|
|
5014
5706
|
}
|
|
5015
5707
|
}
|
|
5016
|
-
const promptPath =
|
|
5708
|
+
const promptPath = join10(reviewersDir, `${reviewerName}.md`);
|
|
5017
5709
|
const promptBytes = Buffer.from(promptText, "utf8");
|
|
5018
5710
|
const promptSha = hashPromptBytes(promptBytes);
|
|
5019
5711
|
const toolsSha = hashTools(tools);
|
|
@@ -5037,7 +5729,7 @@ async function reviewersFetch(reviewerName, opts) {
|
|
|
5037
5729
|
mcpSha
|
|
5038
5730
|
);
|
|
5039
5731
|
}
|
|
5040
|
-
|
|
5732
|
+
writeFileSync12(promptPath, promptBytes);
|
|
5041
5733
|
const lock = {
|
|
5042
5734
|
version: LOCK_FILE_VERSION,
|
|
5043
5735
|
source,
|
|
@@ -5266,7 +5958,7 @@ function buildConfigYamlHint(reviewerName, tools, mcpServers) {
|
|
|
5266
5958
|
}
|
|
5267
5959
|
function launchEditor(path2) {
|
|
5268
5960
|
const editor = process.env["EDITOR"] ?? process.env["VISUAL"] ?? (process.platform === "win32" ? "notepad" : "vi");
|
|
5269
|
-
const result =
|
|
5961
|
+
const result = spawnSync7(editor, [path2], { stdio: "inherit" });
|
|
5270
5962
|
if (result.error) {
|
|
5271
5963
|
throw new Error(
|
|
5272
5964
|
`failed to launch editor "${editor}": ${result.error.message}`
|
|
@@ -5278,11 +5970,11 @@ function launchEditor(path2) {
|
|
|
5278
5970
|
}
|
|
5279
5971
|
|
|
5280
5972
|
// src/commands/status.ts
|
|
5281
|
-
import { existsSync as
|
|
5973
|
+
import { existsSync as existsSync18 } from "fs";
|
|
5282
5974
|
function runStatus(opts) {
|
|
5283
5975
|
const repoRoot = findRepoRoot();
|
|
5284
5976
|
const configPath = stampConfigFile(repoRoot);
|
|
5285
|
-
if (!
|
|
5977
|
+
if (!existsSync18(configPath)) {
|
|
5286
5978
|
throw new Error(
|
|
5287
5979
|
`no .stamp/config.yml at ${configPath}. Run \`stamp init\` first.`
|
|
5288
5980
|
);
|
|
@@ -5350,17 +6042,17 @@ function printGate(result, base_sha, head_sha) {
|
|
|
5350
6042
|
}
|
|
5351
6043
|
|
|
5352
6044
|
// src/commands/update.ts
|
|
5353
|
-
import { spawnSync as
|
|
6045
|
+
import { spawnSync as spawnSync8 } from "child_process";
|
|
5354
6046
|
|
|
5355
6047
|
// src/lib/version.ts
|
|
5356
|
-
import { readFileSync as
|
|
5357
|
-
import { dirname as dirname5, join as
|
|
6048
|
+
import { readFileSync as readFileSync12 } from "fs";
|
|
6049
|
+
import { dirname as dirname5, join as join11 } from "path";
|
|
5358
6050
|
import { fileURLToPath } from "url";
|
|
5359
6051
|
function readPackageVersion() {
|
|
5360
6052
|
const here = dirname5(fileURLToPath(import.meta.url));
|
|
5361
6053
|
for (let dir = here, i = 0; i < 6; i++) {
|
|
5362
6054
|
try {
|
|
5363
|
-
const raw =
|
|
6055
|
+
const raw = readFileSync12(join11(dir, "package.json"), "utf8");
|
|
5364
6056
|
const pkg = JSON.parse(raw);
|
|
5365
6057
|
if (pkg.name === "@openthink/stamp" && pkg.version) return pkg.version;
|
|
5366
6058
|
} catch {
|
|
@@ -5391,7 +6083,7 @@ function runUpdate() {
|
|
|
5391
6083
|
`);
|
|
5392
6084
|
process.stdout.write(`checking npm registry for latest...
|
|
5393
6085
|
`);
|
|
5394
|
-
const viewResult =
|
|
6086
|
+
const viewResult = spawnSync8("npm", ["view", PKG_NAME, "version"], {
|
|
5395
6087
|
encoding: "utf8"
|
|
5396
6088
|
});
|
|
5397
6089
|
if (viewResult.error || viewResult.status !== 0) {
|
|
@@ -5425,7 +6117,7 @@ function runUpdate() {
|
|
|
5425
6117
|
}
|
|
5426
6118
|
process.stdout.write(`installing ${PKG_NAME}@${latest}...
|
|
5427
6119
|
`);
|
|
5428
|
-
const installResult =
|
|
6120
|
+
const installResult = spawnSync8(
|
|
5429
6121
|
"npm",
|
|
5430
6122
|
["install", "-g", `${PKG_NAME}@${latest}`],
|
|
5431
6123
|
{ stdio: "inherit" }
|
|
@@ -5446,9 +6138,9 @@ through that tool instead \u2014 this command only uses 'npm install -g'.`
|
|
|
5446
6138
|
}
|
|
5447
6139
|
|
|
5448
6140
|
// src/commands/verify.ts
|
|
5449
|
-
import { execFileSync as execFileSync2, spawnSync as
|
|
6141
|
+
import { execFileSync as execFileSync2, spawnSync as spawnSync9 } from "child_process";
|
|
5450
6142
|
function loadConfigAtSha(sha, repoRoot) {
|
|
5451
|
-
const result =
|
|
6143
|
+
const result = spawnSync9(
|
|
5452
6144
|
"git",
|
|
5453
6145
|
["show", `${sha}:.stamp/config.yml`],
|
|
5454
6146
|
{ cwd: repoRoot, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }
|
|
@@ -5694,6 +6386,9 @@ program.command("init").description(
|
|
|
5694
6386
|
"--remote <name>",
|
|
5695
6387
|
"remote name to inspect for deployment-shape detection (default: origin)",
|
|
5696
6388
|
"origin"
|
|
6389
|
+
).option(
|
|
6390
|
+
"--no-oteam",
|
|
6391
|
+
"bypass the oteam-detection prompt that offers to fill stamp.host in ~/.open-team/config.json"
|
|
5697
6392
|
).action(
|
|
5698
6393
|
(opts) => {
|
|
5699
6394
|
try {
|
|
@@ -5714,7 +6409,8 @@ program.command("init").description(
|
|
|
5714
6409
|
bootstrapCommit: opts.bootstrapCommit,
|
|
5715
6410
|
ghProtect: opts.ghProtect,
|
|
5716
6411
|
mode,
|
|
5717
|
-
remote: opts.remote
|
|
6412
|
+
remote: opts.remote,
|
|
6413
|
+
oteam: opts.oteam
|
|
5718
6414
|
});
|
|
5719
6415
|
} catch (err) {
|
|
5720
6416
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -5757,8 +6453,8 @@ program.command("bootstrap").description(
|
|
|
5757
6453
|
}
|
|
5758
6454
|
}
|
|
5759
6455
|
);
|
|
5760
|
-
program.command("provision
|
|
5761
|
-
"single-command server-gated repo setup: provision a bare repo on the stamp server (~/.stamp/server.yml or --server), clone it, run bootstrap, optionally create a GitHub mirror + apply the Ruleset"
|
|
6456
|
+
program.command("provision [name]").description(
|
|
6457
|
+
"single-command server-gated repo setup: provision a bare repo on the stamp server (~/.stamp/server.yml or --server), clone it, run bootstrap, optionally create a GitHub mirror + apply the Ruleset. With --migrate-bypass, migrate an existing server-gated repo's Ruleset bypass from OrganizationAdmin to a per-repo DeployKey actor (cwd's .stamp/mirror.yml identifies the target; <name> is ignored)."
|
|
5762
6458
|
).option(
|
|
5763
6459
|
"--server <host:port>",
|
|
5764
6460
|
"override ~/.stamp/server.yml with an inline endpoint"
|
|
@@ -5774,11 +6470,22 @@ program.command("provision <name>").description(
|
|
|
5774
6470
|
).option("--no-mirror", "skip GitHub mirror creation + .stamp/mirror.yml").option("--no-ruleset", "skip applying the GitHub Ruleset on the mirror").option("--dry-run", "print the plan without making changes").option(
|
|
5775
6471
|
"--migrate-existing",
|
|
5776
6472
|
"brownfield: migrate the existing repo at cwd (with .stamp/ committed and origin \u2192 github) to server-gated; preserves history, renames origin \u2192 github, points new origin at the stamp server"
|
|
6473
|
+
).option(
|
|
6474
|
+
"--migrate-bypass",
|
|
6475
|
+
"migrate an existing server-gated repo's stamp-mirror-only Ruleset bypass actor from OrganizationAdmin to a per-repo DeployKey. Identifies the target via cwd's .stamp/mirror.yml. Additive by default (DeployKey added alongside existing actors); pair with --remove-orgadmin to also strip OrganizationAdmin from the bypass list"
|
|
6476
|
+
).option(
|
|
6477
|
+
"--remove-orgadmin",
|
|
6478
|
+
"under --migrate-bypass, also remove OrganizationAdmin from the ruleset's bypass list. Verify the DeployKey transport works (one stamp push) before running this \u2014 there is no automated push-verification step"
|
|
5777
6479
|
).action(
|
|
5778
6480
|
async (name, opts) => {
|
|
5779
6481
|
try {
|
|
5780
6482
|
await runProvision({
|
|
5781
|
-
name
|
|
6483
|
+
// ProvisionOptions.name is typed `string` so the rest of the
|
|
6484
|
+
// downstream readers don't have to narrow. Empty placeholder
|
|
6485
|
+
// for --migrate-bypass (which doesn't read it); the validation
|
|
6486
|
+
// block in runProvision requires a non-empty name in all other
|
|
6487
|
+
// modes.
|
|
6488
|
+
name: name ?? "",
|
|
5782
6489
|
server: opts.server,
|
|
5783
6490
|
org: opts.org,
|
|
5784
6491
|
into: opts.into,
|
|
@@ -5786,7 +6493,9 @@ program.command("provision <name>").description(
|
|
|
5786
6493
|
noMirror: !opts.mirror,
|
|
5787
6494
|
noRuleset: !opts.ruleset,
|
|
5788
6495
|
dryRun: opts.dryRun,
|
|
5789
|
-
migrateExisting: opts.migrateExisting
|
|
6496
|
+
migrateExisting: opts.migrateExisting,
|
|
6497
|
+
migrateBypass: opts.migrateBypass,
|
|
6498
|
+
removeOrgadmin: opts.removeOrgadmin
|
|
5790
6499
|
});
|
|
5791
6500
|
} catch (err) {
|
|
5792
6501
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -5821,6 +6530,21 @@ server.command("config [host:port]").description(
|
|
|
5821
6530
|
}
|
|
5822
6531
|
}
|
|
5823
6532
|
);
|
|
6533
|
+
server.command("pubkey").description(
|
|
6534
|
+
"print a stamp-server-managed GitHub mirror-push deploy-key public half \u2014 single OpenSSH line, pipe-able into `gh api -X POST /repos/:o/:r/keys --field key=@-` to register as a deploy key. Without --repo, returns the legacy shared key (back-compat). With --repo <owner/repo>, returns a per-repo key that the server lazily generates on first request \u2014 preferred for new migrations because GitHub rejects re-registering the same key on a second repo."
|
|
6535
|
+
).option(
|
|
6536
|
+
"--server <host:port>",
|
|
6537
|
+
"override ~/.stamp/server.yml for this call"
|
|
6538
|
+
).option(
|
|
6539
|
+
"--repo <owner>/<repo>",
|
|
6540
|
+
"fetch the per-repo deploy key for this GitHub mirror (lazy-generated server-side on first request)"
|
|
6541
|
+
).action((opts) => {
|
|
6542
|
+
try {
|
|
6543
|
+
runServerPubkey({ server: opts.server, repo: opts.repo });
|
|
6544
|
+
} catch (err) {
|
|
6545
|
+
handleCliError(err);
|
|
6546
|
+
}
|
|
6547
|
+
});
|
|
5824
6548
|
var config = program.command("config").description(
|
|
5825
6549
|
"manage per-user stamp config at ~/.stamp/config.yml \u2014 operator-level knobs that shouldn't be committed. Per-repo policy lives in `.stamp/config.yml`."
|
|
5826
6550
|
);
|
|
@@ -5905,7 +6629,7 @@ serverRepo.command("restore <name>").description("restore the most recent soft-d
|
|
|
5905
6629
|
}
|
|
5906
6630
|
);
|
|
5907
6631
|
program.command("review").description(
|
|
5908
|
-
"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)."
|
|
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 are env-tunable: STAMP_REVIEWER_MAX_TURNS (default 8) caps the model/tool round-trip count, STAMP_REVIEWER_TIMEOUT_MS (default 300000) bounds wall-clock time. Raise them when a reviewer with heavy lookup tools (Linear / GitHub MCP, multi-file Read) fails with subtype=error_max_turns or the abort message \u2014 see docs/troubleshooting.md."
|
|
5909
6633
|
).requiredOption("--diff <revspec>", "git revspec to review, e.g. main..HEAD").option("--only <reviewer>", "run a single reviewer by name").option(
|
|
5910
6634
|
"--allow-large",
|
|
5911
6635
|
"bypass the 200KB diff size cap (raise STAMP_REVIEW_DIFF_CAP_BYTES for a different threshold)"
|