codiedev 0.9.12 → 0.9.13
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/captureReadiness.d.ts +36 -0
- package/dist/captureReadiness.js +107 -0
- package/dist/commands/doctor.js +75 -0
- package/dist/hook.js +3 -0
- package/dist/utils.d.ts +23 -0
- package/dist/utils.js +50 -1
- package/package.json +1 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { LastCaptureRecord } from "./utils";
|
|
2
|
+
export interface CaptureReadinessDeps<R = unknown> {
|
|
3
|
+
exec: (cmd: string, cwd: string) => string;
|
|
4
|
+
getRemoteUrl: (cwd: string) => string | null;
|
|
5
|
+
matchRepo: (url: string, repos: R[]) => R | null;
|
|
6
|
+
getBranch: (cwd: string) => string | null;
|
|
7
|
+
getHeadSha: (cwd: string) => string | null;
|
|
8
|
+
}
|
|
9
|
+
export interface CaptureReadiness {
|
|
10
|
+
gitOnPath: boolean;
|
|
11
|
+
insideWorkTree: boolean;
|
|
12
|
+
remoteUrl: string | null;
|
|
13
|
+
matched: boolean;
|
|
14
|
+
branch: string | null;
|
|
15
|
+
headSha: string | null;
|
|
16
|
+
}
|
|
17
|
+
export declare function assessCaptureReadiness<R>(cwd: string, repos: R[], deps: CaptureReadinessDeps<R>): CaptureReadiness;
|
|
18
|
+
export declare function formatRelativeAge(ms: number): string;
|
|
19
|
+
export interface LastCaptureSummary {
|
|
20
|
+
status: "pass" | "warn";
|
|
21
|
+
detail: string;
|
|
22
|
+
}
|
|
23
|
+
export declare function summarizeLastCapture(record: LastCaptureRecord | null, nowMs: number): LastCaptureSummary;
|
|
24
|
+
export interface ServerCaptureResponse {
|
|
25
|
+
found: boolean;
|
|
26
|
+
latest: {
|
|
27
|
+
sessionId: string;
|
|
28
|
+
capturedAt: number;
|
|
29
|
+
createdAt: number;
|
|
30
|
+
repoMatched: boolean;
|
|
31
|
+
branch: string | null;
|
|
32
|
+
headSha: string | null;
|
|
33
|
+
} | null;
|
|
34
|
+
sessionFound?: boolean;
|
|
35
|
+
}
|
|
36
|
+
export declare function summarizeServerCapture(server: ServerCaptureResponse | null, local: LastCaptureRecord | null, nowMs: number): LastCaptureSummary;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Capture-readiness assessment for `codiedev doctor`. Answers: "from where I'm
|
|
3
|
+
// standing right now, would a session actually capture?" — the blind spot that
|
|
4
|
+
// let lean-container / submodule / no-remote setups silently produce zero
|
|
5
|
+
// sessions while doctor's setup checks stayed green.
|
|
6
|
+
//
|
|
7
|
+
// Pure + fully injectable so it unit-tests without a real git repo. The default
|
|
8
|
+
// production wiring (in doctor.ts) plugs in execSync + the real git helpers.
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.assessCaptureReadiness = assessCaptureReadiness;
|
|
11
|
+
exports.formatRelativeAge = formatRelativeAge;
|
|
12
|
+
exports.summarizeLastCapture = summarizeLastCapture;
|
|
13
|
+
exports.summarizeServerCapture = summarizeServerCapture;
|
|
14
|
+
function assessCaptureReadiness(cwd, repos, deps) {
|
|
15
|
+
let gitOnPath = false;
|
|
16
|
+
try {
|
|
17
|
+
deps.exec("git --version", cwd);
|
|
18
|
+
gitOnPath = true;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
gitOnPath = false;
|
|
22
|
+
}
|
|
23
|
+
let insideWorkTree = false;
|
|
24
|
+
if (gitOnPath) {
|
|
25
|
+
try {
|
|
26
|
+
const out = deps.exec("git rev-parse --is-inside-work-tree", cwd);
|
|
27
|
+
insideWorkTree = out.trim() === "true";
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
insideWorkTree = false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const remoteUrl = deps.getRemoteUrl(cwd);
|
|
34
|
+
const matched = remoteUrl ? deps.matchRepo(remoteUrl, repos) !== null : false;
|
|
35
|
+
return {
|
|
36
|
+
gitOnPath,
|
|
37
|
+
insideWorkTree,
|
|
38
|
+
remoteUrl,
|
|
39
|
+
matched,
|
|
40
|
+
branch: deps.getBranch(cwd),
|
|
41
|
+
headSha: deps.getHeadSha(cwd),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function formatRelativeAge(ms) {
|
|
45
|
+
if (ms < 0)
|
|
46
|
+
ms = 0;
|
|
47
|
+
const s = Math.floor(ms / 1000);
|
|
48
|
+
if (s < 60)
|
|
49
|
+
return `${s}s`;
|
|
50
|
+
const m = Math.floor(s / 60);
|
|
51
|
+
if (m < 60)
|
|
52
|
+
return `${m}m`;
|
|
53
|
+
const h = Math.floor(m / 60);
|
|
54
|
+
if (h < 24)
|
|
55
|
+
return `${h}h`;
|
|
56
|
+
return `${Math.floor(h / 24)}d`;
|
|
57
|
+
}
|
|
58
|
+
// Turns the "last session captured" breadcrumb into a doctor line. This is the
|
|
59
|
+
// payoff check: it lets a user confirm their most recent session actually
|
|
60
|
+
// landed without opening the portal.
|
|
61
|
+
function summarizeLastCapture(record, nowMs) {
|
|
62
|
+
if (!record) {
|
|
63
|
+
return {
|
|
64
|
+
status: "warn",
|
|
65
|
+
detail: "no session captured yet from this machine — run a session, exit, then re-run `codiedev doctor`",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const age = formatRelativeAge(nowMs - record.recordedAt);
|
|
69
|
+
if (!record.matched) {
|
|
70
|
+
return {
|
|
71
|
+
status: "warn",
|
|
72
|
+
detail: `last session captured ${age} ago, but NOT matched to a tracked repo (${record.repoUrl || "no remote"}) — link the repo in the portal`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return { status: "pass", detail: `last session captured ${age} ago` };
|
|
76
|
+
}
|
|
77
|
+
// Server-confirmed "did my last session land?". Prefers portal truth over the
|
|
78
|
+
// local 2xx breadcrumb; the highest-value branch is "uploaded locally but the
|
|
79
|
+
// portal has no record" — the exact silent failure devs hit (2xx to nothing,
|
|
80
|
+
// wrong identity, backend drop).
|
|
81
|
+
function summarizeServerCapture(server, local, nowMs) {
|
|
82
|
+
// Endpoint unavailable / old backend → fall back to the local breadcrumb.
|
|
83
|
+
if (server === null)
|
|
84
|
+
return summarizeLastCapture(local, nowMs);
|
|
85
|
+
if (server.found && server.latest) {
|
|
86
|
+
const age = formatRelativeAge(nowMs - server.latest.createdAt);
|
|
87
|
+
if (!server.latest.repoMatched) {
|
|
88
|
+
return {
|
|
89
|
+
status: "warn",
|
|
90
|
+
detail: `last session is in the portal (${age} ago) but NOT matched to a tracked repo — link the repo in the portal`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return { status: "pass", detail: `confirmed in the portal — last session captured ${age} ago` };
|
|
94
|
+
}
|
|
95
|
+
// Server reachable but has no session for this user.
|
|
96
|
+
if (local) {
|
|
97
|
+
const age = formatRelativeAge(nowMs - local.recordedAt);
|
|
98
|
+
return {
|
|
99
|
+
status: "warn",
|
|
100
|
+
detail: `uploaded from this machine ${age} ago but NOT found in the portal — capture isn't landing (check \`codiedev connect\`, token, and that the repo is linked)`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
status: "warn",
|
|
105
|
+
detail: "no session captured yet from this machine — run a session, exit, then re-run `codiedev doctor`",
|
|
106
|
+
};
|
|
107
|
+
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -43,6 +43,7 @@ const utils_1 = require("../utils");
|
|
|
43
43
|
const detection_1 = require("../detection");
|
|
44
44
|
const version_1 = require("../version");
|
|
45
45
|
const upgradeNudge_1 = require("../upgradeNudge");
|
|
46
|
+
const captureReadiness_1 = require("../captureReadiness");
|
|
46
47
|
const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
|
|
47
48
|
const CLAUDE_INSTRUCTIONS_PATH = path.join(os.homedir(), ".claude", "CLAUDE.md");
|
|
48
49
|
const CODEX_HOOKS_PATH = path.join(os.homedir(), ".codex", "hooks.json");
|
|
@@ -210,6 +211,80 @@ async function runDoctor(_args) {
|
|
|
210
211
|
? `${repoCount} repo${repoCount === 1 ? "" : "s"}`
|
|
211
212
|
: "0 — link repos in the portal so sessions get captured",
|
|
212
213
|
});
|
|
214
|
+
// 4b. Capture readiness — the part that actually decides whether a session
|
|
215
|
+
// lands. doctor used to verify only that the hook was *installed*; these
|
|
216
|
+
// checks answer "from this directory, would a session capture, and did my
|
|
217
|
+
// last one?" — the blind spot behind lean-container / submodule / no-remote
|
|
218
|
+
// zeros.
|
|
219
|
+
const cwd = process.cwd();
|
|
220
|
+
const readiness = (0, captureReadiness_1.assessCaptureReadiness)(cwd, config.repos ?? [], {
|
|
221
|
+
exec: (cmd, dir) => (0, child_process_1.execSync)(cmd, { cwd: dir, stdio: ["pipe", "pipe", "pipe"] }).toString(),
|
|
222
|
+
getRemoteUrl: utils_1.getGitRemoteUrl,
|
|
223
|
+
matchRepo: utils_1.matchRepo,
|
|
224
|
+
getBranch: utils_1.getGitBranch,
|
|
225
|
+
getHeadSha: utils_1.getGitHeadSha,
|
|
226
|
+
});
|
|
227
|
+
checks.push({
|
|
228
|
+
name: "git on PATH",
|
|
229
|
+
status: readiness.gitOnPath ? "pass" : "fail",
|
|
230
|
+
detail: readiness.gitOnPath
|
|
231
|
+
? undefined
|
|
232
|
+
: "not found — capture can't tag sessions to a repo (install git, e.g. `apk add git`)",
|
|
233
|
+
});
|
|
234
|
+
checks.push({
|
|
235
|
+
name: "Inside a git work tree (current dir)",
|
|
236
|
+
status: readiness.insideWorkTree ? "pass" : "warn",
|
|
237
|
+
detail: readiness.insideWorkTree
|
|
238
|
+
? undefined
|
|
239
|
+
: "current dir isn't a git work tree — if your repo is a mounted submodule, mount the full project so its .git is present",
|
|
240
|
+
});
|
|
241
|
+
checks.push({
|
|
242
|
+
name: "Current repo resolves to a tracked repo",
|
|
243
|
+
status: readiness.matched ? "pass" : "warn",
|
|
244
|
+
detail: readiness.matched
|
|
245
|
+
? `${readiness.remoteUrl}`
|
|
246
|
+
: readiness.remoteUrl
|
|
247
|
+
? `remote ${readiness.remoteUrl} isn't a tracked repo — link it in the portal`
|
|
248
|
+
: "no origin remote here — capture uploads but won't link to a repo",
|
|
249
|
+
});
|
|
250
|
+
checks.push({
|
|
251
|
+
name: "What this session would capture",
|
|
252
|
+
status: readiness.branch || readiness.headSha ? "pass" : "warn",
|
|
253
|
+
detail: readiness.branch || readiness.headSha
|
|
254
|
+
? `branch ${readiness.branch ?? "—"} @ ${(readiness.headSha ?? "—").slice(0, 8)}`
|
|
255
|
+
: "no branch/sha resolved — session→merge attribution will be weaker",
|
|
256
|
+
});
|
|
257
|
+
// 4c. The payoff: did the last session actually land? Ask the server for
|
|
258
|
+
// portal truth (does the row exist for this user?), falling back to the local
|
|
259
|
+
// 2xx breadcrumb if the endpoint is unreachable or predates this CLI/backend.
|
|
260
|
+
const localMarker = (0, utils_1.readLastCapture)();
|
|
261
|
+
let server = null;
|
|
262
|
+
try {
|
|
263
|
+
const u = new URL(`${config.backendUrl}/api/cli/lastCapture`);
|
|
264
|
+
if (localMarker?.sessionId)
|
|
265
|
+
u.searchParams.set("sessionId", localMarker.sessionId);
|
|
266
|
+
const ctrl = new AbortController();
|
|
267
|
+
const timer = setTimeout(() => ctrl.abort(), 3000);
|
|
268
|
+
const res = await fetch(u.toString(), {
|
|
269
|
+
headers: {
|
|
270
|
+
Authorization: `Bearer ${config.token}`,
|
|
271
|
+
"X-Codiedev-Cli-Version": version_1.CLI_VERSION,
|
|
272
|
+
},
|
|
273
|
+
signal: ctrl.signal,
|
|
274
|
+
});
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
if (res.ok)
|
|
277
|
+
server = (await res.json());
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
server = null; // unreachable / old backend → fall back to the local marker
|
|
281
|
+
}
|
|
282
|
+
const lastCapture = (0, captureReadiness_1.summarizeServerCapture)(server, localMarker, Date.now());
|
|
283
|
+
checks.push({
|
|
284
|
+
name: "Last session captured",
|
|
285
|
+
status: lastCapture.status,
|
|
286
|
+
detail: lastCapture.detail,
|
|
287
|
+
});
|
|
213
288
|
// 5. Claude Code setup (if present)
|
|
214
289
|
if ((0, utils_1.claudeCodeInstalled)()) {
|
|
215
290
|
checks.push({
|
package/dist/hook.js
CHANGED
|
@@ -171,6 +171,9 @@ async function main() {
|
|
|
171
171
|
// Persist a bumped attempt count on failure so a later sweep retries
|
|
172
172
|
// (instead of dropping a session on a transient network blip).
|
|
173
173
|
writeFileSync: fs.writeFileSync,
|
|
174
|
+
// On a successful upload, drop the "last session captured" breadcrumb
|
|
175
|
+
// that `codiedev doctor` reads to confirm the session landed.
|
|
176
|
+
recordSuccess: (job) => (0, utils_1.writeLastCapture)((0, utils_1.buildLastCaptureRecord)(job, Date.now())),
|
|
174
177
|
});
|
|
175
178
|
}
|
|
176
179
|
process.exit(0);
|
package/dist/utils.d.ts
CHANGED
|
@@ -188,6 +188,29 @@ interface UploadDeps {
|
|
|
188
188
|
unlinkSync: typeof fs.unlinkSync;
|
|
189
189
|
writeFileSync?: typeof fs.writeFileSync;
|
|
190
190
|
now?: () => number;
|
|
191
|
+
recordSuccess?: (job: CaptureUploadJob) => void;
|
|
191
192
|
}
|
|
193
|
+
export interface LastCaptureRecord {
|
|
194
|
+
sessionId: string | undefined;
|
|
195
|
+
repoUrl: string;
|
|
196
|
+
matched: boolean;
|
|
197
|
+
branch: string;
|
|
198
|
+
headSha: string;
|
|
199
|
+
source: string;
|
|
200
|
+
capturedAt: number | undefined;
|
|
201
|
+
recordedAt: number;
|
|
202
|
+
}
|
|
203
|
+
export declare function buildLastCaptureRecord(job: CaptureUploadJob, now: number): LastCaptureRecord;
|
|
204
|
+
interface LastCaptureWriteDeps {
|
|
205
|
+
writeFileSync: typeof fs.writeFileSync;
|
|
206
|
+
existsSync: typeof fs.existsSync;
|
|
207
|
+
mkdirSync: typeof fs.mkdirSync;
|
|
208
|
+
}
|
|
209
|
+
export declare function writeLastCapture(record: LastCaptureRecord, deps?: LastCaptureWriteDeps, filePath?: string): void;
|
|
210
|
+
interface LastCaptureReadDeps {
|
|
211
|
+
readFileSync: typeof fs.readFileSync;
|
|
212
|
+
existsSync: typeof fs.existsSync;
|
|
213
|
+
}
|
|
214
|
+
export declare function readLastCapture(deps?: LastCaptureReadDeps, filePath?: string): LastCaptureRecord | null;
|
|
192
215
|
export declare function runCaptureUpload(jobFile: string, deps: UploadDeps): Promise<void>;
|
|
193
216
|
export {};
|
package/dist/utils.js
CHANGED
|
@@ -81,6 +81,9 @@ exports.parseCodexStats = parseCodexStats;
|
|
|
81
81
|
exports.extractCodexRepoUrl = extractCodexRepoUrl;
|
|
82
82
|
exports.dispatchCaptureDetached = dispatchCaptureDetached;
|
|
83
83
|
exports.sweepPendingCaptures = sweepPendingCaptures;
|
|
84
|
+
exports.buildLastCaptureRecord = buildLastCaptureRecord;
|
|
85
|
+
exports.writeLastCapture = writeLastCapture;
|
|
86
|
+
exports.readLastCapture = readLastCapture;
|
|
84
87
|
exports.runCaptureUpload = runCaptureUpload;
|
|
85
88
|
const fs = __importStar(require("fs"));
|
|
86
89
|
const path = __importStar(require("path"));
|
|
@@ -1492,6 +1495,45 @@ function sweepPendingCaptures(deps) {
|
|
|
1492
1495
|
}
|
|
1493
1496
|
}
|
|
1494
1497
|
}
|
|
1498
|
+
// "Last session captured" breadcrumb. Written next to config.json on a
|
|
1499
|
+
// successful capture upload; read by `codiedev doctor` so a user can confirm
|
|
1500
|
+
// their most recent session actually landed (the single hardest thing to
|
|
1501
|
+
// self-verify today — it previously required eyeballing the portal).
|
|
1502
|
+
const LAST_CAPTURE_PATH = path.join(CONFIG_DIR, "last-capture.json");
|
|
1503
|
+
function buildLastCaptureRecord(job, now) {
|
|
1504
|
+
const b = job.body;
|
|
1505
|
+
return {
|
|
1506
|
+
sessionId: b.sessionId,
|
|
1507
|
+
repoUrl: b.repoUrl ?? "",
|
|
1508
|
+
matched: Boolean(b.repoId),
|
|
1509
|
+
branch: b.branch ?? "",
|
|
1510
|
+
headSha: b.headSha ?? "",
|
|
1511
|
+
source: b.source ?? "",
|
|
1512
|
+
capturedAt: b.capturedAt,
|
|
1513
|
+
recordedAt: now,
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
function writeLastCapture(record, deps = fs, filePath = LAST_CAPTURE_PATH) {
|
|
1517
|
+
try {
|
|
1518
|
+
const dir = path.dirname(filePath);
|
|
1519
|
+
if (!deps.existsSync(dir))
|
|
1520
|
+
deps.mkdirSync(dir, { recursive: true });
|
|
1521
|
+
deps.writeFileSync(filePath, JSON.stringify(record), { mode: 0o600 });
|
|
1522
|
+
}
|
|
1523
|
+
catch {
|
|
1524
|
+
// breadcrumb is best-effort — never throw on the capture path.
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
function readLastCapture(deps = fs, filePath = LAST_CAPTURE_PATH) {
|
|
1528
|
+
try {
|
|
1529
|
+
if (!deps.existsSync(filePath))
|
|
1530
|
+
return null;
|
|
1531
|
+
return JSON.parse(deps.readFileSync(filePath, "utf8"));
|
|
1532
|
+
}
|
|
1533
|
+
catch {
|
|
1534
|
+
return null;
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1495
1537
|
// Runs inside the detached child spawned by `dispatchCaptureDetached`: read the
|
|
1496
1538
|
// persisted job, POST it, and ALWAYS delete the temp file afterwards (even on
|
|
1497
1539
|
// failure) so we don't litter the tmp dir. Never throws — capture is
|
|
@@ -1511,7 +1553,14 @@ async function runCaptureUpload(jobFile, deps) {
|
|
|
1511
1553
|
}
|
|
1512
1554
|
try {
|
|
1513
1555
|
await deps.post(job.url, job.body, job.token);
|
|
1514
|
-
// success —
|
|
1556
|
+
// success — drop a "last session captured" breadcrumb (before removing the
|
|
1557
|
+
// job) so doctor can confirm the most recent session actually landed.
|
|
1558
|
+
if (deps.recordSuccess) {
|
|
1559
|
+
try {
|
|
1560
|
+
deps.recordSuccess(job);
|
|
1561
|
+
}
|
|
1562
|
+
catch { /* breadcrumb is best-effort */ }
|
|
1563
|
+
}
|
|
1515
1564
|
try {
|
|
1516
1565
|
deps.unlinkSync(jobFile);
|
|
1517
1566
|
}
|
package/package.json
CHANGED