replen 1.0.33 → 1.0.34
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/auto-register.js +163 -0
- package/dist/commands.js +10 -1
- package/dist/known-repos.js +55 -0
- package/package.json +1 -1
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Zero-touch repo auto-registration, run from the SessionStart hook.
|
|
2
|
+
//
|
|
3
|
+
// The problem this closes: registration used to happen ONLY when the user ran
|
|
4
|
+
// `npx replen sync-projects` by hand. A repo they cloned/created and never
|
|
5
|
+
// manually synced was invisible to Replen — so it never got matched, and the
|
|
6
|
+
// user had to remember a step. This folds discovery into the hook that
|
|
7
|
+
// already runs every session, so a repo's *identity* registers itself the
|
|
8
|
+
// first time the user opens Claude Code anywhere near it.
|
|
9
|
+
//
|
|
10
|
+
// Two triggers, both bounded and silent (this is on the session-open critical
|
|
11
|
+
// path; it must never prompt, log to the user, slow things down, or throw):
|
|
12
|
+
// 1. cwd fast-path — the repo the user just opened. Cheap (a single-repo
|
|
13
|
+
// walk), runs every session, catches the "I just made this repo" case
|
|
14
|
+
// immediately.
|
|
15
|
+
// 2. throttled full walk — the whole portfolio, at most once per
|
|
16
|
+
// FULL_SCAN_INTERVAL. Belt-and-suspenders for repos cloned but not yet
|
|
17
|
+
// opened in Claude Code.
|
|
18
|
+
//
|
|
19
|
+
// Identity only: this registers owner/name (+ manifest-derived tags) via the
|
|
20
|
+
// same local-FS `discoverProjects` the manual sync uses. No code is read, no
|
|
21
|
+
// LLM is called. Capability/facet profiling is Part 2 (onboard-on-first-visit).
|
|
22
|
+
import { execSync } from "node:child_process";
|
|
23
|
+
import { discoverProjects } from "./discover-projects.js";
|
|
24
|
+
import { readKnownRepos, mergeKnownRepos } from "./known-repos.js";
|
|
25
|
+
import { rootsFromClaudeJson, rootsFromConfig, rootsFromEnv, rootsFromHardcoded, } from "./discover-roots.js";
|
|
26
|
+
// How often the full-portfolio filesystem walk may run. The walk is local-FS
|
|
27
|
+
// only but still touches every repo under the user's roots, so we don't want
|
|
28
|
+
// it on every session — the cwd fast-path covers the common case in between.
|
|
29
|
+
const FULL_SCAN_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h
|
|
30
|
+
// Hard ceiling on the bulk POST. The hook is blocking session open; a slow or
|
|
31
|
+
// unreachable server must not stall it. Errors / timeouts just defer
|
|
32
|
+
// registration to a future session (the repo stays "unknown" in the cache).
|
|
33
|
+
const POST_TIMEOUT_MS = 4000;
|
|
34
|
+
/**
|
|
35
|
+
* Discover repos that aren't yet known to Replen and register their identity.
|
|
36
|
+
* Best-effort and silent: every failure path returns quietly. Designed to be
|
|
37
|
+
* awaited in parallel with the inventory fetch so it adds ~no wall-clock when
|
|
38
|
+
* there's nothing new (the common case: cwd repo already known, no scan due).
|
|
39
|
+
*/
|
|
40
|
+
export async function autoRegisterNewRepos(opts) {
|
|
41
|
+
const known = await readKnownRepos();
|
|
42
|
+
const knownSet = new Set(known.repos.map((r) => r.toLowerCase()));
|
|
43
|
+
const scanDue = isFullScanDue(known.lastFullScanAt);
|
|
44
|
+
// Gather discovered projects, deduped by githubFullName across both triggers.
|
|
45
|
+
const byFullName = new Map();
|
|
46
|
+
// 1. cwd fast-path: walk just the repo the user opened (resolved to its
|
|
47
|
+
// git toplevel so subdir cwds still work).
|
|
48
|
+
const cwdRoot = repoRootForCwd();
|
|
49
|
+
if (cwdRoot) {
|
|
50
|
+
for (const p of safeDiscover([cwdRoot])) {
|
|
51
|
+
if (p.githubFullName)
|
|
52
|
+
byFullName.set(p.githubFullName, p);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// 2. throttled full walk.
|
|
56
|
+
if (scanDue) {
|
|
57
|
+
const roots = await resolveScanRoots();
|
|
58
|
+
if (roots.length > 0) {
|
|
59
|
+
for (const p of safeDiscover(roots)) {
|
|
60
|
+
if (p.githubFullName)
|
|
61
|
+
byFullName.set(p.githubFullName, p);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const fresh = Array.from(byFullName.values()).filter((p) => p.githubFullName && !knownSet.has(p.githubFullName.toLowerCase()));
|
|
66
|
+
if (fresh.length === 0) {
|
|
67
|
+
// Nothing new — but if a scan was due, record that it ran so we don't
|
|
68
|
+
// re-walk the filesystem every session.
|
|
69
|
+
if (scanDue)
|
|
70
|
+
await mergeKnownRepos([], true);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const ok = await postProjectsBulk(opts.base, opts.token, fresh);
|
|
74
|
+
if (ok) {
|
|
75
|
+
// Cache the now-registered repos so we don't re-POST them next session.
|
|
76
|
+
await mergeKnownRepos(fresh.map((p) => p.githubFullName), scanDue);
|
|
77
|
+
}
|
|
78
|
+
else if (scanDue) {
|
|
79
|
+
// POST failed: don't mark these repos known (a later session retries
|
|
80
|
+
// them), but do stamp the scan time so we don't re-walk on every session
|
|
81
|
+
// while the server is unreachable. The cwd fast-path still retries.
|
|
82
|
+
await mergeKnownRepos([], true);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function isFullScanDue(lastFullScanAt) {
|
|
86
|
+
if (!lastFullScanAt)
|
|
87
|
+
return true;
|
|
88
|
+
const t = Date.parse(lastFullScanAt);
|
|
89
|
+
if (Number.isNaN(t))
|
|
90
|
+
return true;
|
|
91
|
+
return Date.now() - t > FULL_SCAN_INTERVAL_MS;
|
|
92
|
+
}
|
|
93
|
+
// Resolve the cwd to its git repo root so a subdir cwd still discovers the
|
|
94
|
+
// repo. Returns null when not in a git repo.
|
|
95
|
+
function repoRootForCwd() {
|
|
96
|
+
try {
|
|
97
|
+
const top = execSync("git rev-parse --show-toplevel", {
|
|
98
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
99
|
+
encoding: "utf8",
|
|
100
|
+
timeout: 1500,
|
|
101
|
+
}).trim();
|
|
102
|
+
return top || null;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Non-interactive root resolution for the full walk. Mirrors sync-projects'
|
|
109
|
+
// inference order but NEVER prompts (the hook has no TTY contract) — config
|
|
110
|
+
// and env first, then the high-signal Claude-tracked dirs and conventional
|
|
111
|
+
// layout. discoverProjects dedups by path + githubFullName, so the union is
|
|
112
|
+
// safe even when roots overlap.
|
|
113
|
+
async function resolveScanRoots() {
|
|
114
|
+
const fromConfig = await rootsFromConfig();
|
|
115
|
+
return Array.from(new Set([
|
|
116
|
+
...fromConfig,
|
|
117
|
+
...rootsFromEnv(),
|
|
118
|
+
...rootsFromClaudeJson(),
|
|
119
|
+
...rootsFromHardcoded(),
|
|
120
|
+
]));
|
|
121
|
+
}
|
|
122
|
+
// discoverProjects shells out to git per repo; guard the whole walk so a
|
|
123
|
+
// transient FS/git error can't bubble out of the hook.
|
|
124
|
+
function safeDiscover(roots) {
|
|
125
|
+
try {
|
|
126
|
+
return discoverProjects(roots).projects;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Silent, bounded POST to the same bulk-upsert endpoint the manual sync uses.
|
|
133
|
+
// Returns whether the registration succeeded.
|
|
134
|
+
async function postProjectsBulk(base, token, projects) {
|
|
135
|
+
const ctrl = new AbortController();
|
|
136
|
+
const timer = setTimeout(() => ctrl.abort(), POST_TIMEOUT_MS);
|
|
137
|
+
try {
|
|
138
|
+
const res = await fetch(`${base}/api/projects/bulk`, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: {
|
|
141
|
+
"x-digest-token": token,
|
|
142
|
+
"content-type": "application/json",
|
|
143
|
+
},
|
|
144
|
+
body: JSON.stringify({
|
|
145
|
+
projects: projects.map((p) => ({
|
|
146
|
+
slug: p.slug,
|
|
147
|
+
githubFullName: p.githubFullName,
|
|
148
|
+
name: p.name,
|
|
149
|
+
tags: p.tags,
|
|
150
|
+
primaryLanguage: p.primaryLanguage ?? undefined,
|
|
151
|
+
})),
|
|
152
|
+
}),
|
|
153
|
+
signal: ctrl.signal,
|
|
154
|
+
});
|
|
155
|
+
return res.ok;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
clearTimeout(timer);
|
|
162
|
+
}
|
|
163
|
+
}
|
package/dist/commands.js
CHANGED
|
@@ -217,7 +217,16 @@ export async function runCheckNew(argv) {
|
|
|
217
217
|
}
|
|
218
218
|
return;
|
|
219
219
|
}
|
|
220
|
-
|
|
220
|
+
// Auto-register newly-appeared repos (identity only — owner/name +
|
|
221
|
+
// manifest tags, no code, no LLM) in parallel with the inventory fetch.
|
|
222
|
+
// Both are bounded and best-effort; neither may slow or disrupt session
|
|
223
|
+
// open. When there's nothing new (the common case) auto-register returns
|
|
224
|
+
// near-instantly, adding no wall-clock over the inventory call alone.
|
|
225
|
+
const { autoRegisterNewRepos } = await import("./auto-register.js");
|
|
226
|
+
const [, inv] = await Promise.all([
|
|
227
|
+
autoRegisterNewRepos({ token: cfg.token, base: cfg.base }).catch(() => undefined),
|
|
228
|
+
fetchInventoryStatus(cfg, cwdRepo),
|
|
229
|
+
]);
|
|
221
230
|
if (inv?.displayText) {
|
|
222
231
|
console.log("[Replen] Relay the following line to the user, verbatim, as a footnote at the very end of your first reply:");
|
|
223
232
|
console.log(inv.displayText);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Local cache of repos Replen has already seen, plus the timestamp of the
|
|
2
|
+
// last full-portfolio scan. Lives next to the auth config in ~/.replen/.
|
|
3
|
+
//
|
|
4
|
+
// Purpose: the SessionStart hook (auto-register.ts) needs to answer "is this
|
|
5
|
+
// repo new?" without POSTing the user's whole portfolio to the server on
|
|
6
|
+
// every single session. We cache the set of githubFullNames we've registered
|
|
7
|
+
// and only send the diff. The server upsert is idempotent, so a stale cache
|
|
8
|
+
// is harmless (worst case: a redundant POST); the cache is a latency
|
|
9
|
+
// optimisation, not a source of truth.
|
|
10
|
+
//
|
|
11
|
+
// Identity only — this file never stores code, paths, or capabilities, just
|
|
12
|
+
// the owner/name strings already public on GitHub.
|
|
13
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join, dirname } from "node:path";
|
|
16
|
+
const KNOWN_FILE = join(homedir(), ".replen", "known-repos.json");
|
|
17
|
+
const EMPTY = { repos: [], lastFullScanAt: null };
|
|
18
|
+
export async function readKnownRepos() {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(await readFile(KNOWN_FILE, "utf8"));
|
|
21
|
+
return {
|
|
22
|
+
repos: Array.isArray(parsed.repos)
|
|
23
|
+
? parsed.repos.filter((r) => typeof r === "string")
|
|
24
|
+
: [],
|
|
25
|
+
lastFullScanAt: typeof parsed.lastFullScanAt === "string" ? parsed.lastFullScanAt : null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Missing or malformed cache — treat as "nothing known yet".
|
|
30
|
+
return { ...EMPTY };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Merge newly-registered repos into the cache. When `didFullScan` is true,
|
|
35
|
+
* also stamp `lastFullScanAt` so the throttled full walk doesn't re-run for
|
|
36
|
+
* the configured interval. Best-effort: a write failure is swallowed (the
|
|
37
|
+
* cache only ever costs a redundant idempotent POST next session).
|
|
38
|
+
*/
|
|
39
|
+
export async function mergeKnownRepos(newRepos, didFullScan) {
|
|
40
|
+
const cur = await readKnownRepos();
|
|
41
|
+
const set = new Set(cur.repos);
|
|
42
|
+
for (const r of newRepos)
|
|
43
|
+
set.add(r);
|
|
44
|
+
const next = {
|
|
45
|
+
repos: Array.from(set).sort(),
|
|
46
|
+
lastFullScanAt: didFullScan ? new Date().toISOString() : cur.lastFullScanAt,
|
|
47
|
+
};
|
|
48
|
+
try {
|
|
49
|
+
await mkdir(dirname(KNOWN_FILE), { recursive: true, mode: 0o700 });
|
|
50
|
+
await writeFile(KNOWN_FILE, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// intentionally ignore — cache write must never disrupt a session
|
|
54
|
+
}
|
|
55
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "replen",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.34",
|
|
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": {
|