codiedev 0.9.24 → 0.9.25
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/doctor.js +56 -5
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +109 -3
- package/package.json +1 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -165,6 +165,9 @@ async function runDoctor(_args) {
|
|
|
165
165
|
catch {
|
|
166
166
|
// malformed backendUrl in config — keep the generic placeholder
|
|
167
167
|
}
|
|
168
|
+
// Live tracked-repo list fetched from validateToken below. Null until (and
|
|
169
|
+
// unless) the backend answers; the checks fall back to `config.repos`.
|
|
170
|
+
let liveRepos = null;
|
|
168
171
|
try {
|
|
169
172
|
const res = await fetch(`${config.backendUrl}/api/cli/validateToken`, {
|
|
170
173
|
method: "POST",
|
|
@@ -180,6 +183,21 @@ async function runDoctor(_args) {
|
|
|
180
183
|
status: "pass",
|
|
181
184
|
detail: backendHost,
|
|
182
185
|
});
|
|
186
|
+
// validateToken returns the company's CURRENT tracked repos. Capture
|
|
187
|
+
// them: the repo-resolution checks below must reflect what the SERVER
|
|
188
|
+
// will resolve at capture time (repoResolver.ts resolves against the
|
|
189
|
+
// live list), not the possibly-stale `config.repos` frozen at the last
|
|
190
|
+
// `connect`. A repo linked in the portal after that connect otherwise
|
|
191
|
+
// reports a false "isn't a tracked repo — link it in the portal" here
|
|
192
|
+
// even though sessions/MRs for it attribute fine server-side.
|
|
193
|
+
try {
|
|
194
|
+
const data = (await res.json());
|
|
195
|
+
if (Array.isArray(data.repos))
|
|
196
|
+
liveRepos = data.repos;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
liveRepos = null; // malformed body → fall back to the cached list
|
|
200
|
+
}
|
|
183
201
|
}
|
|
184
202
|
else {
|
|
185
203
|
checks.push({
|
|
@@ -219,13 +237,46 @@ async function runDoctor(_args) {
|
|
|
219
237
|
detail: err.message,
|
|
220
238
|
});
|
|
221
239
|
}
|
|
222
|
-
// 4. Tracked repos
|
|
223
|
-
|
|
240
|
+
// 4. Tracked repos. Prefer the live list from validateToken over the cache:
|
|
241
|
+
// a repo linked after the last `connect` is tracked server-side but absent
|
|
242
|
+
// from `config.repos`, and resolving against the stale cache is exactly what
|
|
243
|
+
// produced the false "isn't a tracked repo — link it in the portal" warning.
|
|
244
|
+
// Treat an EMPTY live list as "no usable live data", NOT an authoritative
|
|
245
|
+
// zero: a transient permission glitch / backend hiccup can return `repos: []`,
|
|
246
|
+
// and both driving the checks from it AND healing the cache to empty would be
|
|
247
|
+
// wrong — the write would durably break every offline doctor run (and the
|
|
248
|
+
// capture hook's providedRepoId) until the next successful connect. So when
|
|
249
|
+
// live is empty, fall back to the cache and skip the write, exactly like
|
|
250
|
+
// backend-unreachable. A genuinely repo-less company also has an empty cache,
|
|
251
|
+
// so it still reports "0 repos" correctly.
|
|
252
|
+
const trackedRepos = liveRepos !== null && liveRepos.length > 0 ? liveRepos : (config.repos ?? []);
|
|
253
|
+
// Detect drift by CONTENT, not count: a same-size swap (one repo removed +
|
|
254
|
+
// another added between connects) or a repo whose path/URL changed while its
|
|
255
|
+
// id stayed stable (GitLab move/rename — exactly the SONIFI migration case)
|
|
256
|
+
// both leave the count unchanged. A length-only check would skip the heal and
|
|
257
|
+
// leave the capture hook's cache — and its advisory providedRepoId — stale.
|
|
258
|
+
const canonRepos = (rs) => JSON.stringify([...rs].map((r) => [r.repoId, r.url, r.fullName]).sort());
|
|
259
|
+
const cacheRefreshed = liveRepos !== null &&
|
|
260
|
+
liveRepos.length > 0 &&
|
|
261
|
+
canonRepos(liveRepos) !== canonRepos(config.repos ?? []);
|
|
262
|
+
// Heal the on-disk cache so the capture hook (which reads `config.repos` for
|
|
263
|
+
// the advisory providedRepoId) stops trailing the portal too. Best-effort:
|
|
264
|
+
// doctor must never fail because the config couldn't be rewritten.
|
|
265
|
+
if (cacheRefreshed && liveRepos) {
|
|
266
|
+
try {
|
|
267
|
+
(0, utils_1.writeConfig)({ ...config, repos: liveRepos, lastSynced: new Date().toISOString() });
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// read-only home / permission issue — the in-memory list still drives
|
|
271
|
+
// the checks below; the user just re-syncs on the next `connect`.
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const repoCount = trackedRepos.length;
|
|
224
275
|
checks.push({
|
|
225
276
|
name: "Tracked repos",
|
|
226
277
|
status: repoCount > 0 ? "pass" : "warn",
|
|
227
278
|
detail: repoCount > 0
|
|
228
|
-
? `${repoCount} repo${repoCount === 1 ? "" : "s"}`
|
|
279
|
+
? `${repoCount} repo${repoCount === 1 ? "" : "s"}${cacheRefreshed ? " (refreshed from portal)" : ""}`
|
|
229
280
|
: "0 — link repos in the portal so sessions get captured",
|
|
230
281
|
});
|
|
231
282
|
// 4b. Capture readiness — the part that actually decides whether a session
|
|
@@ -234,7 +285,7 @@ async function runDoctor(_args) {
|
|
|
234
285
|
// last one?" — the blind spot behind lean-container / submodule / no-remote
|
|
235
286
|
// zeros.
|
|
236
287
|
const cwd = process.cwd();
|
|
237
|
-
const readiness = (0, captureReadiness_1.assessCaptureReadiness)(cwd,
|
|
288
|
+
const readiness = (0, captureReadiness_1.assessCaptureReadiness)(cwd, trackedRepos, {
|
|
238
289
|
exec: (cmd, dir) => (0, child_process_1.execSync)(cmd, { cwd: dir, stdio: ["pipe", "pipe", "pipe"] }).toString(),
|
|
239
290
|
getRemoteUrl: utils_1.getGitRemoteUrl,
|
|
240
291
|
matchRepo: utils_1.matchRepo,
|
|
@@ -247,7 +298,7 @@ async function runDoctor(_args) {
|
|
|
247
298
|
// instead of flatly warning "not a work tree / no remote" — the exact output
|
|
248
299
|
// that made working workspace-root setups read as broken.
|
|
249
300
|
const workspaceScan = readiness.gitOnPath && !readiness.insideWorkTree
|
|
250
|
-
? (0, captureReadiness_1.assessWorkspaceRepos)(cwd,
|
|
301
|
+
? (0, captureReadiness_1.assessWorkspaceRepos)(cwd, trackedRepos, {
|
|
251
302
|
listChildDirs: (dir) => fs
|
|
252
303
|
.readdirSync(dir, { withFileTypes: true })
|
|
253
304
|
.filter((e) => e.isDirectory())
|
package/dist/utils.d.ts
CHANGED
|
@@ -67,6 +67,8 @@ export interface CapturePayloadInput {
|
|
|
67
67
|
}
|
|
68
68
|
export declare function buildCapturePayload(input: CapturePayloadInput): Record<string, unknown>;
|
|
69
69
|
export declare function hashToken(token: string): string;
|
|
70
|
+
export declare function extractHookNodePath(cmd: string | undefined): string | undefined;
|
|
71
|
+
export declare function resetStableNodeCacheForTests(): void;
|
|
70
72
|
export declare function claudeCodeInstalled(): boolean;
|
|
71
73
|
export declare function codexInstalled(): boolean;
|
|
72
74
|
export declare function cursorInstalled(): boolean;
|
package/dist/utils.js
CHANGED
|
@@ -52,6 +52,8 @@ exports.getGitBranch = getGitBranch;
|
|
|
52
52
|
exports.getGitHeadSha = getGitHeadSha;
|
|
53
53
|
exports.buildCapturePayload = buildCapturePayload;
|
|
54
54
|
exports.hashToken = hashToken;
|
|
55
|
+
exports.extractHookNodePath = extractHookNodePath;
|
|
56
|
+
exports.resetStableNodeCacheForTests = resetStableNodeCacheForTests;
|
|
55
57
|
exports.claudeCodeInstalled = claudeCodeInstalled;
|
|
56
58
|
exports.codexInstalled = codexInstalled;
|
|
57
59
|
exports.cursorInstalled = cursorInstalled;
|
|
@@ -733,7 +735,94 @@ const VSCODE_USER_MCP_PATH = path.join(VSCODE_USER_DIR, "mcp.json");
|
|
|
733
735
|
function shellQuote(s) {
|
|
734
736
|
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
735
737
|
}
|
|
736
|
-
|
|
738
|
+
// Pull the `<node>` prefix out of a codiedev hook command we previously wrote.
|
|
739
|
+
// Commands take the form `'<node>' '<hookScript>' <subcommand>` (each path
|
|
740
|
+
// single-quoted by shellQuote). Returns undefined for the legacy
|
|
741
|
+
// `npx codiedev-hook <subcommand>` form (no explicit node path) or if the
|
|
742
|
+
// command doesn't start with a quoted token. Exported for unit testing.
|
|
743
|
+
function extractHookNodePath(cmd) {
|
|
744
|
+
if (!cmd)
|
|
745
|
+
return undefined;
|
|
746
|
+
// Match the first single-quoted token, honoring shellQuote's `'\''` escape.
|
|
747
|
+
const m = cmd.match(/^'((?:[^']|'\\'')*)'/);
|
|
748
|
+
if (!m)
|
|
749
|
+
return undefined;
|
|
750
|
+
return m[1].replace(/'\\''/g, "'");
|
|
751
|
+
}
|
|
752
|
+
// `process.execPath` is the *resolved* path to the running node binary, so under
|
|
753
|
+
// Homebrew it's the version-specific Cellar path (e.g.
|
|
754
|
+
// /opt/homebrew/Cellar/node/26.4.0/bin/node). Baking that into the hook means a
|
|
755
|
+
// later `brew upgrade node` moves the Cellar dir and the hook path 404s. Prefer
|
|
756
|
+
// a stable alias for the *same* binary:
|
|
757
|
+
// 1. A node path from a hook we previously wrote that still resolves — usually
|
|
758
|
+
// a symlink the user deliberately pinned (e.g. /opt/homebrew/bin/node). A
|
|
759
|
+
// re-`connect`/self-heal then honors it instead of clobbering it.
|
|
760
|
+
// 2. Otherwise, `node` as found on PATH when it points at the binary we're
|
|
761
|
+
// running (the Homebrew symlink, an nvm `current` alias, etc.).
|
|
762
|
+
// 3. Last resort: the resolved `process.execPath`. Always valid right now, and
|
|
763
|
+
// still an absolute path a GUI-launched agent can execute without PATH.
|
|
764
|
+
function realpathOrSelf(p) {
|
|
765
|
+
try {
|
|
766
|
+
return fs.realpathSync(p);
|
|
767
|
+
}
|
|
768
|
+
catch {
|
|
769
|
+
return p;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
// A stable `node` alias on PATH (Homebrew symlink, nvm `current`, …) that points
|
|
773
|
+
// at the very binary we're running, or undefined if none. `installHook` writes
|
|
774
|
+
// five hooks per run, so memoize: `command -v node` is a synchronous subprocess
|
|
775
|
+
// spawn and its result can't change within a process. `null` = "looked, none".
|
|
776
|
+
let cachedStableNodeOnPath;
|
|
777
|
+
// Test-only: the memo lives for the process lifetime, so suites that stage
|
|
778
|
+
// their own PATH/symlink fixtures must clear it between tests.
|
|
779
|
+
function resetStableNodeCacheForTests() {
|
|
780
|
+
cachedStableNodeOnPath = undefined;
|
|
781
|
+
}
|
|
782
|
+
function stableNodeOnPath() {
|
|
783
|
+
if (cachedStableNodeOnPath !== undefined) {
|
|
784
|
+
return cachedStableNodeOnPath ?? undefined;
|
|
785
|
+
}
|
|
786
|
+
const execReal = realpathOrSelf(process.execPath);
|
|
787
|
+
try {
|
|
788
|
+
const onPath = (0, child_process_1.execSync)("command -v node", {
|
|
789
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
790
|
+
})
|
|
791
|
+
.toString()
|
|
792
|
+
.trim();
|
|
793
|
+
if (onPath && onPath !== process.execPath && realpathOrSelf(onPath) === execReal) {
|
|
794
|
+
cachedStableNodeOnPath = onPath;
|
|
795
|
+
return onPath;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
catch {
|
|
799
|
+
// No node on PATH (e.g. GUI-launched agent) — fall through.
|
|
800
|
+
}
|
|
801
|
+
cachedStableNodeOnPath = null;
|
|
802
|
+
return undefined;
|
|
803
|
+
}
|
|
804
|
+
function resolveHookNodePath(preferredNodePath) {
|
|
805
|
+
const execReal = realpathOrSelf(process.execPath);
|
|
806
|
+
// 1. Honor a still-working node path from the existing hook — but only when
|
|
807
|
+
// it's a *different* string than the resolved realpath. If the existing
|
|
808
|
+
// path already IS `process.execPath` it's the version-specific realpath we
|
|
809
|
+
// want to upgrade away from (that's the Homebrew bug), so fall through to
|
|
810
|
+
// hunt for a stable alias in step 2.
|
|
811
|
+
if (preferredNodePath &&
|
|
812
|
+
preferredNodePath !== process.execPath &&
|
|
813
|
+
fs.existsSync(preferredNodePath) &&
|
|
814
|
+
realpathOrSelf(preferredNodePath) === execReal) {
|
|
815
|
+
return preferredNodePath;
|
|
816
|
+
}
|
|
817
|
+
// 2. Prefer a stable `node` on PATH over the version-specific realpath.
|
|
818
|
+
const onPath = stableNodeOnPath();
|
|
819
|
+
if (onPath)
|
|
820
|
+
return onPath;
|
|
821
|
+
// 3. Last resort: the resolved absolute path. Always valid right now, and
|
|
822
|
+
// still an absolute path a GUI-launched agent can execute without PATH.
|
|
823
|
+
return process.execPath;
|
|
824
|
+
}
|
|
825
|
+
function resolveHookCommand(subcommand, preferredNodePath) {
|
|
737
826
|
try {
|
|
738
827
|
const cliEntry = process.argv[1];
|
|
739
828
|
if (cliEntry) {
|
|
@@ -741,7 +830,8 @@ function resolveHookCommand(subcommand) {
|
|
|
741
830
|
const distDir = path.dirname(realCli);
|
|
742
831
|
const hookScript = path.join(distDir, "hook.js");
|
|
743
832
|
if (fs.existsSync(hookScript)) {
|
|
744
|
-
|
|
833
|
+
const nodePath = resolveHookNodePath(preferredNodePath);
|
|
834
|
+
return `${shellQuote(nodePath)} ${shellQuote(hookScript)} ${subcommand}`;
|
|
745
835
|
}
|
|
746
836
|
}
|
|
747
837
|
}
|
|
@@ -798,6 +888,22 @@ function upsertCodiedevHook(hooks, eventName, subcommand, matcher = ".*") {
|
|
|
798
888
|
if (!hooks[eventName])
|
|
799
889
|
hooks[eventName] = [];
|
|
800
890
|
const existing = hooks[eventName];
|
|
891
|
+
// Before stripping the old codiedev entry, remember the node path it used so a
|
|
892
|
+
// re-run/upgrade can honor a stable symlink the user pinned instead of
|
|
893
|
+
// overwriting it with the version-specific `process.execPath` (see
|
|
894
|
+
// resolveHookNodePath). Done in an explicit scan rather than as a side effect
|
|
895
|
+
// of the filter predicate below, which stays a pure predicate.
|
|
896
|
+
let preservedNodePath;
|
|
897
|
+
for (const hook of existing) {
|
|
898
|
+
const inner = hook.hooks;
|
|
899
|
+
if (!Array.isArray(inner))
|
|
900
|
+
continue;
|
|
901
|
+
const codiedevInner = inner.find((h) => (0, detection_1.isCodiedevHookCommand)(h.command));
|
|
902
|
+
if (codiedevInner) {
|
|
903
|
+
preservedNodePath = extractHookNodePath(codiedevInner.command);
|
|
904
|
+
break;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
801
907
|
const filtered = existing.filter((hook) => {
|
|
802
908
|
const inner = hook.hooks;
|
|
803
909
|
if (Array.isArray(inner)) {
|
|
@@ -807,7 +913,7 @@ function upsertCodiedevHook(hooks, eventName, subcommand, matcher = ".*") {
|
|
|
807
913
|
});
|
|
808
914
|
filtered.push({
|
|
809
915
|
matcher,
|
|
810
|
-
hooks: [{ type: "command", command: resolveHookCommand(subcommand) }],
|
|
916
|
+
hooks: [{ type: "command", command: resolveHookCommand(subcommand, preservedNodePath) }],
|
|
811
917
|
});
|
|
812
918
|
hooks[eventName] = filtered;
|
|
813
919
|
}
|
package/package.json
CHANGED