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.
@@ -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
- const repoCount = config.repos?.length ?? 0;
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, config.repos ?? [], {
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, config.repos ?? [], {
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
- function resolveHookCommand(subcommand) {
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
- return `${shellQuote(process.execPath)} ${shellQuote(hookScript)} ${subcommand}`;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codiedev",
3
- "version": "0.9.24",
3
+ "version": "0.9.25",
4
4
  "description": "Connect Claude Code, Codex, Cursor, or VS Code Copilot to Codiedev for org-wide session capture and artifact collaboration",
5
5
  "bin": {
6
6
  "codiedev": "dist/cli.js",