@themoltnet/legreffier 0.32.0 → 0.32.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 (2) hide show
  1. package/dist/index.js +126 -6
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -8084,6 +8084,28 @@ async function writeEnvFile(opts) {
8084
8084
  await writeFile(envPath, outputLines.join("\n") + "\n", "utf-8");
8085
8085
  }
8086
8086
  /**
8087
+ * Update a single managed key in an existing env file.
8088
+ * If the key exists, its value is replaced; otherwise appended.
8089
+ */
8090
+ async function updateEnvVar(envDir, key, value) {
8091
+ const envPath = join(envDir, "env");
8092
+ let content;
8093
+ try {
8094
+ content = await readFile(envPath, "utf-8");
8095
+ } catch {
8096
+ content = "";
8097
+ }
8098
+ const line = `${key}=${q(value)}`;
8099
+ const keyPrefix = `${key}=`;
8100
+ const lines = content === "" ? [] : content.split("\n");
8101
+ const index = lines.findIndex((l) => l.startsWith(keyPrefix));
8102
+ if (index !== -1) {
8103
+ lines[index] = line;
8104
+ content = lines.join("\n");
8105
+ } else content = content.endsWith("\n") ? content + line + "\n" : content + "\n" + line + "\n";
8106
+ await writeFile(envPath, content, "utf-8");
8107
+ }
8108
+ /**
8087
8109
  * Resolve the human operator's git identity from global git config.
8088
8110
  * Must be called BEFORE GIT_CONFIG_GLOBAL is set (so it reads the
8089
8111
  * human's config, not the agent's).
@@ -8529,10 +8551,41 @@ async function writeTokenCache(cachePath, token, expiresAt) {
8529
8551
  } catch {}
8530
8552
  }
8531
8553
  /**
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.
8554
+ * List all installations of this GitHub App and return the one whose
8555
+ * `account.login` matches the given owner (case-insensitive).
8556
+ *
8557
+ * Uses the App JWT (not an installation token), so it works even when
8558
+ * `installation_id` is missing or stale.
8535
8559
  */
8560
+ async function findInstallationForOwner(opts) {
8561
+ const privateKeyPem = await readFile(opts.privateKeyPath, "utf-8");
8562
+ const jwt = createAppJWT(opts.appId, privateKeyPem);
8563
+ const ownerLower = opts.owner.toLowerCase();
8564
+ let nextUrl = "https://api.github.com/app/installations?per_page=100";
8565
+ let pageCount = 0;
8566
+ const MAX_PAGES = 10;
8567
+ while (nextUrl && pageCount < MAX_PAGES) {
8568
+ pageCount++;
8569
+ const res = await fetch(nextUrl, { headers: {
8570
+ Authorization: `Bearer ${jwt}`,
8571
+ Accept: "application/vnd.github+json",
8572
+ "X-GitHub-Api-Version": "2022-11-28"
8573
+ } });
8574
+ if (!res.ok) throw new Error(`GitHub API error listing installations (${res.status}): ${await res.text()}`);
8575
+ const match = (await res.json()).find((i) => i.account?.login.toLowerCase() === ownerLower);
8576
+ if (match) return { installationId: String(match.id) };
8577
+ const linkHeader = res.headers.get("link");
8578
+ nextUrl = linkHeader ? parseNextLinkHeader(linkHeader) : null;
8579
+ }
8580
+ return null;
8581
+ }
8582
+ function parseNextLinkHeader(header) {
8583
+ for (const part of header.split(",")) {
8584
+ const match = part.match(/<([^>]+)>\s*;\s*rel="next"/);
8585
+ if (match) return match[1];
8586
+ }
8587
+ return null;
8588
+ }
8536
8589
  async function getInstallationToken(opts) {
8537
8590
  const cachePath = join(dirname(opts.privateKeyPath), "gh-token-cache.json");
8538
8591
  const cached = await readTokenCache(cachePath);
@@ -9554,6 +9607,62 @@ async function runPortDiaryPhase(opts) {
9554
9607
  };
9555
9608
  }
9556
9609
  //#endregion
9610
+ //#region src/phases/portResolveInstallation.ts
9611
+ /**
9612
+ * Resolve the correct `installation_id` for the target owner.
9613
+ *
9614
+ * When porting a config across orgs the source `installation_id` is scoped
9615
+ * to the original account. This phase uses the App JWT to list all
9616
+ * installations and find the one matching the target owner, then updates
9617
+ * `moltnet.json` if it differs.
9618
+ */
9619
+ async function runPortResolveInstallationPhase(opts) {
9620
+ const { targetDir, config, currentRepo } = opts;
9621
+ if (!currentRepo) return {
9622
+ status: "skipped",
9623
+ message: "unable to determine target repo — skipping installation_id resolution",
9624
+ installationId: config.github?.installation_id ?? ""
9625
+ };
9626
+ if (!config.github?.app_id || !config.github?.private_key_path) return {
9627
+ status: "skipped",
9628
+ message: "github.app_id or private_key_path missing — cannot resolve",
9629
+ installationId: config.github?.installation_id ?? ""
9630
+ };
9631
+ const targetOwner = currentRepo.split("/")[0];
9632
+ let result;
9633
+ try {
9634
+ result = await findInstallationForOwner({
9635
+ appId: config.github.app_id,
9636
+ privateKeyPath: config.github.private_key_path,
9637
+ owner: targetOwner
9638
+ });
9639
+ } catch (err) {
9640
+ return {
9641
+ status: "skipped",
9642
+ message: `could not list app installations: ${err.message}`,
9643
+ installationId: config.github?.installation_id ?? ""
9644
+ };
9645
+ }
9646
+ if (!result) return {
9647
+ status: "not-installed",
9648
+ message: `GitHub App is not installed on ${targetOwner} — install it first`,
9649
+ installationId: config.github?.installation_id ?? ""
9650
+ };
9651
+ const oldId = config.github.installation_id;
9652
+ if (oldId === result.installationId) return {
9653
+ status: "unchanged",
9654
+ message: `installation_id ${oldId} already matches ${targetOwner}`,
9655
+ installationId: oldId
9656
+ };
9657
+ await updateConfigSection("github", { installation_id: result.installationId }, targetDir);
9658
+ if (opts.envPrefix) await updateEnvVar(targetDir, `${opts.envPrefix}_GITHUB_APP_INSTALLATION_ID`, result.installationId);
9659
+ return {
9660
+ status: "updated",
9661
+ message: `installation_id updated: ${oldId || "(empty)"} → ${result.installationId} (${targetOwner})`,
9662
+ installationId: result.installationId
9663
+ };
9664
+ }
9665
+ //#endregion
9557
9666
  //#region src/phases/portRewrite.ts
9558
9667
  /**
9559
9668
  * Rewrite absolute paths in the ported `moltnet.json` so they point to
@@ -9777,6 +9886,16 @@ function PortApp({ name, agents, sourceDir, targetRepoDir, diaryMode, apiUrl })
9777
9886
  });
9778
9887
  filesWritten.push(rewriteResult.gitConfigPath);
9779
9888
  filesWritten.push(join(targetDir, "env"));
9889
+ setPhase("resolving_installation");
9890
+ const currentRepo = detectCurrentRepo(targetRepoDir);
9891
+ const prefix = toEnvPrefix(name);
9892
+ const resolveResult = await runPortResolveInstallationPhase({
9893
+ targetDir,
9894
+ config: await readConfig(targetDir) ?? config,
9895
+ currentRepo: currentRepo ?? void 0,
9896
+ envPrefix: prefix
9897
+ });
9898
+ if (resolveResult.status === "not-installed" || resolveResult.status === "skipped") warnings.push(resolveResult.message);
9780
9899
  setPhase("diary");
9781
9900
  const diaryResult = await runPortDiaryPhase({
9782
9901
  targetDir,
@@ -9787,14 +9906,14 @@ function PortApp({ name, agents, sourceDir, targetRepoDir, diaryMode, apiUrl })
9787
9906
  const adapterOpts = {
9788
9907
  repoDir: targetRepoDir,
9789
9908
  agentName: name,
9790
- prefix: toEnvPrefix(name),
9909
+ prefix,
9791
9910
  mcpUrl: config.endpoints?.mcp ?? apiUrl.replace("://api.", "://mcp.") + "/mcp",
9792
9911
  clientId: config.oauth2.client_id,
9793
9912
  clientSecret: config.oauth2.client_secret,
9794
9913
  appSlug: config.github?.app_slug ?? "",
9795
9914
  appId: config.github?.app_id ?? "",
9796
9915
  pemPath: join(targetDir, basename(config.github?.private_key_path ?? "")),
9797
- installationId: config.github?.installation_id ?? ""
9916
+ installationId: resolveResult.installationId
9798
9917
  };
9799
9918
  for (const agentType of agents) {
9800
9919
  const adapter = adapters[agentType];
@@ -9810,7 +9929,7 @@ function PortApp({ name, agents, sourceDir, targetRepoDir, diaryMode, apiUrl })
9810
9929
  setPhase("verifying");
9811
9930
  const verifyResult = await runPortVerifyInstallationPhase({
9812
9931
  config: await readConfig(targetDir) ?? config,
9813
- currentRepo: detectCurrentRepo(targetRepoDir) ?? void 0
9932
+ currentRepo: currentRepo ?? void 0
9814
9933
  });
9815
9934
  if (verifyResult.status !== "ok") warnings.push(verifyResult.message);
9816
9935
  setSummary({
@@ -9852,6 +9971,7 @@ function PortApp({ name, agents, sourceDir, targetRepoDir, diaryMode, apiUrl })
9852
9971
  validating: `Validating source .moltnet/${name}...`,
9853
9972
  copying: `Copying private material...`,
9854
9973
  rewriting: `Rewriting paths in moltnet.json...`,
9974
+ resolving_installation: `Resolving GitHub App installation for target org...`,
9855
9975
  diary: `Configuring diary (${diaryMode})...`,
9856
9976
  agent_setup: `Installing agent files for ${agents.join(", ")}...`,
9857
9977
  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.1",
4
4
  "description": "LeGreffier — attribution and measured memory for AI coding agents.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -32,10 +32,10 @@
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",
36
+ "@moltnet/crypto-service": "0.1.0",
37
37
  "@themoltnet/design-system": "0.5.1",
38
- "@themoltnet/github-agent": "0.23.0",
38
+ "@themoltnet/github-agent": "0.23.1",
39
39
  "@themoltnet/sdk": "0.89.0"
40
40
  },
41
41
  "scripts": {