@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.
- package/README.md +55 -8
- package/dist/index.js +1009 -74
- package/package.json +7 -6
package/dist/index.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
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__ */
|
|
413
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
412
414
|
color: cliTheme.color.text,
|
|
413
|
-
children: "
|
|
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: "
|
|
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(
|
|
7535
|
+
function buildGhTokenRule() {
|
|
7414
7536
|
return [
|
|
7415
|
-
"# GitHub CLI Authentication (
|
|
7537
|
+
"# GitHub CLI Authentication (MoltNet agents)",
|
|
7416
7538
|
"",
|
|
7417
|
-
|
|
7418
|
-
"
|
|
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
|
-
"
|
|
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
|
|
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,
|
|
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`]:
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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 {
|
|
8054
|
-
const
|
|
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
|
|
8360
|
+
email,
|
|
8064
8361
|
signing: true,
|
|
8065
|
-
config_path:
|
|
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
|
-
|
|
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
|