@themoltnet/legreffier 0.32.0 → 0.32.2

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 (2) hide show
  1. package/dist/index.js +141 -13
  2. package/package.json +5 -5
package/dist/index.js CHANGED
@@ -7707,12 +7707,19 @@ function buildGhTokenRule() {
7707
7707
  "",
7708
7708
  "> **STRICT RULE — read this before every `gh` call.**",
7709
7709
  ">",
7710
- "> When `GIT_CONFIG_GLOBAL` is set (matches `.moltnet/<agent>/gitconfig`), you",
7711
- "> **MUST NOT** run bare `gh <command>`. You **MUST** prefix every `gh` call",
7712
- "> with a `GH_TOKEN` resolved from an **absolute path** to `moltnet.json`.",
7713
- "> Running bare `gh` silently falls back to the human personal token and",
7714
- "> attributes the action to the wrong identity — this is a correctness bug,",
7715
- "> not a warning.",
7710
+ "> When `GIT_CONFIG_GLOBAL` is set (matches `.moltnet/<agent>/gitconfig`), the",
7711
+ "> default is: you **MUST NOT** run bare `gh <command>`. You **MUST** prefix",
7712
+ "> every `gh` call with a `GH_TOKEN` resolved from an **absolute path** to",
7713
+ "> `moltnet.json`. Running bare `gh` silently falls back to the human personal",
7714
+ "> token and attributes the action to the wrong identity — this is a",
7715
+ "> correctness bug, not a warning.",
7716
+ ">",
7717
+ "> **Exception — `human` authorship mode**: when `MOLTNET_COMMIT_AUTHORSHIP=human`",
7718
+ "> in `.moltnet/<agent>/env`, `gh pr ...` and `gh issue ...` **must** run bare",
7719
+ "> (no `GH_TOKEN`) so the PR/issue appears as authored by the human. All other",
7720
+ "> `gh` calls (including `gh api repos/.../contents/...`) still require the agent",
7721
+ "> token. `git push` is not a `gh` call and always uses the agent token via the",
7722
+ "> gitconfig-configured credential helper.",
7716
7723
  "",
7717
7724
  "## The only correct form",
7718
7725
  "",
@@ -7749,7 +7756,8 @@ function buildGhTokenRule() {
7749
7756
  "",
7750
7757
  "## Forbidden patterns",
7751
7758
  "",
7752
- "- `gh <command>` — bare, no `GH_TOKEN`. **Never.**",
7759
+ "- `gh <command>` — bare, no `GH_TOKEN`. **Never** (except the `human` mode",
7760
+ " write-op carve-out for `gh pr` / `gh issue` described in the header above).",
7753
7761
  "- `GH_TOKEN=$(... --credentials \"$(dirname \"$GIT_CONFIG_GLOBAL\")/moltnet.json\") gh ...`",
7754
7762
  " — uses the raw relative path. Breaks in worktrees.",
7755
7763
  "- `GH_TOKEN=$(... --credentials \"./moltnet.json\") gh ...` — relative. Breaks.",
@@ -8084,6 +8092,28 @@ async function writeEnvFile(opts) {
8084
8092
  await writeFile(envPath, outputLines.join("\n") + "\n", "utf-8");
8085
8093
  }
8086
8094
  /**
8095
+ * Update a single managed key in an existing env file.
8096
+ * If the key exists, its value is replaced; otherwise appended.
8097
+ */
8098
+ async function updateEnvVar(envDir, key, value) {
8099
+ const envPath = join(envDir, "env");
8100
+ let content;
8101
+ try {
8102
+ content = await readFile(envPath, "utf-8");
8103
+ } catch {
8104
+ content = "";
8105
+ }
8106
+ const line = `${key}=${q(value)}`;
8107
+ const keyPrefix = `${key}=`;
8108
+ const lines = content === "" ? [] : content.split("\n");
8109
+ const index = lines.findIndex((l) => l.startsWith(keyPrefix));
8110
+ if (index !== -1) {
8111
+ lines[index] = line;
8112
+ content = lines.join("\n");
8113
+ } else content = content.endsWith("\n") ? content + line + "\n" : content + "\n" + line + "\n";
8114
+ await writeFile(envPath, content, "utf-8");
8115
+ }
8116
+ /**
8087
8117
  * Resolve the human operator's git identity from global git config.
8088
8118
  * Must be called BEFORE GIT_CONFIG_GLOBAL is set (so it reads the
8089
8119
  * human's config, not the agent's).
@@ -8529,10 +8559,41 @@ async function writeTokenCache(cachePath, token, expiresAt) {
8529
8559
  } catch {}
8530
8560
  }
8531
8561
  /**
8532
- * Exchange a GitHub App JWT for an installation access token.
8533
- * Uses a file-based cache next to the private key to avoid
8534
- * hitting the GitHub API on every call.
8562
+ * List all installations of this GitHub App and return the one whose
8563
+ * `account.login` matches the given owner (case-insensitive).
8564
+ *
8565
+ * Uses the App JWT (not an installation token), so it works even when
8566
+ * `installation_id` is missing or stale.
8535
8567
  */
8568
+ async function findInstallationForOwner(opts) {
8569
+ const privateKeyPem = await readFile(opts.privateKeyPath, "utf-8");
8570
+ const jwt = createAppJWT(opts.appId, privateKeyPem);
8571
+ const ownerLower = opts.owner.toLowerCase();
8572
+ let nextUrl = "https://api.github.com/app/installations?per_page=100";
8573
+ let pageCount = 0;
8574
+ const MAX_PAGES = 10;
8575
+ while (nextUrl && pageCount < MAX_PAGES) {
8576
+ pageCount++;
8577
+ const res = await fetch(nextUrl, { headers: {
8578
+ Authorization: `Bearer ${jwt}`,
8579
+ Accept: "application/vnd.github+json",
8580
+ "X-GitHub-Api-Version": "2022-11-28"
8581
+ } });
8582
+ if (!res.ok) throw new Error(`GitHub API error listing installations (${res.status}): ${await res.text()}`);
8583
+ const match = (await res.json()).find((i) => i.account?.login.toLowerCase() === ownerLower);
8584
+ if (match) return { installationId: String(match.id) };
8585
+ const linkHeader = res.headers.get("link");
8586
+ nextUrl = linkHeader ? parseNextLinkHeader(linkHeader) : null;
8587
+ }
8588
+ return null;
8589
+ }
8590
+ function parseNextLinkHeader(header) {
8591
+ for (const part of header.split(",")) {
8592
+ const match = part.match(/<([^>]+)>\s*;\s*rel="next"/);
8593
+ if (match) return match[1];
8594
+ }
8595
+ return null;
8596
+ }
8536
8597
  async function getInstallationToken(opts) {
8537
8598
  const cachePath = join(dirname(opts.privateKeyPath), "gh-token-cache.json");
8538
8599
  const cached = await readTokenCache(cachePath);
@@ -9554,6 +9615,62 @@ async function runPortDiaryPhase(opts) {
9554
9615
  };
9555
9616
  }
9556
9617
  //#endregion
9618
+ //#region src/phases/portResolveInstallation.ts
9619
+ /**
9620
+ * Resolve the correct `installation_id` for the target owner.
9621
+ *
9622
+ * When porting a config across orgs the source `installation_id` is scoped
9623
+ * to the original account. This phase uses the App JWT to list all
9624
+ * installations and find the one matching the target owner, then updates
9625
+ * `moltnet.json` if it differs.
9626
+ */
9627
+ async function runPortResolveInstallationPhase(opts) {
9628
+ const { targetDir, config, currentRepo } = opts;
9629
+ if (!currentRepo) return {
9630
+ status: "skipped",
9631
+ message: "unable to determine target repo — skipping installation_id resolution",
9632
+ installationId: config.github?.installation_id ?? ""
9633
+ };
9634
+ if (!config.github?.app_id || !config.github?.private_key_path) return {
9635
+ status: "skipped",
9636
+ message: "github.app_id or private_key_path missing — cannot resolve",
9637
+ installationId: config.github?.installation_id ?? ""
9638
+ };
9639
+ const targetOwner = currentRepo.split("/")[0];
9640
+ let result;
9641
+ try {
9642
+ result = await findInstallationForOwner({
9643
+ appId: config.github.app_id,
9644
+ privateKeyPath: config.github.private_key_path,
9645
+ owner: targetOwner
9646
+ });
9647
+ } catch (err) {
9648
+ return {
9649
+ status: "skipped",
9650
+ message: `could not list app installations: ${err.message}`,
9651
+ installationId: config.github?.installation_id ?? ""
9652
+ };
9653
+ }
9654
+ if (!result) return {
9655
+ status: "not-installed",
9656
+ message: `GitHub App is not installed on ${targetOwner} — install it first`,
9657
+ installationId: config.github?.installation_id ?? ""
9658
+ };
9659
+ const oldId = config.github.installation_id;
9660
+ if (oldId === result.installationId) return {
9661
+ status: "unchanged",
9662
+ message: `installation_id ${oldId} already matches ${targetOwner}`,
9663
+ installationId: oldId
9664
+ };
9665
+ await updateConfigSection("github", { installation_id: result.installationId }, targetDir);
9666
+ if (opts.envPrefix) await updateEnvVar(targetDir, `${opts.envPrefix}_GITHUB_APP_INSTALLATION_ID`, result.installationId);
9667
+ return {
9668
+ status: "updated",
9669
+ message: `installation_id updated: ${oldId || "(empty)"} → ${result.installationId} (${targetOwner})`,
9670
+ installationId: result.installationId
9671
+ };
9672
+ }
9673
+ //#endregion
9557
9674
  //#region src/phases/portRewrite.ts
9558
9675
  /**
9559
9676
  * Rewrite absolute paths in the ported `moltnet.json` so they point to
@@ -9777,6 +9894,16 @@ function PortApp({ name, agents, sourceDir, targetRepoDir, diaryMode, apiUrl })
9777
9894
  });
9778
9895
  filesWritten.push(rewriteResult.gitConfigPath);
9779
9896
  filesWritten.push(join(targetDir, "env"));
9897
+ setPhase("resolving_installation");
9898
+ const currentRepo = detectCurrentRepo(targetRepoDir);
9899
+ const prefix = toEnvPrefix(name);
9900
+ const resolveResult = await runPortResolveInstallationPhase({
9901
+ targetDir,
9902
+ config: await readConfig(targetDir) ?? config,
9903
+ currentRepo: currentRepo ?? void 0,
9904
+ envPrefix: prefix
9905
+ });
9906
+ if (resolveResult.status === "not-installed" || resolveResult.status === "skipped") warnings.push(resolveResult.message);
9780
9907
  setPhase("diary");
9781
9908
  const diaryResult = await runPortDiaryPhase({
9782
9909
  targetDir,
@@ -9787,14 +9914,14 @@ function PortApp({ name, agents, sourceDir, targetRepoDir, diaryMode, apiUrl })
9787
9914
  const adapterOpts = {
9788
9915
  repoDir: targetRepoDir,
9789
9916
  agentName: name,
9790
- prefix: toEnvPrefix(name),
9917
+ prefix,
9791
9918
  mcpUrl: config.endpoints?.mcp ?? apiUrl.replace("://api.", "://mcp.") + "/mcp",
9792
9919
  clientId: config.oauth2.client_id,
9793
9920
  clientSecret: config.oauth2.client_secret,
9794
9921
  appSlug: config.github?.app_slug ?? "",
9795
9922
  appId: config.github?.app_id ?? "",
9796
9923
  pemPath: join(targetDir, basename(config.github?.private_key_path ?? "")),
9797
- installationId: config.github?.installation_id ?? ""
9924
+ installationId: resolveResult.installationId
9798
9925
  };
9799
9926
  for (const agentType of agents) {
9800
9927
  const adapter = adapters[agentType];
@@ -9810,7 +9937,7 @@ function PortApp({ name, agents, sourceDir, targetRepoDir, diaryMode, apiUrl })
9810
9937
  setPhase("verifying");
9811
9938
  const verifyResult = await runPortVerifyInstallationPhase({
9812
9939
  config: await readConfig(targetDir) ?? config,
9813
- currentRepo: detectCurrentRepo(targetRepoDir) ?? void 0
9940
+ currentRepo: currentRepo ?? void 0
9814
9941
  });
9815
9942
  if (verifyResult.status !== "ok") warnings.push(verifyResult.message);
9816
9943
  setSummary({
@@ -9852,6 +9979,7 @@ function PortApp({ name, agents, sourceDir, targetRepoDir, diaryMode, apiUrl })
9852
9979
  validating: `Validating source .moltnet/${name}...`,
9853
9980
  copying: `Copying private material...`,
9854
9981
  rewriting: `Rewriting paths in moltnet.json...`,
9982
+ resolving_installation: `Resolving GitHub App installation for target org...`,
9855
9983
  diary: `Configuring diary (${diaryMode})...`,
9856
9984
  agent_setup: `Installing agent files for ${agents.join(", ")}...`,
9857
9985
  verifying: `Verifying GitHub App installation scope...`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@themoltnet/legreffier",
3
- "version": "0.32.0",
3
+ "version": "0.32.2",
4
4
  "description": "LeGreffier — attribution and measured memory for AI coding agents.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -32,11 +32,11 @@
32
32
  "typescript": "^5.3.3",
33
33
  "vite": "^8.0.0",
34
34
  "vitest": "^3.0.0",
35
- "@moltnet/crypto-service": "0.1.0",
36
35
  "@moltnet/api-client": "0.1.0",
37
- "@themoltnet/design-system": "0.5.1",
38
- "@themoltnet/github-agent": "0.23.0",
39
- "@themoltnet/sdk": "0.89.0"
36
+ "@moltnet/crypto-service": "0.1.0",
37
+ "@themoltnet/github-agent": "0.23.2",
38
+ "@themoltnet/sdk": "0.91.0",
39
+ "@themoltnet/design-system": "0.6.0"
40
40
  },
41
41
  "scripts": {
42
42
  "dev": "vite build --watch",