@themoltnet/legreffier 0.28.1 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +55 -8
  2. package/dist/index.js +1009 -74
  3. package/package.json +7 -6
package/dist/index.js CHANGED
@@ -1,13 +1,15 @@
1
1
  #!/usr/bin/env node
2
- import { parseArgs } from "node:util";
2
+ import { statSync } from "node:fs";
3
+ import { parseArgs, parseEnv } from "node:util";
3
4
  import { Box, Text, render, useApp, useInput } from "ink";
4
- import { execFileSync } from "node:child_process";
5
- import { dirname, join } from "node:path";
5
+ import { execFileSync, execSync } from "node:child_process";
6
+ import { basename, dirname, join } from "node:path";
6
7
  import { useEffect, useReducer, useRef, useState } from "react";
7
8
  import { jsx, jsxs } from "react/jsx-runtime";
8
9
  import figlet from "figlet";
10
+ import { createSign } from "node:crypto";
9
11
  import { createHash, randomBytes } from "crypto";
10
- import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
12
+ import { access, chmod, copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
11
13
  import { homedir } from "node:os";
12
14
  import { parse, stringify } from "smol-toml";
13
15
  import open from "open";
@@ -408,14 +410,14 @@ function CliHero({ animated = false }) {
408
410
  /* @__PURE__ */ jsx(Text, { children: " " }),
409
411
  /* @__PURE__ */ jsxs(Text, { children: [
410
412
  " ",
411
- /* @__PURE__ */ jsx(Text, {
413
+ /* @__PURE__ */ jsxs(Text, {
412
414
  color: cliTheme.color.text,
413
- children: "Accountable AI commits. "
415
+ children: ["Attribution for AI coding agents.", " "]
414
416
  }),
415
417
  /* @__PURE__ */ jsx(Text, {
416
418
  color: cliTheme.color.accent,
417
419
  bold: true,
418
- children: "Cryptographic identity."
420
+ children: "Identity, memory, signed rationale."
419
421
  })
420
422
  ] }),
421
423
  /* @__PURE__ */ jsxs(Text, { children: [
@@ -7192,6 +7194,13 @@ async function writeMcpConfig(mcpConfig, dir) {
7192
7194
  }
7193
7195
  //#endregion
7194
7196
  //#region ../../libs/sdk/src/credentials.ts
7197
+ /**
7198
+ * Derive the MCP URL from an API URL.
7199
+ * e.g. "https://api.themolt.net" → "https://mcp.themolt.net/mcp"
7200
+ */
7201
+ function deriveMcpUrl(apiUrl) {
7202
+ return apiUrl.replace("://api.", "://mcp.") + "/mcp";
7203
+ }
7195
7204
  function getConfigDir() {
7196
7205
  return join(homedir(), ".config", "moltnet");
7197
7206
  }
@@ -7241,6 +7250,119 @@ async function updateConfigSection(section, data, configDir) {
7241
7250
  await writeConfig(config, configDir);
7242
7251
  }
7243
7252
  //#endregion
7253
+ //#region ../../libs/sdk/src/repair.ts
7254
+ /**
7255
+ * Validate and optionally repair a MoltNet config.
7256
+ *
7257
+ * Checks required fields, detects stale file paths, and migrates
7258
+ * `credentials.json` to `moltnet.json` when found.
7259
+ *
7260
+ * Pass `dryRun: true` to report issues without writing changes.
7261
+ */
7262
+ async function repairConfig(opts) {
7263
+ const dir = opts?.configDir ?? getConfigDir();
7264
+ const issues = [];
7265
+ let config = await tryReadJson(join(dir, "moltnet.json"));
7266
+ if (!config) {
7267
+ const legacy = await tryReadJson(join(dir, "credentials.json"));
7268
+ if (legacy) {
7269
+ config = legacy;
7270
+ issues.push({
7271
+ field: "file",
7272
+ problem: "using deprecated credentials.json — will migrate to moltnet.json",
7273
+ action: "migrate"
7274
+ });
7275
+ if (!opts?.dryRun) await writeConfig(config, dir);
7276
+ } else return {
7277
+ issues: [],
7278
+ config: null
7279
+ };
7280
+ }
7281
+ validateConfig(config, issues);
7282
+ await checkFilePaths(config, issues);
7283
+ if (!config.endpoints.mcp && config.endpoints.api) {
7284
+ config.endpoints.mcp = deriveMcpUrl(config.endpoints.api);
7285
+ issues.push({
7286
+ field: "endpoints.mcp",
7287
+ problem: "missing — derived from API endpoint",
7288
+ action: "fixed"
7289
+ });
7290
+ }
7291
+ if (issues.some((i) => i.action === "fixed") && !opts?.dryRun) await writeConfig(config, dir);
7292
+ return {
7293
+ issues,
7294
+ config
7295
+ };
7296
+ }
7297
+ function validateConfig(config, issues) {
7298
+ if (!config.identity_id) issues.push({
7299
+ field: "identity_id",
7300
+ problem: "missing",
7301
+ action: "warning"
7302
+ });
7303
+ if (!config.keys.public_key) issues.push({
7304
+ field: "keys.public_key",
7305
+ problem: "missing",
7306
+ action: "warning"
7307
+ });
7308
+ if (!config.keys.private_key) issues.push({
7309
+ field: "keys.private_key",
7310
+ problem: "missing",
7311
+ action: "warning"
7312
+ });
7313
+ if (config.keys.public_key && !config.keys.public_key.startsWith("ed25519:")) issues.push({
7314
+ field: "keys.public_key",
7315
+ problem: "missing 'ed25519:' prefix",
7316
+ action: "warning"
7317
+ });
7318
+ if (!config.endpoints.api) issues.push({
7319
+ field: "endpoints.api",
7320
+ problem: "missing",
7321
+ action: "warning"
7322
+ });
7323
+ if (config.github?.app_id && !/^\d+$/.test(config.github.app_id)) issues.push({
7324
+ field: "github.app_id",
7325
+ problem: `not a numeric GitHub App ID (got "${config.github.app_id}") — likely the slug; refetch via \`moltnet config init-from-env\` or re-run \`legreffier\``,
7326
+ action: "warning"
7327
+ });
7328
+ }
7329
+ async function checkFilePaths(config, issues) {
7330
+ const checks = [];
7331
+ if (config.ssh?.private_key_path) checks.push({
7332
+ field: "ssh.private_key_path",
7333
+ path: config.ssh.private_key_path
7334
+ });
7335
+ if (config.ssh?.public_key_path) checks.push({
7336
+ field: "ssh.public_key_path",
7337
+ path: config.ssh.public_key_path
7338
+ });
7339
+ if (config.git?.config_path) checks.push({
7340
+ field: "git.config_path",
7341
+ path: config.git.config_path
7342
+ });
7343
+ if (config.github?.private_key_path) checks.push({
7344
+ field: "github.private_key_path",
7345
+ path: config.github.private_key_path
7346
+ });
7347
+ for (const { field, path } of checks) try {
7348
+ await access(path);
7349
+ } catch {
7350
+ issues.push({
7351
+ field,
7352
+ problem: `file not found: ${path}`,
7353
+ action: "warning"
7354
+ });
7355
+ }
7356
+ }
7357
+ async function tryReadJson(path) {
7358
+ try {
7359
+ const content = await readFile(path, "utf-8");
7360
+ return JSON.parse(content);
7361
+ } catch {
7362
+ return null;
7363
+ }
7364
+ }
7365
+ //#endregion
7244
7366
  //#region ../../libs/sdk/src/ssh.ts
7245
7367
  /**
7246
7368
  * Export the agent's Ed25519 key pair as OpenSSH key files.
@@ -7410,20 +7532,61 @@ async function downloadSkills(repoDir, skillDir) {
7410
7532
  }
7411
7533
  }
7412
7534
  }
7413
- function buildGhTokenRule(agentName) {
7535
+ function buildGhTokenRule() {
7414
7536
  return [
7415
- "# GitHub CLI Authentication (LeGreffier)",
7537
+ "# GitHub CLI Authentication (MoltNet agents)",
7416
7538
  "",
7417
- `When \`GIT_CONFIG_GLOBAL\` is set to \`.moltnet/${agentName}/gitconfig\`,`,
7418
- "authenticate `gh` CLI commands as the GitHub App by prefixing them with:",
7539
+ "> **STRICT RULE read this before every `gh` call.**",
7540
+ ">",
7541
+ "> When `GIT_CONFIG_GLOBAL` is set (matches `.moltnet/<agent>/gitconfig`), you",
7542
+ "> **MUST NOT** run bare `gh <command>`. You **MUST** prefix every `gh` call",
7543
+ "> with a `GH_TOKEN` resolved from an **absolute path** to `moltnet.json`.",
7544
+ "> Running bare `gh` silently falls back to the human personal token and",
7545
+ "> attributes the action to the wrong identity — this is a correctness bug,",
7546
+ "> not a warning.",
7547
+ "",
7548
+ "## The only correct form",
7419
7549
  "",
7420
7550
  "```bash",
7421
- "GH_TOKEN=$(npx @themoltnet/cli github token --credentials \"$(dirname \"$GIT_CONFIG_GLOBAL\")/moltnet.json\") gh <command>",
7551
+ "# 1. Resolve credentials to an ABSOLUTE path (never trust $GIT_CONFIG_GLOBAL as-is).",
7552
+ "CREDS=\"$(cd \"$(dirname \"$GIT_CONFIG_GLOBAL\")\" 2>/dev/null && pwd)/moltnet.json\"",
7553
+ "",
7554
+ "# 2. Refuse to proceed if the file does not exist at that absolute path.",
7555
+ "[ -f \"$CREDS\" ] || { echo \"FATAL: moltnet.json not found at $CREDS\" >&2; exit 1; }",
7556
+ "",
7557
+ "# 3. Call gh with GH_TOKEN inlined.",
7558
+ "GH_TOKEN=$(npx @themoltnet/cli github token --credentials \"$CREDS\") gh <command>",
7422
7559
  "```",
7423
7560
  "",
7424
- "The token is cached locally (~1 hour lifetime, 5-min expiry buffer),",
7561
+ "The credentials file (`moltnet.json`) always lives next to the `gitconfig`",
7562
+ "inside the same `.moltnet/<agent>/` directory, regardless of which agent is",
7563
+ "active. The token is cached locally (~1 hour lifetime, 5-min expiry buffer),",
7425
7564
  "so repeated calls are fast after the first API hit.",
7426
7565
  "",
7566
+ "## Why absolute paths are mandatory",
7567
+ "",
7568
+ "`GIT_CONFIG_GLOBAL` is almost always a **relative path** (e.g. `.moltnet/<agent>/gitconfig`).",
7569
+ "Every git worktree has a different CWD from the main worktree root, so",
7570
+ "`$(dirname \"$GIT_CONFIG_GLOBAL\")` resolves differently depending on where you are.",
7571
+ "When it resolves to a non-existent directory:",
7572
+ "",
7573
+ "- `npx @themoltnet/cli github token` prints `no credentials found` to stderr,",
7574
+ "- the command substitution yields an empty `GH_TOKEN`,",
7575
+ "- `gh` silently falls back to your personal token,",
7576
+ "- the resulting API call is attributed to the **human**, not the agent.",
7577
+ "",
7578
+ "This failure is invisible in normal output. The `cd ... && pwd` dance in step 1",
7579
+ "is the only reliable way to get an absolute path that works across worktrees.",
7580
+ "",
7581
+ "## Forbidden patterns",
7582
+ "",
7583
+ "- `gh <command>` — bare, no `GH_TOKEN`. **Never.**",
7584
+ "- `GH_TOKEN=$(... --credentials \"$(dirname \"$GIT_CONFIG_GLOBAL\")/moltnet.json\") gh ...`",
7585
+ " — uses the raw relative path. Breaks in worktrees.",
7586
+ "- `GH_TOKEN=$(... --credentials \"./moltnet.json\") gh ...` — relative. Breaks.",
7587
+ "- `GH_TOKEN=$(... --credentials \"~/.moltnet/...\") gh ...` — `~` is not expanded",
7588
+ " inside double quotes; use `$HOME` or the literal absolute path.",
7589
+ "",
7427
7590
  "## Allowed `gh` subcommands",
7428
7591
  "",
7429
7592
  "The GitHub App only has these permissions:",
@@ -7517,6 +7680,28 @@ function buildCodexRules(_agentName) {
7517
7680
  " decision = \"allow\",",
7518
7681
  ")",
7519
7682
  "",
7683
+ "# GitHub CLI — read-only subcommands (write ops prompt the user)",
7684
+ "prefix_rule(",
7685
+ " pattern = [\"gh\", \"pr\", \"view\"],",
7686
+ " decision = \"allow\",",
7687
+ ")",
7688
+ "prefix_rule(",
7689
+ " pattern = [\"gh\", \"pr\", \"list\"],",
7690
+ " decision = \"allow\",",
7691
+ ")",
7692
+ "prefix_rule(",
7693
+ " pattern = [\"gh\", \"issue\", \"view\"],",
7694
+ " decision = \"allow\",",
7695
+ ")",
7696
+ "prefix_rule(",
7697
+ " pattern = [\"gh\", \"issue\", \"list\"],",
7698
+ " decision = \"allow\",",
7699
+ ")",
7700
+ "prefix_rule(",
7701
+ " pattern = [\"gh\", \"repo\", \"view\"],",
7702
+ " decision = \"allow\",",
7703
+ ")",
7704
+ "",
7520
7705
  "# Worktree symlink creation",
7521
7706
  "prefix_rule(",
7522
7707
  " pattern = [\"ln\", \"-s\"],",
@@ -7559,7 +7744,7 @@ function toEnvPrefix(agentName) {
7559
7744
  return agentName.toUpperCase().replace(/[^A-Z0-9]/g, "_");
7560
7745
  }
7561
7746
  /** Merge agent env vars into .claude/settings.local.json, preserving existing entries. */
7562
- async function writeSettingsLocal({ repoDir, agentName, appSlug, pemPath, installationId, clientId, clientSecret }) {
7747
+ async function writeSettingsLocal({ repoDir, agentName, appId, pemPath, installationId, clientId, clientSecret }) {
7563
7748
  const dir = join(repoDir, ".claude");
7564
7749
  await mkdir(dir, { recursive: true });
7565
7750
  const filePath = join(dir, "settings.local.json");
@@ -7582,7 +7767,7 @@ async function writeSettingsLocal({ repoDir, agentName, appSlug, pemPath, instal
7582
7767
  },
7583
7768
  env: {
7584
7769
  ...existing.env,
7585
- [`${prefix}_GITHUB_APP_ID`]: appSlug,
7770
+ [`${prefix}_GITHUB_APP_ID`]: appId,
7586
7771
  [`${prefix}_GITHUB_APP_PRIVATE_KEY_PATH`]: pemPath,
7587
7772
  [`${prefix}_GITHUB_APP_INSTALLATION_ID`]: installationId,
7588
7773
  [`${prefix}_CLIENT_ID`]: clientId,
@@ -7612,7 +7797,7 @@ var ClaudeAdapter = class {
7612
7797
  await writeSettingsLocal({
7613
7798
  repoDir: opts.repoDir,
7614
7799
  agentName: opts.agentName,
7615
- appSlug: opts.appSlug,
7800
+ appId: opts.appId,
7616
7801
  pemPath: opts.pemPath,
7617
7802
  installationId: opts.installationId,
7618
7803
  clientId: opts.clientId,
@@ -7622,7 +7807,7 @@ var ClaudeAdapter = class {
7622
7807
  async writeRules(opts) {
7623
7808
  const dir = join(opts.repoDir, ".claude", "rules");
7624
7809
  await mkdir(dir, { recursive: true });
7625
- await writeFile(join(dir, "legreffier-gh.md"), buildGhTokenRule(opts.agentName), "utf-8");
7810
+ await writeFile(join(dir, "legreffier-gh.md"), buildGhTokenRule(), "utf-8");
7626
7811
  }
7627
7812
  };
7628
7813
  //#endregion
@@ -7672,6 +7857,13 @@ var adapters = {
7672
7857
  };
7673
7858
  //#endregion
7674
7859
  //#region src/env-file.ts
7860
+ /**
7861
+ * Parse a dotenv-format string using Node.js built-in `util.parseEnv`.
7862
+ * Handles quoting, comments, and blank lines.
7863
+ */
7864
+ function parseEnvFile(content) {
7865
+ return parseEnv(content);
7866
+ }
7675
7867
  function q(v) {
7676
7868
  return `'${v.replace(/'/g, "'\\''")}'`;
7677
7869
  }
@@ -7686,10 +7878,11 @@ async function writeEnvFile(opts) {
7686
7878
  const managedEntries = [
7687
7879
  [`${opts.prefix}_CLIENT_ID`, q(opts.clientId)],
7688
7880
  [`${opts.prefix}_CLIENT_SECRET`, q(opts.clientSecret)],
7689
- [`${opts.prefix}_GITHUB_APP_ID`, q(opts.appSlug)],
7881
+ [`${opts.prefix}_GITHUB_APP_ID`, q(opts.appId)],
7690
7882
  [`${opts.prefix}_GITHUB_APP_PRIVATE_KEY_PATH`, q(opts.pemPath)],
7691
7883
  [`${opts.prefix}_GITHUB_APP_INSTALLATION_ID`, q(opts.installationId)],
7692
- ["GIT_CONFIG_GLOBAL", q(`.moltnet/${opts.agentName}/gitconfig`)]
7884
+ ["GIT_CONFIG_GLOBAL", q(`.moltnet/${opts.agentName}/gitconfig`)],
7885
+ ["MOLTNET_AGENT_NAME", q(opts.agentName)]
7693
7886
  ];
7694
7887
  const managedKeys = new Set(managedEntries.map(([k]) => k));
7695
7888
  let existingLines = [];
@@ -7746,7 +7939,7 @@ async function clearState(configDir) {
7746
7939
  //#endregion
7747
7940
  //#region src/phases/agentSetup.ts
7748
7941
  async function runAgentSetupPhase(opts) {
7749
- const { apiUrl, repoDir, configDir, agentName, agentTypes, publicKey, fingerprint, appSlug, pemPath, installationId, identityId, clientId, clientSecret, dispatch } = opts;
7942
+ const { apiUrl, repoDir, configDir, agentName, agentTypes, publicKey, fingerprint, appId, appSlug, pemPath, installationId, identityId, clientId, clientSecret, org, dispatch } = opts;
7750
7943
  dispatch({
7751
7944
  type: "phase",
7752
7945
  phase: "agent_setup"
@@ -7769,10 +7962,11 @@ async function runAgentSetupPhase(opts) {
7769
7962
  mcp: apiUrl.replace("://api.", "://mcp.") + "/mcp"
7770
7963
  },
7771
7964
  github: {
7772
- app_id: appSlug,
7965
+ app_id: appId,
7773
7966
  app_slug: appSlug,
7774
7967
  installation_id: installationId,
7775
- private_key_path: pemPath
7968
+ private_key_path: pemPath,
7969
+ ...org ? { org } : {}
7776
7970
  }
7777
7971
  }, configDir);
7778
7972
  const prefix = toEnvPrefix(agentName);
@@ -7784,6 +7978,7 @@ async function runAgentSetupPhase(opts) {
7784
7978
  clientId,
7785
7979
  clientSecret,
7786
7980
  appSlug,
7981
+ appId,
7787
7982
  pemPath,
7788
7983
  installationId
7789
7984
  };
@@ -7819,7 +8014,7 @@ async function runAgentSetupPhase(opts) {
7819
8014
  prefix,
7820
8015
  clientId,
7821
8016
  clientSecret,
7822
- appSlug,
8017
+ appId,
7823
8018
  pemPath,
7824
8019
  installationId
7825
8020
  });
@@ -7878,35 +8073,6 @@ async function suggestAppNames(appName) {
7878
8073
  available: await checkAppNameAvailable(name)
7879
8074
  })))).filter((r) => r.available).map((r) => r.name);
7880
8075
  }
7881
- /**
7882
- * Look up GitHub bot user and derive noreply email.
7883
- * Tries <appSlug>[bot] first (exists post-installation), then falls back to
7884
- * plain <appSlug> (exists right after app creation, pre-installation).
7885
- *
7886
- * Retries with exponential backoff because GitHub's public /users API
7887
- * may not index a newly created app account immediately.
7888
- */
7889
- async function lookupBotUser(appSlug, { maxRetries = 5, baseDelayMs = 2e3 } = {}) {
7890
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
7891
- for (const username of [`${appSlug}[bot]`, appSlug]) {
7892
- const res = await fetch(`https://api.github.com/users/${encodeURIComponent(username)}`, { headers: GITHUB_HEADERS });
7893
- if (res.ok) {
7894
- const data = await res.json();
7895
- return {
7896
- id: data.id,
7897
- email: `${data.id}+${data.login}@users.noreply.github.com`
7898
- };
7899
- }
7900
- }
7901
- if (attempt < maxRetries) {
7902
- const delayMs = baseDelayMs * 2 ** attempt;
7903
- await new Promise((resolve) => {
7904
- setTimeout(resolve, delayMs);
7905
- });
7906
- }
7907
- }
7908
- throw new Error(`GitHub user lookup failed for app "${appSlug}"`);
7909
- }
7910
8076
  /** Write GitHub App PEM to <configDir>/<appSlug>.pem (mode 0o600). */
7911
8077
  async function writePem(pem, appSlug, configDir) {
7912
8078
  await mkdir(configDir, { recursive: true });
@@ -7918,17 +8084,21 @@ async function writePem(pem, appSlug, configDir) {
7918
8084
  /**
7919
8085
  * Write a standalone gitconfig file to <configDir>/gitconfig and return
7920
8086
  * its path. The config sets user.name/email and enables SSH commit signing
7921
- * using the agent's SSH key.
8087
+ * using the agent's SSH public key.
8088
+ *
8089
+ * **Important:** `signingkey` must live under `[user]`, not `[gpg "ssh"]`.
8090
+ * Git only reads `user.signingkey`; a key declared as `gpg.ssh.signingkey`
8091
+ * is silently ignored and `git commit -S` fails with
8092
+ * `fatal: either user.signingkey or gpg.ssh.defaultKeyCommand needs to be configured`.
7922
8093
  */
7923
- async function writeGitConfig({ configDir, name, email, sshKeyPath }) {
8094
+ async function writeGitConfig({ configDir, name, email, sshPublicKeyPath }) {
7924
8095
  const content = [
7925
8096
  "[user]",
7926
8097
  `\tname = ${name}`,
7927
8098
  `\temail = ${email}`,
8099
+ `\tsigningkey = ${sshPublicKeyPath}`,
7928
8100
  "[gpg]",
7929
8101
  " format = ssh",
7930
- "[gpg \"ssh\"]",
7931
- `\tsigningKey = ${sshKeyPath}`,
7932
8102
  "[commit]",
7933
8103
  " gpgsign = true",
7934
8104
  ""
@@ -7954,6 +8124,7 @@ async function runGithubAppPhase(opts) {
7954
8124
  appSlug: existingConfig.github.app_slug ?? ""
7955
8125
  });
7956
8126
  return {
8127
+ appId: existingConfig.github.app_id,
7957
8128
  appSlug: existingConfig.github.app_slug ?? "",
7958
8129
  pemPath: existingConfig.github.private_key_path,
7959
8130
  installationId: existingConfig.github.installation_id,
@@ -7972,6 +8143,7 @@ async function runGithubAppPhase(opts) {
7972
8143
  appSlug: existingState.appSlug
7973
8144
  });
7974
8145
  return {
8146
+ appId: existingState.appId,
7975
8147
  appSlug: existingState.appSlug,
7976
8148
  pemPath,
7977
8149
  installationId: "",
@@ -8023,6 +8195,7 @@ async function runGithubAppPhase(opts) {
8023
8195
  status: "done"
8024
8196
  });
8025
8197
  return {
8198
+ appId: ghCreds.appId,
8026
8199
  appSlug: ghCreds.appSlug,
8027
8200
  pemPath,
8028
8201
  installationId: "",
@@ -8030,6 +8203,136 @@ async function runGithubAppPhase(opts) {
8030
8203
  };
8031
8204
  }
8032
8205
  //#endregion
8206
+ //#region ../github-agent/src/bot-user.ts
8207
+ var GITHUB_API_BASE_URL = "https://api.github.com";
8208
+ /**
8209
+ * Look up the shadow bot user associated with a GitHub App.
8210
+ * Every GitHub App gets a bot user account (`<slug>[bot]`).
8211
+ * This endpoint is public — no authentication required.
8212
+ *
8213
+ * Tries `<appSlug>[bot]` first (exists post-installation), then falls
8214
+ * back to plain `<appSlug>` (exists right after app creation, pre-install).
8215
+ *
8216
+ * @returns The bot user ID and login
8217
+ */
8218
+ async function lookupBotUser(appSlug, opts = {}) {
8219
+ const { apiBaseUrl = GITHUB_API_BASE_URL, maxRetries = 0, baseDelayMs = 2e3 } = opts;
8220
+ const headers = {
8221
+ Accept: "application/vnd.github+json",
8222
+ "X-GitHub-Api-Version": "2022-11-28"
8223
+ };
8224
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
8225
+ for (const username of [`${appSlug}[bot]`, appSlug]) {
8226
+ const url = `${apiBaseUrl}/users/${encodeURIComponent(username)}`;
8227
+ const response = await fetch(url, { headers });
8228
+ if (response.ok) {
8229
+ const data = await response.json();
8230
+ return {
8231
+ id: data.id,
8232
+ login: data.login
8233
+ };
8234
+ }
8235
+ }
8236
+ if (attempt < maxRetries) {
8237
+ const delayMs = baseDelayMs * 2 ** attempt;
8238
+ await new Promise((resolve) => {
8239
+ setTimeout(resolve, delayMs);
8240
+ });
8241
+ }
8242
+ }
8243
+ throw new Error(`GitHub user lookup failed for app "${appSlug}"`);
8244
+ }
8245
+ /**
8246
+ * Build the GitHub noreply email for a bot user.
8247
+ * Format: `<bot-user-id>+<slug>[bot]@users.noreply.github.com`
8248
+ */
8249
+ function buildBotEmail(botUserId, appSlug) {
8250
+ return `${botUserId}+${appSlug}[bot]@users.noreply.github.com`;
8251
+ }
8252
+ //#endregion
8253
+ //#region ../github-agent/src/token.ts
8254
+ /**
8255
+ * Create a JWT signed with the GitHub App's RSA private key.
8256
+ */
8257
+ function createAppJWT(appId, privateKeyPem) {
8258
+ const now = Math.floor(Date.now() / 1e3);
8259
+ const header = Buffer.from(JSON.stringify({
8260
+ alg: "RS256",
8261
+ typ: "JWT"
8262
+ })).toString("base64url");
8263
+ const payload = Buffer.from(JSON.stringify({
8264
+ iss: appId,
8265
+ iat: now - 60,
8266
+ exp: now + 600
8267
+ })).toString("base64url");
8268
+ const sign = createSign("RSA-SHA256");
8269
+ sign.update(`${header}.${payload}`);
8270
+ return `${header}.${payload}.${sign.sign(privateKeyPem, "base64url")}`;
8271
+ }
8272
+ /** Minimum remaining validity before we consider a cached token expired. */
8273
+ var EXPIRY_BUFFER_MS = 300 * 1e3;
8274
+ /**
8275
+ * Read a cached token from disk. Returns null if missing, corrupt, or expired.
8276
+ */
8277
+ async function readTokenCache(cachePath) {
8278
+ try {
8279
+ const raw = await readFile(cachePath, "utf-8");
8280
+ const cached = JSON.parse(raw);
8281
+ if (!cached.token || !cached.expires_at) return null;
8282
+ const expiresAt = new Date(cached.expires_at).getTime();
8283
+ if (Date.now() + EXPIRY_BUFFER_MS >= expiresAt) return null;
8284
+ return {
8285
+ token: cached.token,
8286
+ expiresAt: cached.expires_at
8287
+ };
8288
+ } catch {
8289
+ return null;
8290
+ }
8291
+ }
8292
+ /**
8293
+ * Write a token to the cache file (best-effort).
8294
+ */
8295
+ async function writeTokenCache(cachePath, token, expiresAt) {
8296
+ const cache = {
8297
+ token,
8298
+ expires_at: expiresAt
8299
+ };
8300
+ try {
8301
+ await writeFile(cachePath, JSON.stringify(cache), { mode: 384 });
8302
+ } catch {}
8303
+ }
8304
+ /**
8305
+ * Exchange a GitHub App JWT for an installation access token.
8306
+ * Uses a file-based cache next to the private key to avoid
8307
+ * hitting the GitHub API on every call.
8308
+ */
8309
+ async function getInstallationToken(opts) {
8310
+ const cachePath = join(dirname(opts.privateKeyPath), "gh-token-cache.json");
8311
+ const cached = await readTokenCache(cachePath);
8312
+ if (cached) return cached;
8313
+ const privateKeyPem = await readFile(opts.privateKeyPath, "utf-8");
8314
+ const jwt = createAppJWT(opts.appId, privateKeyPem);
8315
+ const response = await fetch(`https://api.github.com/app/installations/${opts.installationId}/access_tokens`, {
8316
+ method: "POST",
8317
+ headers: {
8318
+ Authorization: `Bearer ${jwt}`,
8319
+ Accept: "application/vnd.github+json",
8320
+ "X-GitHub-Api-Version": "2022-11-28"
8321
+ }
8322
+ });
8323
+ if (!response.ok) {
8324
+ const body = await response.text();
8325
+ throw new Error(`GitHub API error (${response.status}): ${body}`);
8326
+ }
8327
+ const data = await response.json();
8328
+ const result = {
8329
+ token: data.token,
8330
+ expiresAt: data.expires_at
8331
+ };
8332
+ await writeTokenCache(cachePath, result.token, result.expiresAt);
8333
+ return result;
8334
+ }
8335
+ //#endregion
8033
8336
  //#region src/phases/gitSetup.ts
8034
8337
  async function runGitSetupPhase(opts) {
8035
8338
  const { configDir, agentName, appSlug, dispatch } = opts;
@@ -8050,19 +8353,18 @@ async function runGitSetupPhase(opts) {
8050
8353
  key: "gitSetup",
8051
8354
  status: "running"
8052
8355
  });
8053
- const { privatePath } = await exportSSHKey({ configDir });
8054
- const botUser = await lookupBotUser(appSlug);
8055
- const gitConfigPath = await writeGitConfig({
8056
- configDir,
8057
- name: agentName,
8058
- email: botUser.email,
8059
- sshKeyPath: privatePath
8060
- });
8356
+ const { publicPath } = await exportSSHKey({ configDir });
8357
+ const email = buildBotEmail((await lookupBotUser(appSlug, { maxRetries: 5 })).id, appSlug);
8061
8358
  await updateConfigSection("git", {
8062
8359
  name: agentName,
8063
- email: botUser.email,
8360
+ email,
8064
8361
  signing: true,
8065
- config_path: gitConfigPath
8362
+ config_path: await writeGitConfig({
8363
+ configDir,
8364
+ name: agentName,
8365
+ email,
8366
+ sshPublicKeyPath: publicPath
8367
+ })
8066
8368
  }, configDir);
8067
8369
  dispatch({
8068
8370
  type: "step",
@@ -8073,7 +8375,7 @@ async function runGitSetupPhase(opts) {
8073
8375
  //#endregion
8074
8376
  //#region src/phases/identity.ts
8075
8377
  async function runIdentityPhase(opts) {
8076
- const { apiUrl, agentName, configDir, dispatch } = opts;
8378
+ const { apiUrl, agentName, configDir, org, dispatch } = opts;
8077
8379
  const existingConfig = await readConfig(configDir);
8078
8380
  const existingState = await readState(configDir);
8079
8381
  if (existingConfig?.keys?.public_key && existingConfig?.oauth2?.client_id) {
@@ -8180,7 +8482,8 @@ async function runIdentityPhase(opts) {
8180
8482
  const started = await startOnboarding(apiUrl, {
8181
8483
  publicKey: kp.publicKey,
8182
8484
  fingerprint: kp.fingerprint,
8183
- agentName
8485
+ agentName,
8486
+ ...org ? { org } : {}
8184
8487
  });
8185
8488
  await writeState({
8186
8489
  workflowId: started.workflowId,
@@ -8572,7 +8875,7 @@ function ProgressPhase({ state, name, showManifestFallback, showInstallFallback
8572
8875
  ]
8573
8876
  });
8574
8877
  }
8575
- function InitApp({ name, agents: agentsProp, apiUrl, dir = process.cwd() }) {
8878
+ function InitApp({ name, agents: agentsProp, apiUrl, dir = process.cwd(), org }) {
8576
8879
  const { exit } = useApp();
8577
8880
  const [state, dispatch] = useReducer(uiReducer, {
8578
8881
  phase: "disclaimer",
@@ -8614,6 +8917,7 @@ function InitApp({ name, agents: agentsProp, apiUrl, dir = process.cwd() }) {
8614
8917
  apiUrl,
8615
8918
  agentName: name,
8616
8919
  configDir,
8920
+ org,
8617
8921
  dispatch
8618
8922
  });
8619
8923
  const githubApp = await runGithubAppPhase({
@@ -8648,12 +8952,14 @@ function InitApp({ name, agents: agentsProp, apiUrl, dir = process.cwd() }) {
8648
8952
  agentTypes: selectedAgents,
8649
8953
  publicKey: identity.publicKey,
8650
8954
  fingerprint: identity.fingerprint,
8955
+ appId: githubApp.appId,
8651
8956
  appSlug: githubApp.appSlug,
8652
8957
  pemPath: githubApp.pemPath,
8653
8958
  installationId: installation.installationId || githubApp.installationId,
8654
8959
  identityId: installation.identityId,
8655
8960
  clientId: installation.clientId || identity.clientId,
8656
8961
  clientSecret: installation.clientSecret || identity.clientSecret,
8962
+ org,
8657
8963
  dispatch
8658
8964
  });
8659
8965
  const mcpUrl = apiUrl.replace("://api.", "://mcp.") + "/mcp";
@@ -8707,6 +9013,580 @@ function InitApp({ name, agents: agentsProp, apiUrl, dir = process.cwd() }) {
8707
9013
  });
8708
9014
  }
8709
9015
  //#endregion
9016
+ //#region src/phases/portValidate.ts
9017
+ /**
9018
+ * Validate a source `.moltnet/<agent>/` directory for porting.
9019
+ *
9020
+ * Runs the generic `repairConfig({ dryRun: true })` checks, then adds
9021
+ * port-specific blocking checks:
9022
+ * - `identity_id`, `keys.fingerprint`, `oauth2.client_id/secret`
9023
+ * - `github.app_id` present and numeric, `github.app_slug`, `github.installation_id`
9024
+ * - `ssh.private_key_path`, `ssh.public_key_path`, `git.config_path` set
9025
+ * - `github.private_key_path` set
9026
+ * - All four absolute paths (ssh priv/pub, git config, github pem) exist on disk
9027
+ *
9028
+ * Throws if `moltnet.json` is missing or unreadable — nothing to port.
9029
+ */
9030
+ async function runPortValidatePhase(opts) {
9031
+ const { sourceDir } = opts;
9032
+ const config = await readConfig(sourceDir);
9033
+ if (!config) throw new Error(`No moltnet.json found in ${sourceDir} — nothing to port. Run \`legreffier\` on a repo first to create a source identity.`);
9034
+ const { issues: baseIssues } = await repairConfig({
9035
+ configDir: sourceDir,
9036
+ dryRun: true
9037
+ });
9038
+ const issues = [...baseIssues];
9039
+ if (!config.oauth2?.client_id) issues.push({
9040
+ field: "oauth2.client_id",
9041
+ problem: "missing — required for port",
9042
+ action: "warning"
9043
+ });
9044
+ if (!config.oauth2?.client_secret) issues.push({
9045
+ field: "oauth2.client_secret",
9046
+ problem: "missing — required for port",
9047
+ action: "warning"
9048
+ });
9049
+ if (!config.keys?.fingerprint) issues.push({
9050
+ field: "keys.fingerprint",
9051
+ problem: "missing — required for port",
9052
+ action: "warning"
9053
+ });
9054
+ if (!config.github?.app_id) issues.push({
9055
+ field: "github.app_id",
9056
+ problem: "missing — required for port",
9057
+ action: "warning"
9058
+ });
9059
+ if (!config.github?.app_slug) issues.push({
9060
+ field: "github.app_slug",
9061
+ problem: "missing — required for port (used for PEM filename and bot lookup)",
9062
+ action: "warning"
9063
+ });
9064
+ if (!config.github?.installation_id) issues.push({
9065
+ field: "github.installation_id",
9066
+ problem: "missing — required for port",
9067
+ action: "warning"
9068
+ });
9069
+ if (!config.github?.private_key_path) issues.push({
9070
+ field: "github.private_key_path",
9071
+ problem: "missing — required for port",
9072
+ action: "warning"
9073
+ });
9074
+ if (!config.ssh?.private_key_path) issues.push({
9075
+ field: "ssh.private_key_path",
9076
+ problem: "missing — required for port",
9077
+ action: "warning"
9078
+ });
9079
+ if (!config.ssh?.public_key_path) issues.push({
9080
+ field: "ssh.public_key_path",
9081
+ problem: "missing — required for port",
9082
+ action: "warning"
9083
+ });
9084
+ if (!config.git?.config_path) issues.push({
9085
+ field: "git.config_path",
9086
+ problem: "missing — required for port",
9087
+ action: "warning"
9088
+ });
9089
+ return {
9090
+ config,
9091
+ issues,
9092
+ canProceed: issues.filter((i) => i.action === "warning").length === 0
9093
+ };
9094
+ }
9095
+ /** Check whether a file is readable. Used by portCopy for optional files. */
9096
+ async function fileExists(path) {
9097
+ try {
9098
+ await access(path);
9099
+ return true;
9100
+ } catch {
9101
+ return false;
9102
+ }
9103
+ }
9104
+ //#endregion
9105
+ //#region src/phases/portCopy.ts
9106
+ /**
9107
+ * Copy private material from a source `.moltnet/<agent>/` into the target.
9108
+ *
9109
+ * Copies:
9110
+ * - `moltnet.json` (0600) — will later be rewritten in P3 with absolute paths
9111
+ * - GitHub App PEM (0600) — at `<sourceDir>/<appSlug>.pem` by convention
9112
+ * - SSH private key (0600) and public key (0644)
9113
+ * - `allowed_signers` if present (0644) — optional, warning only
9114
+ *
9115
+ * Assumes `runPortValidatePhase` has been run and `canProceed` was true,
9116
+ * so required fields and files are known to exist.
9117
+ */
9118
+ async function runPortCopyPhase(opts) {
9119
+ const { sourceDir, targetDir, config } = opts;
9120
+ const copied = [];
9121
+ const warnings = [];
9122
+ await mkdir(targetDir, { recursive: true });
9123
+ const targetConfig = join(targetDir, "moltnet.json");
9124
+ await copyFile(join(sourceDir, "moltnet.json"), targetConfig);
9125
+ await chmod(targetConfig, 384);
9126
+ copied.push(targetConfig);
9127
+ if (!config.github?.private_key_path) throw new Error("github.private_key_path missing — run portValidate first");
9128
+ const targetPem = join(targetDir, basename(config.github.private_key_path));
9129
+ await copyFile(config.github.private_key_path, targetPem);
9130
+ await chmod(targetPem, 384);
9131
+ copied.push(targetPem);
9132
+ if (!config.ssh?.private_key_path || !config.ssh?.public_key_path) throw new Error("ssh key paths missing — run portValidate first");
9133
+ const sshDir = join(targetDir, "ssh");
9134
+ await mkdir(sshDir, { recursive: true });
9135
+ const targetSshPriv = join(sshDir, basename(config.ssh.private_key_path));
9136
+ await copyFile(config.ssh.private_key_path, targetSshPriv);
9137
+ await chmod(targetSshPriv, 384);
9138
+ copied.push(targetSshPriv);
9139
+ const targetSshPub = join(sshDir, basename(config.ssh.public_key_path));
9140
+ await copyFile(config.ssh.public_key_path, targetSshPub);
9141
+ await chmod(targetSshPub, 420);
9142
+ copied.push(targetSshPub);
9143
+ const sourceAllowed = join(dirname(config.ssh.private_key_path), "allowed_signers");
9144
+ if (await fileExists(sourceAllowed)) {
9145
+ const targetAllowed = join(sshDir, "allowed_signers");
9146
+ await copyFile(sourceAllowed, targetAllowed);
9147
+ await chmod(targetAllowed, 420);
9148
+ copied.push(targetAllowed);
9149
+ } else warnings.push(`allowed_signers not found at ${sourceAllowed} — skipping (optional)`);
9150
+ return {
9151
+ copied,
9152
+ warnings
9153
+ };
9154
+ }
9155
+ //#endregion
9156
+ //#region src/phases/portDiary.ts
9157
+ /**
9158
+ * Standard UUID v4-ish shape. Diary IDs are server-issued UUIDs; anything
9159
+ * else in this field is either stale data, a mis-parsed env line, or a
9160
+ * crafted injection attempt (e.g. embedded newlines) and must be rejected
9161
+ * before we echo it into the target env file.
9162
+ */
9163
+ var DIARY_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9164
+ /**
9165
+ * Read `MOLTNET_DIARY_ID` from a source env file.
9166
+ * Returns null if the file or key is absent, or if the stored value is
9167
+ * not a valid UUID (defensive: never propagate malformed data).
9168
+ */
9169
+ async function readSourceDiaryId(sourceDir) {
9170
+ try {
9171
+ const raw = parseEnvFile(await readFile(join(sourceDir, "env"), "utf-8")).MOLTNET_DIARY_ID;
9172
+ if (!raw || !DIARY_ID_RE.test(raw)) return null;
9173
+ return raw;
9174
+ } catch {
9175
+ return null;
9176
+ }
9177
+ }
9178
+ /**
9179
+ * Apply the chosen diary mode to the target env file:
9180
+ *
9181
+ * - `reuse`: persist the source MOLTNET_DIARY_ID in the target env
9182
+ * - `new`: strip MOLTNET_DIARY_ID from the target env (legreffier skill
9183
+ * will resolve it at session start via `diaries_list` / create)
9184
+ * - `skip`: leave the target env untouched
9185
+ *
9186
+ * Assumes the target env file has already been written by `portRewrite`.
9187
+ */
9188
+ async function runPortDiaryPhase(opts) {
9189
+ const { targetDir, mode, sourceDiaryId } = opts;
9190
+ const envPath = join(targetDir, "env");
9191
+ if (mode === "skip") return {
9192
+ mode,
9193
+ diaryId: null,
9194
+ modified: false
9195
+ };
9196
+ let content = "";
9197
+ try {
9198
+ content = await readFile(envPath, "utf-8");
9199
+ } catch {
9200
+ return {
9201
+ mode,
9202
+ diaryId: null,
9203
+ modified: false
9204
+ };
9205
+ }
9206
+ const lines = content.split("\n");
9207
+ const diaryLineRe = /^\s*MOLTNET_DIARY_ID\s*=/;
9208
+ const filtered = lines.filter((l) => !diaryLineRe.test(l));
9209
+ const strippedExisting = filtered.length !== lines.length;
9210
+ if (mode === "reuse") {
9211
+ if (!sourceDiaryId) return {
9212
+ mode,
9213
+ diaryId: null,
9214
+ modified: false
9215
+ };
9216
+ if (!DIARY_ID_RE.test(sourceDiaryId)) throw new Error(`invalid sourceDiaryId: ${JSON.stringify(sourceDiaryId)} — expected UUID`);
9217
+ const diaryLine = `MOLTNET_DIARY_ID='${sourceDiaryId}'`;
9218
+ const gitCfgIdx = filtered.findIndex((l) => /^\s*GIT_CONFIG_GLOBAL\s*=/.test(l));
9219
+ if (gitCfgIdx >= 0) filtered.splice(gitCfgIdx + 1, 0, diaryLine);
9220
+ else filtered.unshift(diaryLine);
9221
+ await writeFile(envPath, filtered.join("\n"), { mode: 384 });
9222
+ return {
9223
+ mode,
9224
+ diaryId: sourceDiaryId,
9225
+ modified: true
9226
+ };
9227
+ }
9228
+ if (strippedExisting) await writeFile(envPath, filtered.join("\n"), { mode: 384 });
9229
+ return {
9230
+ mode,
9231
+ diaryId: null,
9232
+ modified: strippedExisting
9233
+ };
9234
+ }
9235
+ //#endregion
9236
+ //#region src/phases/portRewrite.ts
9237
+ /**
9238
+ * Rewrite absolute paths in the ported `moltnet.json` so they point to
9239
+ * target locations, then regenerate the gitconfig and env file.
9240
+ *
9241
+ * Assumes `portCopy` already placed files at:
9242
+ * - `<targetDir>/moltnet.json`
9243
+ * - `<targetDir>/<appSlug>.pem`
9244
+ * - `<targetDir>/ssh/<basename(ssh.private_key_path)>`
9245
+ * - `<targetDir>/ssh/<basename(ssh.public_key_path)>`
9246
+ *
9247
+ * The ported config still has the *source* absolute paths — this phase
9248
+ * rewrites them to the target absolute paths, then writes the gitconfig
9249
+ * (which needs the new ssh key path) and the env file (which needs the
9250
+ * new PEM path).
9251
+ */
9252
+ async function runPortRewritePhase(opts) {
9253
+ const { targetDir, agentName, config } = opts;
9254
+ if (!config.ssh || !config.git || !config.github) throw new Error("config missing ssh/git/github sections — run portValidate first");
9255
+ const newSshPriv = join(targetDir, "ssh", basename(config.ssh.private_key_path));
9256
+ const newSshPub = join(targetDir, "ssh", basename(config.ssh.public_key_path));
9257
+ const newPem = join(targetDir, basename(config.github.private_key_path));
9258
+ const newGitConfig = join(targetDir, "gitconfig");
9259
+ await updateConfigSection("ssh", {
9260
+ private_key_path: newSshPriv,
9261
+ public_key_path: newSshPub
9262
+ }, targetDir);
9263
+ await updateConfigSection("github", {
9264
+ app_id: config.github.app_id,
9265
+ app_slug: config.github.app_slug,
9266
+ installation_id: config.github.installation_id,
9267
+ private_key_path: newPem,
9268
+ ...config.github.org ? { org: config.github.org } : {}
9269
+ }, targetDir);
9270
+ await updateConfigSection("git", {
9271
+ name: config.git.name,
9272
+ email: config.git.email,
9273
+ signing: config.git.signing,
9274
+ config_path: newGitConfig
9275
+ }, targetDir);
9276
+ const rewrittenFields = [
9277
+ "ssh.private_key_path",
9278
+ "ssh.public_key_path",
9279
+ "github.private_key_path",
9280
+ "git.config_path"
9281
+ ];
9282
+ await writeGitConfig({
9283
+ configDir: targetDir,
9284
+ name: config.git.name,
9285
+ email: config.git.email,
9286
+ sshPublicKeyPath: newSshPub
9287
+ });
9288
+ await writeEnvFile({
9289
+ envDir: targetDir,
9290
+ agentName,
9291
+ prefix: toEnvPrefix(agentName),
9292
+ clientId: config.oauth2.client_id,
9293
+ clientSecret: config.oauth2.client_secret,
9294
+ appId: config.github.app_id,
9295
+ pemPath: newPem,
9296
+ installationId: config.github.installation_id
9297
+ });
9298
+ return {
9299
+ configPath: join(targetDir, "moltnet.json"),
9300
+ rewrittenFields,
9301
+ gitConfigPath: newGitConfig,
9302
+ envDir: targetDir
9303
+ };
9304
+ }
9305
+ //#endregion
9306
+ //#region src/phases/portVerifyInstallation.ts
9307
+ /**
9308
+ * Warning-only check: can the ported GitHub App installation reach the
9309
+ * repo the port command is running against?
9310
+ *
9311
+ * Mints an installation token via github-agent, then calls
9312
+ * GET /installation/repositories. Never blocks — returns a warning
9313
+ * object the TUI renders. Any failure (bad token, network, missing
9314
+ * currentRepo) is downgraded to a warning.
9315
+ */
9316
+ async function runPortVerifyInstallationPhase(opts) {
9317
+ const { config, currentRepo, apiBaseUrl = "https://api.github.com" } = opts;
9318
+ if (!currentRepo) return {
9319
+ status: "warning",
9320
+ message: "unable to determine current repo (git remote missing) — skipping installation scope check"
9321
+ };
9322
+ if (!config.github?.app_id || !config.github?.installation_id || !config.github?.private_key_path) return {
9323
+ status: "warning",
9324
+ message: "github.app_id / installation_id / private_key_path missing",
9325
+ currentRepo
9326
+ };
9327
+ let token;
9328
+ try {
9329
+ token = (await getInstallationToken({
9330
+ appId: config.github.app_id,
9331
+ privateKeyPath: config.github.private_key_path,
9332
+ installationId: config.github.installation_id
9333
+ })).token;
9334
+ } catch (err) {
9335
+ return {
9336
+ status: "warning",
9337
+ message: `could not mint installation token: ${err.message}`,
9338
+ currentRepo
9339
+ };
9340
+ }
9341
+ const accessible = [];
9342
+ let nextUrl = `${apiBaseUrl}/installation/repositories?per_page=100`;
9343
+ let pageCount = 0;
9344
+ const MAX_PAGES = 20;
9345
+ while (nextUrl && pageCount < MAX_PAGES) {
9346
+ pageCount++;
9347
+ let res;
9348
+ try {
9349
+ res = await fetch(nextUrl, { headers: {
9350
+ Accept: "application/vnd.github+json",
9351
+ "X-GitHub-Api-Version": "2022-11-28",
9352
+ Authorization: `Bearer ${token}`
9353
+ } });
9354
+ } catch (err) {
9355
+ return {
9356
+ status: "warning",
9357
+ message: `installation check network error: ${err.message}`,
9358
+ currentRepo
9359
+ };
9360
+ }
9361
+ if (!res.ok) return {
9362
+ status: "warning",
9363
+ message: `installation check failed (${res.status})`,
9364
+ currentRepo
9365
+ };
9366
+ const data = await res.json();
9367
+ if (data.repository_selection === "all") return {
9368
+ status: "ok",
9369
+ message: "installation has access to all repos on the account",
9370
+ currentRepo,
9371
+ repositorySelection: "all"
9372
+ };
9373
+ for (const r of data.repositories) accessible.push(r.full_name);
9374
+ if (accessible.includes(currentRepo)) break;
9375
+ nextUrl = parseNextLink(res.headers.get("link"));
9376
+ }
9377
+ if (accessible.includes(currentRepo)) return {
9378
+ status: "ok",
9379
+ message: `installation has access to ${currentRepo}`,
9380
+ currentRepo,
9381
+ repositorySelection: "selected",
9382
+ accessibleRepos: accessible
9383
+ };
9384
+ const truncated = pageCount >= MAX_PAGES && nextUrl !== null;
9385
+ const truncatedNote = truncated ? " (scan truncated after " + MAX_PAGES + " pages — result may be stale)" : "";
9386
+ return {
9387
+ status: "repo-not-in-scope",
9388
+ message: `installation is scoped to ${accessible.length}${truncated ? "+" : ""} repo(s) but does not include ${currentRepo}. Add the repo at https://github.com/settings/installations/${config.github.installation_id}` + truncatedNote,
9389
+ currentRepo,
9390
+ repositorySelection: "selected",
9391
+ accessibleRepos: accessible
9392
+ };
9393
+ }
9394
+ /**
9395
+ * Parse the `Link` header for a `rel="next"` URL. Returns null if absent.
9396
+ * GitHub's Link header format:
9397
+ * <https://api.github.com/...?page=2>; rel="next", <...>; rel="last"
9398
+ */
9399
+ function parseNextLink(header) {
9400
+ if (!header) return null;
9401
+ for (const part of header.split(",")) {
9402
+ const match = part.match(/<([^>]+)>\s*;\s*rel="next"/);
9403
+ if (match) return match[1];
9404
+ }
9405
+ return null;
9406
+ }
9407
+ //#endregion
9408
+ //#region src/PortApp.tsx
9409
+ /** Read `owner/repo` from `git remote get-url origin`. Returns null on any failure. */
9410
+ function detectCurrentRepo(repoDir) {
9411
+ try {
9412
+ const match = execSync("git remote get-url origin", {
9413
+ cwd: repoDir,
9414
+ encoding: "utf-8",
9415
+ stdio: [
9416
+ "ignore",
9417
+ "pipe",
9418
+ "ignore"
9419
+ ]
9420
+ }).trim().match(/github\.com[:/]([^/]+\/[^/]+?)(\.git)?$/);
9421
+ return match ? match[1] : null;
9422
+ } catch {
9423
+ return null;
9424
+ }
9425
+ }
9426
+ function PortApp({ name, agents, sourceDir, targetRepoDir, diaryMode, apiUrl }) {
9427
+ const { exit } = useApp();
9428
+ const [phase, setPhase] = useState("validating");
9429
+ const [error, setError] = useState();
9430
+ const [summary, setSummary] = useState(null);
9431
+ useEffect(() => {
9432
+ (async () => {
9433
+ try {
9434
+ const targetDir = join(targetRepoDir, ".moltnet", name);
9435
+ const filesWritten = [];
9436
+ const warnings = [];
9437
+ setPhase("validating");
9438
+ const { config, issues, canProceed } = await runPortValidatePhase({ sourceDir });
9439
+ if (!canProceed) throw new Error("source .moltnet is not portable: " + issues.map((i) => `${i.field} (${i.problem})`).join(", "));
9440
+ const existing = await readConfig(targetDir);
9441
+ if (existing?.identity_id && existing.identity_id !== config.identity_id) throw new Error(`target ${targetDir} already has a different identity_id (${existing.identity_id}); refusing to overwrite`);
9442
+ setPhase("copying");
9443
+ const copyResult = await runPortCopyPhase({
9444
+ sourceDir,
9445
+ targetDir,
9446
+ config
9447
+ });
9448
+ filesWritten.push(...copyResult.copied);
9449
+ warnings.push(...copyResult.warnings);
9450
+ setPhase("rewriting");
9451
+ const rewriteResult = await runPortRewritePhase({
9452
+ targetDir,
9453
+ agentName: name,
9454
+ config
9455
+ });
9456
+ filesWritten.push(rewriteResult.gitConfigPath);
9457
+ filesWritten.push(join(targetDir, "env"));
9458
+ setPhase("diary");
9459
+ const diaryResult = await runPortDiaryPhase({
9460
+ targetDir,
9461
+ mode: diaryMode,
9462
+ sourceDiaryId: await readSourceDiaryId(sourceDir)
9463
+ });
9464
+ setPhase("agent_setup");
9465
+ const adapterOpts = {
9466
+ repoDir: targetRepoDir,
9467
+ agentName: name,
9468
+ prefix: toEnvPrefix(name),
9469
+ mcpUrl: config.endpoints?.mcp ?? apiUrl.replace("://api.", "://mcp.") + "/mcp",
9470
+ clientId: config.oauth2.client_id,
9471
+ clientSecret: config.oauth2.client_secret,
9472
+ appSlug: config.github?.app_slug ?? "",
9473
+ appId: config.github?.app_id ?? "",
9474
+ pemPath: join(targetDir, basename(config.github?.private_key_path ?? "")),
9475
+ installationId: config.github?.installation_id ?? ""
9476
+ };
9477
+ for (const agentType of agents) {
9478
+ const adapter = adapters[agentType];
9479
+ await adapter.writeMcpConfig(adapterOpts);
9480
+ filesWritten.push(`${agentType}: MCP config`);
9481
+ await adapter.writeSkills(targetRepoDir);
9482
+ filesWritten.push(`${agentType}: skills`);
9483
+ await adapter.writeSettings(adapterOpts);
9484
+ filesWritten.push(`${agentType}: settings`);
9485
+ await adapter.writeRules(adapterOpts);
9486
+ filesWritten.push(`${agentType}: gh token rule`);
9487
+ }
9488
+ setPhase("verifying");
9489
+ const verifyResult = await runPortVerifyInstallationPhase({
9490
+ config: await readConfig(targetDir) ?? config,
9491
+ currentRepo: detectCurrentRepo(targetRepoDir) ?? void 0
9492
+ });
9493
+ if (verifyResult.status !== "ok") warnings.push(verifyResult.message);
9494
+ setSummary({
9495
+ agentName: name,
9496
+ filesWritten,
9497
+ warnings,
9498
+ validationIssues: issues,
9499
+ diaryMode,
9500
+ diaryId: diaryResult.diaryId,
9501
+ installMessage: verifyResult.message,
9502
+ installStatus: verifyResult.status
9503
+ });
9504
+ setPhase("done");
9505
+ setTimeout(() => exit(), 3e3);
9506
+ } catch (err) {
9507
+ setError(toErrorMessage(err));
9508
+ setPhase("error");
9509
+ setTimeout(() => exit(/* @__PURE__ */ new Error("Port failed")), 3e3);
9510
+ }
9511
+ })();
9512
+ }, []);
9513
+ if (phase === "error") return /* @__PURE__ */ jsxs(Box, {
9514
+ flexDirection: "column",
9515
+ paddingY: 1,
9516
+ children: [/* @__PURE__ */ jsx(CliHero, {}), /* @__PURE__ */ jsx(Box, {
9517
+ borderStyle: "round",
9518
+ borderColor: cliTheme.color.error,
9519
+ paddingX: 2,
9520
+ paddingY: 1,
9521
+ children: /* @__PURE__ */ jsx(Text, {
9522
+ color: cliTheme.color.error,
9523
+ bold: true,
9524
+ children: "* Port failed: " + (error ?? "unknown error")
9525
+ })
9526
+ })]
9527
+ });
9528
+ if (phase !== "done") {
9529
+ const labels = {
9530
+ validating: `Validating source .moltnet/${name}...`,
9531
+ copying: `Copying private material...`,
9532
+ rewriting: `Rewriting paths in moltnet.json...`,
9533
+ diary: `Configuring diary (${diaryMode})...`,
9534
+ agent_setup: `Installing agent files for ${agents.join(", ")}...`,
9535
+ verifying: `Verifying GitHub App installation scope...`
9536
+ };
9537
+ return /* @__PURE__ */ jsxs(Box, {
9538
+ flexDirection: "column",
9539
+ paddingY: 1,
9540
+ children: [/* @__PURE__ */ jsx(CliHero, {}), /* @__PURE__ */ jsx(CliSpinner, { label: labels[phase] })]
9541
+ });
9542
+ }
9543
+ return /* @__PURE__ */ jsxs(Box, {
9544
+ flexDirection: "column",
9545
+ paddingY: 1,
9546
+ children: [
9547
+ /* @__PURE__ */ jsx(CliHero, {}),
9548
+ /* @__PURE__ */ jsxs(Box, {
9549
+ flexDirection: "column",
9550
+ marginBottom: 1,
9551
+ children: [
9552
+ /* @__PURE__ */ jsx(Text, {
9553
+ color: cliTheme.color.success,
9554
+ bold: true,
9555
+ children: `Ported ${name} to ${targetRepoDir}`
9556
+ }),
9557
+ /* @__PURE__ */ jsx(Text, {
9558
+ color: cliTheme.color.muted,
9559
+ children: ` diary: ${summary?.diaryMode}${summary?.diaryId ? ` (${summary.diaryId})` : ""}`
9560
+ }),
9561
+ /* @__PURE__ */ jsx(Text, {
9562
+ color: cliTheme.color.muted,
9563
+ children: ` installation: ${summary?.installStatus}`
9564
+ }),
9565
+ summary?.filesWritten.map((f, i) => /* @__PURE__ */ jsx(Text, {
9566
+ color: cliTheme.color.muted,
9567
+ children: " * " + f
9568
+ }, i))
9569
+ ]
9570
+ }),
9571
+ summary && summary.warnings.length > 0 && /* @__PURE__ */ jsxs(Box, {
9572
+ borderStyle: "round",
9573
+ borderColor: cliTheme.color.warning,
9574
+ paddingX: 2,
9575
+ paddingY: 0,
9576
+ flexDirection: "column",
9577
+ children: [/* @__PURE__ */ jsx(Text, {
9578
+ color: cliTheme.color.warning,
9579
+ bold: true,
9580
+ children: "Warnings:"
9581
+ }), summary.warnings.map((w, i) => /* @__PURE__ */ jsx(Text, {
9582
+ color: cliTheme.color.warning,
9583
+ children: " ! " + w
9584
+ }, i))]
9585
+ })
9586
+ ]
9587
+ });
9588
+ }
9589
+ //#endregion
8710
9590
  //#region src/SetupApp.tsx
8711
9591
  function SetupApp({ name, agents: agentsProp, apiUrl, dir }) {
8712
9592
  const { exit } = useApp();
@@ -8732,6 +9612,7 @@ function SetupApp({ name, agents: agentsProp, apiUrl, dir }) {
8732
9612
  clientId: config.oauth2.client_id,
8733
9613
  clientSecret: config.oauth2.client_secret,
8734
9614
  appSlug: config.github?.app_slug ?? config.github?.app_id ?? "",
9615
+ appId: config.github?.app_id ?? "",
8735
9616
  pemPath: config.github?.private_key_path ?? "",
8736
9617
  installationId: config.github?.installation_id ?? ""
8737
9618
  };
@@ -8835,7 +9716,13 @@ var { values, positionals } = parseArgs({
8835
9716
  multiple: true
8836
9717
  },
8837
9718
  "api-url": { type: "string" },
8838
- dir: { type: "string" }
9719
+ dir: { type: "string" },
9720
+ org: {
9721
+ type: "string",
9722
+ short: "o"
9723
+ },
9724
+ from: { type: "string" },
9725
+ diary: { type: "string" }
8839
9726
  }
8840
9727
  });
8841
9728
  var subcommand = positionals[0] ?? "init";
@@ -8843,6 +9730,13 @@ var name = values["name"];
8843
9730
  var agentFlags = values["agent"] ?? [];
8844
9731
  var apiUrl = values["api-url"] ?? process.env.MOLTNET_API_URL ?? "https://api.themolt.net";
8845
9732
  var dir = values["dir"] ?? process.cwd();
9733
+ var org = values["org"];
9734
+ var fromDir = values["from"];
9735
+ var diaryModeArg = values["diary"];
9736
+ if (diaryModeArg !== void 0 && subcommand !== "port") {
9737
+ process.stderr.write(`Error: --diary is only valid for \`legreffier port\` (got subcommand "${subcommand}")\n`);
9738
+ process.exit(1);
9739
+ }
8846
9740
  if (subcommand === "github" && positionals[1] === "token") try {
8847
9741
  printGitHubToken(resolveAgentName(name, process.env.GIT_CONFIG_GLOBAL), dir);
8848
9742
  process.exit(0);
@@ -8851,7 +9745,7 @@ if (subcommand === "github" && positionals[1] === "token") try {
8851
9745
  process.exit(1);
8852
9746
  }
8853
9747
  if (!name) {
8854
- const usage = subcommand === "setup" ? "Usage: legreffier setup --name <agent-name> [--agent claude] [--agent codex] [--dir <path>]" : "Usage: legreffier [init] --name <agent-name> [--agent claude] [--agent codex] [--api-url <url>] [--dir <path>]";
9748
+ const usage = subcommand === "setup" ? "Usage: legreffier setup --name <agent-name> [--agent claude] [--agent codex] [--dir <path>]" : subcommand === "port" ? "Usage: legreffier port --name <agent-name> --from <path/to/source/.moltnet/<agent>> [--agent claude] [--agent codex] [--dir <target-repo>] [--diary new|reuse|skip]" : "Usage: legreffier [init] --name <agent-name> [--agent claude] [--agent codex] [--api-url <url>] [--dir <path>] [--org <github-org>]";
8855
9749
  process.stderr.write(usage + "\n");
8856
9750
  process.exit(1);
8857
9751
  }
@@ -8874,10 +9768,51 @@ else if (subcommand === "init") render(/* @__PURE__ */ jsx(InitApp, {
8874
9768
  name,
8875
9769
  agents: agents.length > 0 ? agents : void 0,
8876
9770
  apiUrl,
8877
- dir
9771
+ dir,
9772
+ org
8878
9773
  }));
8879
- else {
8880
- process.stderr.write(`Unknown subcommand: ${subcommand}. Use "init" or "setup".\n`);
9774
+ else if (subcommand === "port") {
9775
+ if (!fromDir) {
9776
+ process.stderr.write("Error: legreffier port requires --from <path/to/source/.moltnet/<agent>>\n");
9777
+ process.exit(1);
9778
+ }
9779
+ const resolvedDiaryMode = diaryModeArg ?? "new";
9780
+ if (![
9781
+ "new",
9782
+ "reuse",
9783
+ "skip"
9784
+ ].includes(resolvedDiaryMode)) {
9785
+ process.stderr.write(`Error: --diary must be one of: new, reuse, skip (got "${resolvedDiaryMode}")\n`);
9786
+ process.exit(1);
9787
+ }
9788
+ try {
9789
+ if (!statSync(dir).isDirectory()) {
9790
+ process.stderr.write(`Error: --dir "${dir}" is not a directory\n`);
9791
+ process.exit(1);
9792
+ }
9793
+ } catch {
9794
+ process.stderr.write(`Error: --dir "${dir}" does not exist\n`);
9795
+ process.exit(1);
9796
+ }
9797
+ try {
9798
+ if (!statSync(fromDir).isDirectory()) {
9799
+ process.stderr.write(`Error: --from "${fromDir}" is not a directory\n`);
9800
+ process.exit(1);
9801
+ }
9802
+ } catch {
9803
+ process.stderr.write(`Error: --from "${fromDir}" does not exist\n`);
9804
+ process.exit(1);
9805
+ }
9806
+ render(/* @__PURE__ */ jsx(PortApp, {
9807
+ name,
9808
+ agents: agents.length > 0 ? agents : ["claude"],
9809
+ sourceDir: fromDir,
9810
+ targetRepoDir: dir,
9811
+ diaryMode: resolvedDiaryMode,
9812
+ apiUrl
9813
+ }));
9814
+ } else {
9815
+ process.stderr.write(`Unknown subcommand: ${subcommand}. Use "init", "setup", or "port".\n`);
8881
9816
  process.exit(1);
8882
9817
  }
8883
9818
  //#endregion