codiedev 0.9.4 → 0.9.6-beta.0

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.
@@ -3,6 +3,7 @@ export interface SelectDeps {
3
3
  readFile: (rel: string) => string;
4
4
  sha256: (content: string) => string;
5
5
  }
6
+ export declare function unionPaths(a: string[], b: string[]): string[];
6
7
  export declare function selectBackupFiles(input: {
7
8
  repoKey: string;
8
9
  statusPaths: string[];
@@ -14,6 +15,7 @@ export declare function selectBackupFiles(input: {
14
15
  };
15
16
  import { readConfig } from "../utils";
16
17
  import { loadCache, saveCache } from "./cache";
18
+ export declare function gitCandidatePaths(root: string): string[];
17
19
  export declare function runBackup(cwd: string, hookScriptPath: string): void;
18
20
  export interface BackupUploadDeps {
19
21
  readConfig: typeof readConfig;
@@ -33,11 +33,29 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.unionPaths = unionPaths;
36
37
  exports.selectBackupFiles = selectBackupFiles;
38
+ exports.gitCandidatePaths = gitCandidatePaths;
37
39
  exports.runBackup = runBackup;
38
40
  exports.runBackupUpload = runBackupUpload;
39
41
  const match_1 = require("./match");
40
42
  const cache_1 = require("./cache");
43
+ // Pure: merge two path lists into one, deduped, preserving first-seen order.
44
+ function unionPaths(a, b) {
45
+ const seen = new Set();
46
+ const out = [];
47
+ for (const p of a)
48
+ if (!seen.has(p)) {
49
+ seen.add(p);
50
+ out.push(p);
51
+ }
52
+ for (const p of b)
53
+ if (!seen.has(p)) {
54
+ seen.add(p);
55
+ out.push(p);
56
+ }
57
+ return out;
58
+ }
41
59
  // Pure: from this turn's git-status paths, keep artifact files whose content
42
60
  // hash changed since the cache. Returns the changed candidates + next cache.
43
61
  function selectBackupFiles(input) {
@@ -95,6 +113,27 @@ function gitStatusPaths(root) {
95
113
  return [];
96
114
  }
97
115
  }
116
+ // All git-tracked files (committed, regardless of dirty state). `git ls-files`
117
+ // emits bare NUL-separated paths — no "XY " status prefix — so we don't slice.
118
+ function gitTrackedPaths(root) {
119
+ try {
120
+ const out = (0, child_process_1.execFileSync)("git", ["-C", root, "ls-files", "-z"], {
121
+ encoding: "utf8",
122
+ });
123
+ return out.split("\0").filter(Boolean);
124
+ }
125
+ catch {
126
+ return [];
127
+ }
128
+ }
129
+ // The hook's candidate set: dirty/untracked files ∪ all tracked files. Feeding
130
+ // tracked files in is what lets a COMMITTED artifact (clean status) still get
131
+ // backed up — the hash cache in selectBackupFiles dedups so already-backed-up
132
+ // files don't re-upload. selectBackupFiles rejects non-artifact paths cheaply
133
+ // (by regex, no content read), so the full tracked list is fine to pass.
134
+ function gitCandidatePaths(root) {
135
+ return unionPaths(gitStatusPaths(root), gitTrackedPaths(root));
136
+ }
98
137
  const sha256 = (s) => crypto.createHash("sha256").update(s, "utf8").digest("hex");
99
138
  // Stop/sessionEnd hook entry. Best-effort, never throws.
100
139
  function runBackup(cwd, hookScriptPath) {
@@ -108,7 +147,9 @@ function runBackup(cwd, hookScriptPath) {
108
147
  if (!root)
109
148
  return;
110
149
  const repoKey = (0, utils_1.getGitRemoteUrl)(root) ?? root;
111
- const statusPaths = gitStatusPaths(root);
150
+ // Dirty files ∪ tracked files — so committed-but-unbacked artifacts are
151
+ // caught too, not just unsaved drafts. Hash cache dedups the rest.
152
+ const statusPaths = gitCandidatePaths(root);
112
153
  if (statusPaths.length === 0)
113
154
  return;
114
155
  const prevCache = (0, cache_2.loadCache)();
package/dist/hook.js CHANGED
@@ -236,31 +236,33 @@ async function main() {
236
236
  if (!remoteUrl) {
237
237
  remoteUrl = (0, utils_1.getGitRemoteUrl)(workspaceRoot);
238
238
  }
239
- if (!remoteUrl) {
240
- process.exit(0);
241
- }
242
- // Best-effort local match — backend re-resolves from `remoteUrl` against
243
- // the company's current repo list, so a stale or empty local cache
244
- // doesn't drop the session. (Pre-0.7.7 we exited 0 here, which silently
245
- // dropped sessions for repos added after the user's last `connect`.)
246
- const matchedRepo = (0, utils_1.matchRepo)(remoteUrl, config.repos);
239
+ // Capture-first: the transcript is the asset; the repo is just a tag. We do
240
+ // NOT bail when `remoteUrl` is null — that previously dropped EVERY session
241
+ // for users whose workspace couldn't resolve a remote (no git binary on lean
242
+ // Alpine images, no `origin`, or a submodule whose parent .git isn't mounted),
243
+ // with no error and a green `doctor`. Upload regardless; the backend accepts
244
+ // an unmatched capture (repoId null) and surfaces it for triage.
245
+ //
246
+ // Best-effort local match — backend re-resolves from `remoteUrl` against the
247
+ // company's current repo list, so a stale/empty local cache doesn't matter.
248
+ const matchedRepo = remoteUrl ? (0, utils_1.matchRepo)(remoteUrl, config.repos) : null;
247
249
  const stats = mode === "codex"
248
250
  ? (0, utils_1.parseCodexStats)(transcriptContent)
249
251
  : mode === "cursor"
250
252
  ? { messageCount: 0, toolCallCount: 0 }
251
253
  : (0, utils_1.parseClaudeCodeStats)(transcriptContent);
252
254
  const transcriptBase64 = Buffer.from(transcriptContent, "utf8").toString("base64");
253
- const payload = {
255
+ const payload = (0, utils_1.buildCapturePayload)({
254
256
  sessionId: session_id,
255
- repoUrl: remoteUrl,
256
- repoId: matchedRepo?.repoId,
257
+ transcriptBase64,
258
+ remoteUrl,
259
+ matchedRepoId: matchedRepo?.repoId,
257
260
  companyId: config.companyId,
258
- transcript: transcriptBase64,
259
261
  messageCount: stats.messageCount,
260
262
  toolCallCount: stats.toolCallCount,
261
263
  capturedAt: Date.now(),
262
264
  source: mode,
263
- };
265
+ });
264
266
  // Fire the upload from a fully detached child and exit immediately. Awaiting
265
267
  // it inline let Claude Code's SessionEnd shutdown window elapse and kill the
266
268
  // still-running hook ("Hook cancelled") on large transcripts — even though
@@ -6,7 +6,7 @@ export interface RepoLite {
6
6
  gitProvider?: "github" | "gitlab";
7
7
  }
8
8
  export interface ResolveInput {
9
- remoteUrl: string;
9
+ remoteUrl: string | null | undefined;
10
10
  providedRepoId: string | null;
11
11
  companyRepos: ReadonlyArray<RepoLite>;
12
12
  }
@@ -30,7 +30,7 @@ function resolveRepoForCapture(input) {
30
30
  if (hit)
31
31
  return { repoId: hit._id, source: "provided" };
32
32
  }
33
- const parsed = parseRemoteUrl(remoteUrl);
33
+ const parsed = remoteUrl ? parseRemoteUrl(remoteUrl) : null;
34
34
  if (!parsed) {
35
35
  return { repoId: null, source: "unmatched" };
36
36
  }
package/dist/utils.d.ts CHANGED
@@ -14,6 +14,8 @@ export interface CodiedevConfig {
14
14
  }
15
15
  export declare function readConfig(): CodiedevConfig | null;
16
16
  export declare function writeConfig(config: CodiedevConfig): void;
17
+ export declare function parseGitRemoteFromConfig(configText: string): string | null;
18
+ export declare function readGitRemoteFromConfig(startDir: string): string | null;
17
19
  export declare function getGitRemoteUrl(cwd: string): string | null;
18
20
  export declare function normalizeRepoUrl(url: string): string;
19
21
  export declare function matchRepo(remoteUrl: string, repos: Array<{
@@ -25,6 +27,18 @@ export declare function matchRepo(remoteUrl: string, repos: Array<{
25
27
  url: string;
26
28
  fullName: string;
27
29
  } | null;
30
+ export interface CapturePayloadInput {
31
+ sessionId?: string;
32
+ transcriptBase64: string;
33
+ remoteUrl: string | null;
34
+ matchedRepoId?: string;
35
+ companyId: string;
36
+ messageCount: number;
37
+ toolCallCount: number;
38
+ capturedAt: number;
39
+ source: string;
40
+ }
41
+ export declare function buildCapturePayload(input: CapturePayloadInput): Record<string, unknown>;
28
42
  export declare function hashToken(token: string): string;
29
43
  export declare function claudeCodeInstalled(): boolean;
30
44
  export declare function codexInstalled(): boolean;
package/dist/utils.js CHANGED
@@ -35,9 +35,12 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.readConfig = readConfig;
37
37
  exports.writeConfig = writeConfig;
38
+ exports.parseGitRemoteFromConfig = parseGitRemoteFromConfig;
39
+ exports.readGitRemoteFromConfig = readGitRemoteFromConfig;
38
40
  exports.getGitRemoteUrl = getGitRemoteUrl;
39
41
  exports.normalizeRepoUrl = normalizeRepoUrl;
40
42
  exports.matchRepo = matchRepo;
43
+ exports.buildCapturePayload = buildCapturePayload;
41
44
  exports.hashToken = hashToken;
42
45
  exports.claudeCodeInstalled = claudeCodeInstalled;
43
46
  exports.codexInstalled = codexInstalled;
@@ -89,7 +92,115 @@ function writeConfig(config) {
89
92
  }
90
93
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf8");
91
94
  }
95
+ // Parse a remote URL out of git-config INI text WITHOUT invoking git. Prefers
96
+ // the "origin" remote; falls back to the first remote found. Returns null if
97
+ // there are no remote sections.
98
+ function parseGitRemoteFromConfig(configText) {
99
+ const remotes = {};
100
+ let current = null;
101
+ for (const raw of configText.split(/\r?\n/)) {
102
+ const line = raw.trim();
103
+ const remoteHeader = line.match(/^\[\s*remote\s+"([^"]+)"\s*\]$/);
104
+ if (remoteHeader) {
105
+ current = remoteHeader[1];
106
+ continue;
107
+ }
108
+ if (line.startsWith("[")) {
109
+ // Any other section header ends the current remote context, so a
110
+ // `url =` under e.g. [core] or [some-section] is never mistaken for one.
111
+ current = null;
112
+ continue;
113
+ }
114
+ if (current) {
115
+ const kv = line.match(/^url\s*=\s*(.+)$/);
116
+ if (kv && remotes[current] === undefined) {
117
+ remotes[current] = kv[1].trim();
118
+ }
119
+ }
120
+ }
121
+ if (remotes.origin)
122
+ return remotes.origin;
123
+ const first = Object.values(remotes)[0];
124
+ return first ?? null;
125
+ }
126
+ // Locate the git directory for `startDir`, walking up parent directories.
127
+ // Handles a `.git` directory (normal repo), a `.git` FILE containing
128
+ // `gitdir: <path>` (submodules, some devcontainer/worktree layouts), and stops
129
+ // at the filesystem root. Returns the resolved git dir path, or null.
130
+ function resolveGitDir(startDir) {
131
+ let dir = path.resolve(startDir);
132
+ // eslint-disable-next-line no-constant-condition
133
+ while (true) {
134
+ const dotGit = path.join(dir, ".git");
135
+ try {
136
+ const stat = fs.statSync(dotGit);
137
+ if (stat.isDirectory())
138
+ return dotGit;
139
+ if (stat.isFile()) {
140
+ const m = fs.readFileSync(dotGit, "utf8").match(/^gitdir:\s*(.+)\s*$/m);
141
+ if (m) {
142
+ const target = m[1].trim();
143
+ return path.isAbsolute(target) ? target : path.resolve(dir, target);
144
+ }
145
+ }
146
+ }
147
+ catch {
148
+ // No .git here — keep walking up.
149
+ }
150
+ const parent = path.dirname(dir);
151
+ if (parent === dir)
152
+ return null;
153
+ dir = parent;
154
+ }
155
+ }
156
+ // Ordered list of config files that may hold the remotes for a git dir. Linked
157
+ // worktrees keep their remotes in the COMMON (main) git dir, pointed at by a
158
+ // `commondir` file, so we check that first, then the git dir's own config.
159
+ function gitConfigCandidates(gitDir) {
160
+ const candidates = [];
161
+ try {
162
+ const commondirFile = path.join(gitDir, "commondir");
163
+ if (fs.existsSync(commondirFile)) {
164
+ const rel = fs.readFileSync(commondirFile, "utf8").trim();
165
+ const commonDir = path.isAbsolute(rel) ? rel : path.resolve(gitDir, rel);
166
+ candidates.push(path.join(commonDir, "config"));
167
+ }
168
+ }
169
+ catch {
170
+ // ignore — fall through to the plain config below
171
+ }
172
+ candidates.push(path.join(gitDir, "config"));
173
+ return candidates;
174
+ }
175
+ // Resolve a repo's remote URL by reading .git/config directly — no `git`
176
+ // binary required. This is the primary path so lean container images (Alpine
177
+ // devcontainers without git) still capture sessions instead of silently
178
+ // dropping them.
179
+ function readGitRemoteFromConfig(startDir) {
180
+ const gitDir = resolveGitDir(startDir);
181
+ if (!gitDir)
182
+ return null;
183
+ for (const cfg of gitConfigCandidates(gitDir)) {
184
+ try {
185
+ const url = parseGitRemoteFromConfig(fs.readFileSync(cfg, "utf8"));
186
+ if (url)
187
+ return url;
188
+ }
189
+ catch {
190
+ // try the next candidate
191
+ }
192
+ }
193
+ return null;
194
+ }
92
195
  function getGitRemoteUrl(cwd) {
196
+ // Primary: read .git/config directly. Works even when the `git` binary is
197
+ // absent (lean Alpine/devcontainer images), which previously made the
198
+ // SessionEnd hook silently drop every session.
199
+ const fromConfig = readGitRemoteFromConfig(cwd);
200
+ if (fromConfig)
201
+ return fromConfig;
202
+ // Fallback: ask git itself, for setups the direct read doesn't cover
203
+ // (insteadOf rewrites, includeIf'd remotes, unusual gitdir indirection).
93
204
  try {
94
205
  const url = (0, child_process_1.execSync)("git remote get-url origin", {
95
206
  cwd,
@@ -136,6 +247,19 @@ function matchRepo(remoteUrl, repos) {
136
247
  }
137
248
  return null;
138
249
  }
250
+ function buildCapturePayload(input) {
251
+ return {
252
+ sessionId: input.sessionId,
253
+ repoUrl: input.remoteUrl ?? "",
254
+ repoId: input.matchedRepoId,
255
+ companyId: input.companyId,
256
+ transcript: input.transcriptBase64,
257
+ messageCount: input.messageCount,
258
+ toolCallCount: input.toolCallCount,
259
+ capturedAt: input.capturedAt,
260
+ source: input.source,
261
+ };
262
+ }
139
263
  function hashToken(token) {
140
264
  let hash = 0;
141
265
  for (let i = 0; i < token.length; i++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codiedev",
3
- "version": "0.9.4",
3
+ "version": "0.9.6-beta.0",
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",