replen 1.5.4 → 1.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/commands.js CHANGED
@@ -568,7 +568,7 @@ async function fetchInventoryStatus(cfg, repo) {
568
568
  // (one source of truth) and writes them locally; you own and can open them.
569
569
  export async function runAtlas(argv) {
570
570
  const { mkdirSync, writeFileSync, rmSync } = await import("node:fs");
571
- const { join, dirname } = await import("node:path");
571
+ const { join, dirname, resolve, sep } = await import("node:path");
572
572
  const { homedir } = await import("node:os");
573
573
  const cfg = await loadConfigOrExit();
574
574
  const dir = getFlag(argv, "--out") ?? join(homedir(), ".replen", "atlas");
@@ -584,8 +584,13 @@ export async function runAtlas(argv) {
584
584
  }
585
585
  catch { /* */ }
586
586
  }
587
+ // f.path is server-controlled: refuse any path that escapes the atlas dir
588
+ // (e.g. "../../.zshrc"). Mirrors the guard in mcp/src/atlas-sync.ts.
589
+ const root = resolve(dir);
587
590
  for (const f of data.files) {
588
- const full = join(dir, f.path);
591
+ const full = resolve(dir, f.path);
592
+ if (full !== root && !full.startsWith(root + sep))
593
+ continue; // path traversal guard
589
594
  mkdirSync(dirname(full), { recursive: true });
590
595
  writeFileSync(full, f.content);
591
596
  }
package/dist/immerse.js CHANGED
@@ -15,7 +15,7 @@
15
15
  // Self-host installs don't need this — they default on via REPLEN_SELF_HOST and
16
16
  // the pipeline reads local disk directly.
17
17
  import { readFileSync, statSync } from "node:fs";
18
- import { join } from "node:path";
18
+ import { resolve, sep } from "node:path";
19
19
  import { loadConfigOrExit, apiGet, apiPost } from "./api.js";
20
20
  import { resolveAndWalk } from "./sync-projects.js";
21
21
  const MAX_FILE_BYTES = 1_000_000; // mirror the server per-file cap
@@ -75,10 +75,20 @@ async function send(cfg) {
75
75
  continue;
76
76
  withPaths++;
77
77
  // Read exactly the grounded files the server asked for (size-capped).
78
+ // The server sanitizes its manifest paths, but the client must not trust a
79
+ // server-supplied path either: reject `..` traversal and anything that
80
+ // resolves outside this repo so a compromised/hostile manifest can't make
81
+ // `immerse` read files elsewhere on disk. "Your code never leaves except
82
+ // these exact repo files" is only true if we enforce it here too.
83
+ const root = resolve(repo.localPath);
78
84
  const files = [];
79
85
  for (const rel of manifest.paths) {
80
86
  try {
81
- const abs = join(repo.localPath, rel);
87
+ if (typeof rel !== "string" || rel.split(/[\\/]/).includes(".."))
88
+ continue;
89
+ const abs = resolve(root, rel);
90
+ if (abs !== root && !abs.startsWith(root + sep))
91
+ continue;
82
92
  const st = statSync(abs);
83
93
  if (!st.isFile() || st.size > MAX_FILE_BYTES)
84
94
  continue;
package/dist/index.js CHANGED
@@ -12,11 +12,12 @@ Usage:
12
12
  npx replen mcp setup Re-wire MCP using saved auth
13
13
  npx replen project-init Print a prompt your AI coding tool uses to draft
14
14
  a CLAUDE.md tuned for replen
15
- npx replen inject [-y] Append the "## Replen integration" section to
16
- every CLAUDE.md + AGENTS.md (Claude Code +
17
- Codex) under ~/github/, ~/code/, ~/projects/
18
- so the agent auto-surfaces matches on session
19
- start. Idempotent. Asks for consent unless -y.
15
+ npx replen inject [-y] Add the "## Replen integration" section to
16
+ CLAUDE.md + AGENTS.md + GEMINI.md (Claude Code +
17
+ Codex + Gemini CLI), creating any that are
18
+ missing, in every repo under ~/github/, ~/code/,
19
+ ~/projects/ so the agent auto-surfaces matches on
20
+ session start. Idempotent. Consent unless -y.
20
21
  npx replen sync-projects Re-scan local repos for new GitHub remotes
21
22
  [--root PATH ...] and register them with Replen. Run after
22
23
  cloning a new repo, or pass --root to point
package/dist/init.js CHANGED
@@ -13,11 +13,14 @@ function pickPort() {
13
13
  return PORT_MIN + Math.floor(Math.random() * (PORT_MAX - PORT_MIN));
14
14
  }
15
15
  function openBrowser(url) {
16
- const cmd = platform() === "darwin" ? "open"
17
- : platform() === "win32" ? "start"
18
- : "xdg-open";
16
+ // On Windows `start` is a cmd.exe builtin, not an executable spawning it
17
+ // directly fails, so route through `cmd /c start "" <url>` (the "" is the
18
+ // required title arg). darwin/linux have real `open`/`xdg-open` binaries.
19
+ const [cmd, args] = platform() === "darwin" ? ["open", [url]]
20
+ : platform() === "win32" ? ["cmd", ["/c", "start", "", url]]
21
+ : ["xdg-open", [url]];
19
22
  // Detach. We don't care about its exit.
20
- const proc = spawn(cmd, [url], { stdio: "ignore", detached: true });
23
+ const proc = spawn(cmd, args, { stdio: "ignore", detached: true });
21
24
  proc.on("error", () => {
22
25
  // Fail silently. We print the URL anyway as fallback.
23
26
  });
@@ -118,10 +121,15 @@ export async function runInit() {
118
121
  console.log("");
119
122
  console.log(" (Waiting for browser callback on http://127.0.0.1:" + port + "…)");
120
123
  console.log("");
124
+ // Bind the loopback listener BEFORE opening the browser (waitForCallback's
125
+ // server.listen runs synchronously in the Promise executor). Otherwise a
126
+ // local port-squatter could grab the port between open and listen and
127
+ // capture the code+state from the callback, then exchange them for the token.
128
+ const cbPromise = waitForCallback(port, state);
121
129
  openBrowser(authUrl);
122
130
  let cb;
123
131
  try {
124
- cb = await waitForCallback(port, state);
132
+ cb = await cbPromise;
125
133
  }
126
134
  catch (e) {
127
135
  console.error(" ✗ " + (e?.message ?? String(e)));
@@ -262,7 +262,7 @@ function replaceSection(claudeMd) {
262
262
  }
263
263
  async function promptYes(question) {
264
264
  if (!process.stdin.isTTY)
265
- return true; // non-interactive assume yes
265
+ return false; // non-interactive + no --yes ⇒ refuse (mirrors uninstall)
266
266
  const rl = createInterface({ input: process.stdin, output: process.stdout });
267
267
  return new Promise((resolve) => {
268
268
  rl.question(question, (answer) => {
@@ -287,13 +287,21 @@ export async function injectInstructions(opts = {}) {
287
287
  console.log(" · no git repos with GitHub remotes found — skipping CLAUDE.md inject. Pass --root <path> if your code lives somewhere non-conventional.");
288
288
  return outcome;
289
289
  }
290
- // First-run consent. Shows the count + an example path so the user
291
- // knows the blast radius. --yes (or non-TTY) bypasses.
290
+ // First-run consent. Shows the count + an example path so the user knows the
291
+ // blast radius, and names every file we touch (three per repo, created if
292
+ // missing). --yes bypasses; non-interactive without --yes is a safe no-op.
292
293
  if (!opts.yes) {
294
+ if (!process.stdin.isTTY) {
295
+ outcome.declined = true;
296
+ console.log(`\n · non-interactive shell — skipping CLAUDE.md/AGENTS.md/GEMINI.md inject.`);
297
+ console.log(` Re-run with \`npx replen inject -y\` to apply without a prompt.`);
298
+ return outcome;
299
+ }
293
300
  console.log(`\n Found ${repos.length} git repo(s) with GitHub remotes.`);
294
- console.log(` Append a "## Replen integration" section to each CLAUDE.md so Claude Code`);
295
- console.log(` surfaces today's matches at session start. Idempotent; edit freely above`);
296
- console.log(` the section. First 3:`);
301
+ console.log(` Add a "## Replen integration" section to CLAUDE.md, AGENTS.md and GEMINI.md`);
302
+ console.log(` in each (creating any that don't exist) so Claude Code / Codex / Gemini CLI`);
303
+ console.log(` surface today's matches at session start. Idempotent; edit freely above the`);
304
+ console.log(` section. First 3:`);
297
305
  for (const r of repos.slice(0, 3))
298
306
  console.log(` • ${r}`);
299
307
  if (repos.length > 3)
package/dist/mcp-setup.js CHANGED
@@ -189,6 +189,15 @@ function setupCodex(token, base) {
189
189
  const path = CODEX_CONFIG;
190
190
  try {
191
191
  backupIfExists(path);
192
+ // smol-toml preserves values but not comments, blank lines, or table order.
193
+ // If the user's config carries comments, warn (a .bak was already taken) so
194
+ // the loss isn't silent — the only faithful alternative is hand-editing.
195
+ if (existsSync(path)) {
196
+ const raw = readFileSync(path, "utf8");
197
+ if (/^\s*#/m.test(raw)) {
198
+ console.warn(` ⚠ ${path}: comments/formatting in this TOML aren't preserved when Replen edits it (a .bak was saved next to it).`);
199
+ }
200
+ }
192
201
  const config = readToml(path);
193
202
  const mcpServers = config.mcp_servers ?? {};
194
203
  const existed = !!mcpServers[SERVER_NAME];
@@ -254,7 +263,9 @@ function backupIfExists(path) {
254
263
  // previous backup. Each run preserves the prior state.
255
264
  const ts = new Date().toISOString().replace(/[:.]/g, "-");
256
265
  const backup = `${path}.bak.${ts}`;
257
- writeFileSync(backup, readFileSync(path));
266
+ // The backed-up config can carry the ingest token; keep it 0600 like the
267
+ // atomic write above, not the umask default (typically world-readable 0644).
268
+ writeFileSync(backup, readFileSync(path), { mode: 0o600 });
258
269
  }
259
270
  // ============================================================================
260
271
  // Claude SessionStart hook
@@ -31,6 +31,18 @@ export async function syncDiscoveredProjects({ token, base, explicitRoots = [],
31
31
  if (prompted) {
32
32
  await persistRoots(result.scannedRoots);
33
33
  }
34
+ // The absolute local checkout path carries the OS username; it is only useful
35
+ // to a server running ON this machine (self-host at localhost) for Immersion.
36
+ // A remote/hosted server can't read it, so don't leak it there.
37
+ const isLocalBase = (() => {
38
+ try {
39
+ const h = new URL(base).hostname;
40
+ return h === "localhost" || h === "127.0.0.1" || h === "::1";
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ })();
34
46
  const payload = {
35
47
  projects: result.projects.map((p) => ({
36
48
  slug: p.slug,
@@ -38,11 +50,9 @@ export async function syncDiscoveredProjects({ token, base, explicitRoots = [],
38
50
  name: p.name,
39
51
  tags: p.tags,
40
52
  primaryLanguage: p.primaryLanguage ?? undefined,
41
- // Absolute local checkout path. Used by Immersion on a self-host install
42
- // (server == this machine) to ground on the actual source; inert on the
43
- // hosted server, which can't read it. Always sent — it's identity, not
44
- // code; nothing is read unless the operator enables Immersion.
45
- localPath: p.localPath,
53
+ // Only sent to a local (self-host) server; omitted for a remote host so
54
+ // the username-bearing absolute path never leaves the machine.
55
+ localPath: isLocalBase ? p.localPath : undefined,
46
56
  })),
47
57
  };
48
58
  let res;
package/dist/uninstall.js CHANGED
@@ -345,6 +345,11 @@ function stripTomlServer(path) {
345
345
  if (Object.keys(servers).length === 0)
346
346
  delete next.mcp_servers;
347
347
  backupIfExists(path);
348
+ // smol-toml doesn't preserve comments/formatting on re-serialise; warn if the
349
+ // file had comments so their loss on uninstall isn't silent (a .bak is taken).
350
+ if (existsSync(path) && /^\s*#/m.test(readFileSync(path, "utf8"))) {
351
+ console.warn(` ⚠ ${path}: comments/formatting in this TOML aren't preserved (a .bak was saved next to it).`);
352
+ }
348
353
  writeTomlAtomic(path, next);
349
354
  }
350
355
  // ============================================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replen",
3
- "version": "1.5.4",
3
+ "version": "1.5.5",
4
4
  "description": "Make your AI coding tools smarter. One command, no API keys, free. Replen watches what your projects actually do and surfaces a few things worth bringing in each month. Use one as is, port a piece of another, cherry pick an idea, or build it clean room. The match happens inside your AI tool's session. A few actionable matches a month, by design.",
5
5
  "type": "module",
6
6
  "bin": {