replen 1.5.3 → 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
  }
@@ -4,7 +4,7 @@
4
4
  // finds every git repo, and for each one extracts:
5
5
  // - The repo's `owner/name` from `git remote get-url origin`
6
6
  // - A slug derived from the GITHUB repo name (so a local folder named
7
- // "drone" whose remote is acme/aegis registers with slug "aegis",
7
+ // "drone" whose remote is acme/acme registers with slug "acme",
8
8
  // matching what shows on GitHub). Falls back to dirname for repos
9
9
  // without a GitHub remote.
10
10
  // - A name (from package.json's `name` field if present, else slug)
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
  // ============================================================================
@@ -56,8 +56,8 @@ Find the user's local repositories and filter to the ones worth onboarding.
56
56
  - Has a git remote (so Replen can scope by `owner/name`). Note any local-only
57
57
  repos and skip them.
58
58
  3. **Present the list and get ONE green light.** Show the in-scope repos and ask
59
- the user to confirm or trim — e.g. *"Found 8 active repos to onboard: aegis-web,
60
- aegis-api, … — go, or want me to drop any?"* This is the **only** question you
59
+ the user to confirm or trim — e.g. *"Found 8 active repos to onboard: acme-web,
60
+ acme-api, … — go, or want me to drop any?"* This is the **only** question you
61
61
  ask. Once confirmed, run the rest autonomously.
62
62
 
63
63
  ## Step 1.5 — Pre-flight: decide the MINIMUM work per repo (do NOT skip this)
@@ -291,7 +291,7 @@ sanitize titles, no codenames.
291
291
 
292
292
  **Also capture architectural PRACTICES as capabilities.** Beyond what the code
293
293
  *does*, note the distinctive structural *moves* it makes — these are what let
294
- Replen suggest a pattern to your OTHER projects ("Aegis made its domain model
294
+ Replen suggest a pattern to your OTHER projects ("Acme made its domain model
295
295
  data-driven; this data-heavy project should consider it"). Include a capability
296
296
  for any deliberate practice you find: a **data-driven domain ontology** (entity
297
297
  types/relationships stored as DATA/rows, not hardcoded — configurable without a
@@ -353,7 +353,7 @@ typed properties, computed properties as rows", never the entity names).
353
353
  it (e.g. `{tag: "computer vision", …, paths: ["src/cv/transformations.py"]}`).
354
354
  Paths only, never code. You just read these files; recording WHERE each
355
355
  capability lives lets Replen point another of the user's projects straight
356
- at the implementation worth porting ("aegis solved this — see src/cv/…").
356
+ at the implementation worth porting ("acme solved this — see src/cv/…").
357
357
  **If you grounded from a vault, also pass the `concepts` array from 2d** —
358
358
  concept titles + their `[[wikilinks]]` only (never file/symbol notes, never
359
359
  bodies). Replen turns these into graph nodes + your-own-graph leaps. Omit it
@@ -369,7 +369,7 @@ typed properties, computed properties as rows", never the entity names).
369
369
 
370
370
  ## Step 3 — Group multi-repo products
371
371
 
372
- If several repos are one product (e.g. `aegis-web`/`aegis-api`/`aegis-cv`), group
372
+ If several repos are one product (e.g. `acme-web`/`acme-api`/`acme-cv`), group
373
373
  them with `replen_set_product` so Replen unions the whole product's capabilities
374
374
  when the user is in any one of them.
375
375
 
@@ -383,7 +383,7 @@ when the user is in any one of them.
383
383
  ```
384
384
  Replen onboarding done — 8 repos grounded.
385
385
  · docs: 2 created, 1 augmented, 5 already good (untouched)
386
- · capabilities + report set for all 8; aegis-* grouped as one product
386
+ · capabilities + report set for all 8; acme-* grouped as one product
387
387
  · doc changes are on branch `replen/onboarding-docs` for your review
388
388
  Replen will now surface genuinely relevant tools for these repos.
389
389
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replen",
3
- "version": "1.5.3",
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": {
@@ -10,7 +10,7 @@
10
10
  "build": "tsc && chmod +x dist/index.js",
11
11
  "dev": "tsx src/index.ts",
12
12
  "check:skill-sync": "node scripts/check-skill-sync.mjs",
13
- "prepublishOnly": "npm run build && npm run check:skill-sync"
13
+ "prepublishOnly": "npm run build && npm run check:skill-sync && node ../scripts/check-no-private-slugs.mjs"
14
14
  },
15
15
  "files": [
16
16
  "dist",