@openthink/stamp 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js 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-cli \u2014 do not edit between markers) -->";
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 STAMP_CLAUDE_BEGIN = "<!-- stamp:claude:begin (managed by stamp-cli \u2014 do not edit between markers) -->";
62
- var STAMP_CLAUDE_END = "<!-- stamp:claude:end -->";
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 beginIdx = existing.indexOf(STAMP_BEGIN);
267
- const endIdx = existing.indexOf(STAMP_END);
268
- if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
269
- const before = existing.slice(0, beginIdx);
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 = `${STAMP_CLAUDE_BEGIN}
336
+ const stampBlock = `${STAMP_BEGIN}
320
337
 
321
338
  ${STAMP_CLAUDE_SECTION.trimEnd()}
322
339
 
323
- ${STAMP_CLAUDE_END}`;
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 beginIdx = existing.indexOf(STAMP_CLAUDE_BEGIN);
333
- const endIdx = existing.indexOf(STAMP_CLAUDE_END);
334
- if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
335
- const before = existing.slice(0, beginIdx);
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(STAMP_CLAUDE_BEGIN) ? "replaced" : "appended";
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 existsSync7, writeFileSync as writeFileSync6 } from "fs";
2836
- import { join as join4 } from "path";
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 = existsSync7(configFile);
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
- writeFileSync6(configFile, stringifyConfig(MINIMAL_CONFIG));
3014
- writeFileSync6(join4(reviewersDir, "example.md"), EXAMPLE_REVIEWER_PROMPT);
3353
+ writeFileSync7(configFile, stringifyConfig(MINIMAL_CONFIG));
3354
+ writeFileSync7(join5(reviewersDir, "example.md"), EXAMPLE_REVIEWER_PROMPT);
3015
3355
  } else {
3016
- writeFileSync6(configFile, stringifyConfig(DEFAULT_CONFIG));
3017
- writeFileSync6(
3018
- join4(reviewersDir, "security.md"),
3356
+ writeFileSync7(configFile, stringifyConfig(DEFAULT_CONFIG));
3357
+ writeFileSync7(
3358
+ join5(reviewersDir, "security.md"),
3019
3359
  DEFAULT_SECURITY_PROMPT
3020
3360
  );
3021
- writeFileSync6(
3022
- join4(reviewersDir, "standards.md"),
3361
+ writeFileSync7(
3362
+ join5(reviewersDir, "standards.md"),
3023
3363
  DEFAULT_STANDARDS_PROMPT
3024
3364
  );
3025
- writeFileSync6(
3026
- join4(reviewersDir, "product.md"),
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 = join4(
3373
+ const pubKeyPath = join5(
3034
3374
  trustedKeysDir,
3035
3375
  publicKeyFingerprintFilename(keypair.fingerprint)
3036
3376
  );
3037
- const keyDeposited = !existsSync7(pubKeyPath);
3377
+ const keyDeposited = !existsSync9(pubKeyPath);
3038
3378
  if (keyDeposited) {
3039
- writeFileSync6(pubKeyPath, keypair.publicKeyPem);
3379
+ writeFileSync7(pubKeyPath, keypair.publicKeyPem);
3040
3380
  }
3041
- const dbExisted = existsSync7(stateDbPath);
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 (existsSync7(join4(repoRoot, "AGENTS.md"))) toAdd.push("AGENTS.md");
3172
- if (existsSync7(join4(repoRoot, "CLAUDE.md"))) toAdd.push("CLAUDE.md");
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 spawnSync4 } from "child_process";
3341
- import { existsSync as existsSync9, mkdtempSync, rmSync, writeFileSync as writeFileSync7 } from "fs";
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 join5, resolve as resolvePath } from "path";
3724
+ import { join as join6, resolve as resolvePath } from "path";
3725
+ import { parse as parseYaml5 } from "yaml";
3344
3726
 
3345
- // src/lib/serverConfig.ts
3346
- import { existsSync as existsSync8, readFileSync as readFileSync6 } from "fs";
3347
- import { parse as parseYaml4 } from "yaml";
3348
- var DEFAULT_USER = "git";
3349
- var DEFAULT_REPO_ROOT = "/srv/git";
3350
- var USER_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;
3351
- var HOST_RE = /^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$/;
3352
- var REPO_ROOT_RE = /^(\/[A-Za-z0-9_-][A-Za-z0-9._-]*)+\/?$/;
3353
- function describeShape2(field) {
3354
- switch (field) {
3355
- case "user":
3356
- return "alphanumerics + . _ -, must not start with -";
3357
- case "host":
3358
- return "hostname-shaped (alphanumerics + . -, must start and end with alphanumeric)";
3359
- case "repo_root_prefix":
3360
- return "absolute path with alphanumeric/. _ - segments, no .. components";
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
- function validateField(field, value, contextPath) {
3364
- const re = field === "user" ? USER_RE : field === "host" ? HOST_RE : REPO_ROOT_RE;
3365
- if (!re.test(value)) {
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
- `${contextPath}: '${field}' has an invalid shape (got ${JSON.stringify(value)}). Allowed: ${describeShape2(field)}.`
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 loadServerConfig() {
3372
- const path2 = userServerConfigPath();
3373
- if (!existsSync8(path2)) return null;
3374
- let raw;
3375
- try {
3376
- raw = readFileSync6(path2, "utf8");
3377
- } catch (err) {
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 to read ${path2}: ${err instanceof Error ? err.message : String(err)}`
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 parseServerConfig(raw, contextPath = "<inline>") {
3385
- const parsed = parseYaml4(raw);
3386
- if (!parsed || typeof parsed !== "object") {
3387
- throw new Error(`${contextPath}: must be a YAML mapping with at least 'host' and 'port'`);
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 obj = parsed;
3390
- if (typeof obj.host !== "string" || !obj.host.trim()) {
3391
- throw new Error(`${contextPath}: 'host' is required and must be a non-empty string`);
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
- if (typeof obj.port !== "number" || !Number.isInteger(obj.port) || obj.port < 1 || obj.port > 65535) {
3394
- throw new Error(`${contextPath}: 'port' is required and must be an integer 1..65535`);
3897
+ const entries = filterLiveBareRepoNames(result.stdout);
3898
+ if (entries.length === 0) {
3899
+ console.log("(no live bare repos)");
3900
+ return;
3395
3901
  }
3396
- const host = obj.host.trim();
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 parseServerFlag(value, context = "--server") {
3410
- const m = value.trim().match(/^([^:]+):(\d+)$/);
3411
- if (!m) {
3904
+ function resolveServer(serverFlag) {
3905
+ const server2 = serverFlag ? parseServerFlag(serverFlag) : loadServerConfig();
3906
+ if (!server2) {
3412
3907
  throw new Error(
3413
- `${context} must be in the form <host>:<port> (got "${value}")`
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
- const port = Number(m[2]);
3417
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
3418
- throw new Error(
3419
- `${context}: port must be an integer 1..65535 (got "${m[2]}")`
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
- const host = m[1];
3423
- validateField("host", host, context);
3424
- return {
3425
- host,
3426
- port,
3427
- user: DEFAULT_USER,
3428
- repoRootPrefix: DEFAULT_REPO_ROOT
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 bareRepoSshUrl(cfg, repoName) {
3432
- return `ssh://${cfg.user}@${cfg.host}:${cfg.port}${cfg.repoRootPrefix}/${repoName}.git`;
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
- validateRepoName(opts.name);
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 (existsSync9(cloneTarget)) {
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 validateRepoName(name) {
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 = spawnSync4(
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 = spawnSync4(
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
- writeFileSync7(path2, yml);
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 user = lookupAuthenticatedUserId();
3581
- if (!user) {
4267
+ function applyMirrorRuleset(mirror, server2) {
4268
+ const existing = findExistingStampRuleset(mirror.owner, mirror.repo);
4269
+ if (existing !== null) {
3582
4270
  console.log(
3583
- `note: GitHub Ruleset auto-apply skipped \u2014 couldn't look up the gh-authenticated user.`
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
- `note: GitHub Ruleset auto-apply skipped \u2014 couldn't determine whether ${mirror.owner}/${mirror.repo} is a personal or org repo.`
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(` For manual setup, see docs/github-ruleset-setup.md.`);
4280
+ console.log(` For manual setup, see docs/github-ruleset-setup.md.`);
3594
4281
  return;
3595
4282
  }
3596
- const actor = ownerType === "Organization" ? { type: "OrganizationAdmin", id: 1 } : { type: "User", id: user.id };
3597
- const actorDescription = actor.type === "OrganizationAdmin" ? "any org admin (your gh-authed user must be one to push as bypass)" : `${user.login}, id ${user.id}`;
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(join5(tmpdir(), "stamp-migrate-"));
3662
- const bareCloneDir = join5(stagingDir, `${opts.name}.git`);
3663
- const tarballPath = join5(stagingDir, `${opts.name}.tar.gz`);
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 (!existsSync9(join5(cwd, ".stamp", "config.yml"))) {
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 (${join5(cwd, ".stamp/config.yml")} not found). Run \`stamp init --mode local-only\` first, calibrate your reviewers, then re-run with --migrate-existing.`
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 = spawnSync4("tar", ["-czf", outputPath, "-C", parentDir, dirName], {
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 = spawnSync4(
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 = spawnSync4(
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
- // src/commands/serverRepo.ts
3827
- import { spawnSync as spawnSync5 } from "child_process";
3828
- import { createInterface } from "readline";
3829
- async function runServerRepoDelete(opts) {
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
- console.log();
3842
- if (!opts.yes) {
3843
- const expected = opts.purge ? `purge ${name}` : `delete ${name}`;
3844
- const got = await prompt(`Type "${expected}" to confirm: `);
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
- `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.`
4564
+ `could not read ${path2}: ${err instanceof Error ? err.message : String(err)}`
3860
4565
  );
3861
4566
  }
3862
- if (!opts.purge) {
3863
- console.log();
3864
- console.log(`Recovery:`);
3865
- console.log(` stamp server-repos restore ${name} # bring it back`);
3866
- console.log(` stamp server-repos delete ${name} --purge # nuke for real`);
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 (asName) {
3903
- args.push("--as", asName);
4575
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
4576
+ throw new Error(`${path2} is empty or not a map`);
3904
4577
  }
3905
- const result = spawnSync5(
3906
- "ssh",
3907
- ["-p", String(server2.port), "--", `${server2.user}@${server2.host}`, ...args],
3908
- { stdio: ["ignore", "inherit", "inherit"] }
3909
- );
3910
- if (result.status !== 0) {
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
- `server-side restore failed (exit ${result.status}). Run \`stamp server-repos list --trash\` to see what's available.`
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 runServerRepoList(opts) {
3917
- const server2 = resolveServer(opts.server);
3918
- if (opts.trash) {
3919
- const result2 = spawnSync5(
3920
- "ssh",
3921
- ["-p", String(server2.port), "--", `${server2.user}@${server2.host}`, "list-trash"],
3922
- { stdio: ["ignore", "inherit", "inherit"] }
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
- if (result2.status !== 0) {
3925
- throw new Error(
3926
- `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.`
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
- const result = spawnSync5(
3932
- "ssh",
3933
- [
3934
- "-p",
3935
- String(server2.port),
3936
- "--",
3937
- `${server2.user}@${server2.host}`,
3938
- "ls",
3939
- "-1",
3940
- "/srv/git/"
3941
- ],
3942
- { stdio: ["ignore", "pipe", "inherit"], encoding: "utf8" }
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
- if (result.status !== 0) {
3945
- throw new Error(`list failed (exit ${result.status}).`);
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
- const entries = filterLiveBareRepoNames(result.stdout);
3948
- if (entries.length === 0) {
3949
- console.log("(no live bare repos)");
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
- for (const e of entries) console.log(e);
3953
- }
3954
- function resolveServer(serverFlag) {
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
- `no stamp server configured. Either:
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
- return server2;
3966
- }
3967
- var UsageError = class extends Error {
3968
- constructor(message) {
3969
- super(message);
3970
- this.name = "UsageError";
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
- function normalizeRepoName(name) {
3974
- const canonical = name.endsWith(".git") ? name.slice(0, -4) : name;
3975
- validateRepoName2(canonical);
3976
- return canonical;
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 filterLiveBareRepoNames(rawOutput) {
3979
- 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);
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 validateRepoName2(name) {
3982
- if (!/^[A-Za-z0-9_][A-Za-z0-9._-]*$/.test(name) || name.includes("..")) {
3983
- throw new UsageError(
3984
- `repo name must start with [A-Za-z0-9_], match [A-Za-z0-9._-]+, and not contain '..' (got "${name}")`
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 validateTrashEntryName(entry) {
3989
- if (!/^[0-9]{8}T[0-9]{6}Z-[A-Za-z0-9_][A-Za-z0-9._-]*\.git$/.test(entry)) {
3990
- throw new UsageError(
3991
- `--from must match <YYYYMMDDTHHMMSSZ>-<name>.git (got "${entry}"). Run \`stamp server-repos list --trash\` to see valid entry names.`
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
- function validateGithubRepoSpec(spec) {
3996
- if (!/^[A-Za-z0-9_][A-Za-z0-9-]*\/[A-Za-z0-9_][A-Za-z0-9._-]*$/.test(spec)) {
3997
- throw new UsageError(
3998
- `--also-github must be <owner>/<repo> with no leading '-' on either segment (got "${spec}")`
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 existsSync10, readdirSync as readdirSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync8 } from "fs";
4014
- import { basename, join as join6 } from "path";
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 (!existsSync10(trustedDir)) {
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 = readFileSync7(join6(trustedDir, file), "utf8");
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 (!existsSync10(trustedDir)) {
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 (!existsSync10(pubFile)) {
4862
+ if (!existsSync12(pubFile)) {
4083
4863
  throw new Error(`public key file not found: ${pubFile}`);
4084
4864
  }
4085
- const pem = readFileSync7(pubFile, "utf8");
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 = join6(trustedDir, filename);
4096
- if (existsSync10(dest)) {
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
- writeFileSync8(dest, pem);
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 existsSync11 } from "fs";
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 (!existsSync11(configPath)) {
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 (!existsSync11(dbPath)) return [];
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 (!existsSync11(dbPath)) {
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 existsSync12, readdirSync as readdirSync3, statSync as statSync2, unlinkSync as unlinkSync2 } from "fs";
4285
- import { join as join7 } from "path";
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 = join7(gitCommonDir(repoRoot), "stamp", "failed-parses");
5094
+ const spoolDir = join8(gitCommonDir(repoRoot), "stamp", "failed-parses");
4315
5095
  const spoolCutoffMs = Date.now() - durationMs;
4316
- if (!existsSync12(dbPath) && !existsSync12(spoolDir)) {
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 = existsSync12(dbPath) ? openDb(dbPath) : null;
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 (!existsSync12(spoolDir)) return [];
5162
+ if (!existsSync14(spoolDir)) return [];
4383
5163
  const out = [];
4384
5164
  for (const entry of readdirSync3(spoolDir)) {
4385
- const filepath = join7(spoolDir, entry);
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
- unlinkSync2(filepath);
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 existsSync14 } from "fs";
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 (!existsSync14(path2)) {
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 spawnSync6 } from "child_process";
5317
+ import { spawnSync as spawnSync7 } from "child_process";
4626
5318
  import {
4627
- existsSync as existsSync16,
4628
- readFileSync as readFileSync9,
5319
+ existsSync as existsSync17,
5320
+ readFileSync as readFileSync11,
4629
5321
  statSync as statSync3,
4630
5322
  unlinkSync as unlinkSync4,
4631
- writeFileSync as writeFileSync11
5323
+ writeFileSync as writeFileSync12
4632
5324
  } from "fs";
4633
- import { join as join9, relative, resolve } from "path";
4634
- import { parse as parseYaml5, stringify as stringifyYaml3 } from "yaml";
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 existsSync15, readFileSync as readFileSync8, writeFileSync as writeFileSync10 } from "fs";
4638
- import { join as join8 } from "path";
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 join8(repoRoot, ".stamp", "reviewers", `${reviewerName}.lock.json`);
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 (!existsSync15(path2)) return null;
5338
+ if (!existsSync16(path2)) return null;
4647
5339
  try {
4648
- const raw = readFileSync8(path2, "utf8");
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
- writeFileSync10(path2, JSON.stringify(lock, null, 2) + "\n", "utf8");
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 = join8(repoRoot, def.prompt);
4670
- if (!existsSync15(promptPath)) {
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 = readFileSync8(promptPath);
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 (!existsSync16(abs)) {
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 (existsSync16(promptAbs)) {
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
- writeFileSync11(
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
- writeFileSync11(configPath, stringifyConfig(config2));
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
- writeFileSync11(configPath, stringifyConfig(config2));
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 (existsSync16(promptAbs)) {
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 = join9(repoRoot, def.prompt);
4878
- const systemPrompt = readFileSync9(promptPath, "utf8");
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 (!existsSync16(dbPath)) {
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 (!existsSync16(reviewersDir)) {
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 = parseYaml5(configYaml) ?? {};
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 = join9(reviewersDir, `${reviewerName}.md`);
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
- writeFileSync11(promptPath, promptBytes);
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 = spawnSync6(editor, [path2], { stdio: "inherit" });
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 existsSync17 } from "fs";
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 (!existsSync17(configPath)) {
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 spawnSync7 } from "child_process";
6045
+ import { spawnSync as spawnSync8 } from "child_process";
5354
6046
 
5355
6047
  // src/lib/version.ts
5356
- import { readFileSync as readFileSync10 } from "fs";
5357
- import { dirname as dirname5, join as join10 } from "path";
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 = readFileSync10(join10(dir, "package.json"), "utf8");
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 = spawnSync7("npm", ["view", PKG_NAME, "version"], {
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 = spawnSync7(
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 spawnSync8 } from "child_process";
6141
+ import { execFileSync as execFileSync2, spawnSync as spawnSync9 } from "child_process";
5450
6142
  function loadConfigAtSha(sha, repoRoot) {
5451
- const result = spawnSync8(
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 <name>").description(
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
  );