codiedev 0.9.24 → 0.9.26
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/README.md +17 -1
- package/dist/backup/run.js +5 -0
- package/dist/captureExclusions.d.ts +30 -0
- package/dist/captureExclusions.js +118 -0
- package/dist/cli.js +31 -0
- package/dist/commands/capture.d.ts +14 -0
- package/dist/commands/capture.js +148 -0
- package/dist/commands/doctor.js +56 -5
- package/dist/connect.js +19 -0
- package/dist/hook.js +38 -2
- package/dist/utils.d.ts +9 -0
- package/dist/utils.js +135 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,12 +17,28 @@ If both are installed, hooks are wired up for both. If neither is installed, the
|
|
|
17
17
|
|
|
18
18
|
## What gets captured
|
|
19
19
|
|
|
20
|
-
-
|
|
20
|
+
- Agent sessions from connected devices (capture is enabled by default)
|
|
21
21
|
- Transcript + basic stats (message count, tool calls)
|
|
22
22
|
- Processed into summaries and embeddings server-side for search in `/portal/memories`
|
|
23
23
|
|
|
24
24
|
Each captured memory records its `source` (`claude-code` or `codex`) so you can filter in the portal.
|
|
25
25
|
|
|
26
|
+
## Keep folders local
|
|
27
|
+
|
|
28
|
+
Folder exclusions are device-specific and include every child folder. The CLI
|
|
29
|
+
writes the rule locally before attempting to sync its read-only status to the
|
|
30
|
+
portal, so an offline or failed status sync never weakens the exclusion.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
codiedev ignore ~/Obsidian
|
|
34
|
+
codiedev ignored ~/Obsidian/People # check whether a path is ignored
|
|
35
|
+
codiedev ignored # list every ignored folder
|
|
36
|
+
codiedev unignore ~/Obsidian
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Run `ignore` once per device or persistent VM/container. A new ephemeral
|
|
40
|
+
container needs its own rule unless `~/.codiedev/config.json` is persisted.
|
|
41
|
+
|
|
26
42
|
## Codex note
|
|
27
43
|
|
|
28
44
|
Codex doesn't currently expose a `SessionEnd` event, so the hook runs on `Stop` (fires after each assistant turn). The backend dedupes by `(company, session_id)` and replaces the stored transcript on re-upload so the latest turn's snapshot wins. Summarization happens once per session; subsequent turn uploads keep the raw transcript fresh but don't re-summarize (to avoid running up LLM cost).
|
package/dist/backup/run.js
CHANGED
|
@@ -86,6 +86,7 @@ const child_process_1 = require("child_process");
|
|
|
86
86
|
const utils_1 = require("../utils");
|
|
87
87
|
const cache_2 = require("./cache");
|
|
88
88
|
const push = __importStar(require("../commands/push"));
|
|
89
|
+
const captureExclusions_1 = require("../captureExclusions");
|
|
89
90
|
function gitRoot(cwd) {
|
|
90
91
|
try {
|
|
91
92
|
return (0, child_process_1.execFileSync)("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
|
|
@@ -143,6 +144,8 @@ function runBackup(cwd, hookScriptPath) {
|
|
|
143
144
|
const config = (0, utils_1.readConfig)();
|
|
144
145
|
if (!config)
|
|
145
146
|
return;
|
|
147
|
+
if ((0, captureExclusions_1.isCaptureIgnored)(cwd, config.captureIgnoredPaths))
|
|
148
|
+
return;
|
|
146
149
|
const root = gitRoot(cwd);
|
|
147
150
|
if (!root)
|
|
148
151
|
return;
|
|
@@ -195,6 +198,8 @@ async function runBackupUpload(jobFile, deps = DEFAULT_BACKUP_UPLOAD_DEPS) {
|
|
|
195
198
|
const config = deps.readConfig(); // same machine — read full config from disk
|
|
196
199
|
if (!config)
|
|
197
200
|
return;
|
|
201
|
+
if ((0, captureExclusions_1.isCaptureIgnored)(job.root, config.captureIgnoredPaths))
|
|
202
|
+
return;
|
|
198
203
|
const cache = deps.loadCache();
|
|
199
204
|
let dirty = false;
|
|
200
205
|
for (const f of job.files) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CodiedevConfig } from "./utils";
|
|
2
|
+
export interface NormalizeCapturePathDeps {
|
|
3
|
+
cwd?: () => string;
|
|
4
|
+
homedir?: () => string;
|
|
5
|
+
realpathSync?: (value: string) => string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Convert a user-entered path into the canonical absolute form persisted in
|
|
9
|
+
* ~/.codiedev/config.json. Existing symlinks resolve to their real target so a
|
|
10
|
+
* session cannot bypass an exclusion by entering the same folder through a
|
|
11
|
+
* different alias. Non-existent paths still normalize deterministically.
|
|
12
|
+
*/
|
|
13
|
+
export declare function normalizeCapturePath(input: string, deps?: NormalizeCapturePathDeps): string;
|
|
14
|
+
export declare function findMatchingCaptureExclusion(candidate: string | string[] | undefined, ignoredPaths: string[] | undefined): string | null;
|
|
15
|
+
export declare function isCaptureIgnored(candidate: string | string[] | undefined, ignoredPaths: string[] | undefined): boolean;
|
|
16
|
+
export interface CaptureScopeInput {
|
|
17
|
+
cwd?: string;
|
|
18
|
+
workspace_roots?: string[];
|
|
19
|
+
}
|
|
20
|
+
/** Every local root associated with a hook event, deduped in stable order. */
|
|
21
|
+
export declare function captureScopePaths(input: CaptureScopeInput, fallback?: string): string[];
|
|
22
|
+
export declare function shouldIgnoreCaptureInput(config: Pick<CodiedevConfig, "captureIgnoredPaths"> | null | undefined, input: CaptureScopeInput, fallback?: string): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Final egress decision for queued transcript uploads. Jobs written by older
|
|
25
|
+
* CLI versions have no scope metadata; once any exclusion exists, dropping
|
|
26
|
+
* those legacy jobs is safer than guessing that they are public.
|
|
27
|
+
*/
|
|
28
|
+
export declare function shouldSkipCaptureUploadJob(job: {
|
|
29
|
+
scopePaths?: string[];
|
|
30
|
+
}, config: Pick<CodiedevConfig, "captureIgnoredPaths"> | null | undefined): boolean;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.normalizeCapturePath = normalizeCapturePath;
|
|
37
|
+
exports.findMatchingCaptureExclusion = findMatchingCaptureExclusion;
|
|
38
|
+
exports.isCaptureIgnored = isCaptureIgnored;
|
|
39
|
+
exports.captureScopePaths = captureScopePaths;
|
|
40
|
+
exports.shouldIgnoreCaptureInput = shouldIgnoreCaptureInput;
|
|
41
|
+
exports.shouldSkipCaptureUploadJob = shouldSkipCaptureUploadJob;
|
|
42
|
+
const fs = __importStar(require("fs"));
|
|
43
|
+
const os = __importStar(require("os"));
|
|
44
|
+
const path = __importStar(require("path"));
|
|
45
|
+
const DEFAULT_NORMALIZE_DEPS = {
|
|
46
|
+
cwd: () => process.cwd(),
|
|
47
|
+
homedir: () => os.homedir(),
|
|
48
|
+
realpathSync: (value) => fs.realpathSync.native(value),
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Convert a user-entered path into the canonical absolute form persisted in
|
|
52
|
+
* ~/.codiedev/config.json. Existing symlinks resolve to their real target so a
|
|
53
|
+
* session cannot bypass an exclusion by entering the same folder through a
|
|
54
|
+
* different alias. Non-existent paths still normalize deterministically.
|
|
55
|
+
*/
|
|
56
|
+
function normalizeCapturePath(input, deps = {}) {
|
|
57
|
+
const d = { ...DEFAULT_NORMALIZE_DEPS, ...deps };
|
|
58
|
+
const trimmed = input.trim();
|
|
59
|
+
if (!trimmed)
|
|
60
|
+
throw new Error("Folder path is required");
|
|
61
|
+
const expanded = /^~(?=$|[\\/])/.test(trimmed)
|
|
62
|
+
? path.join(d.homedir(), trimmed.slice(1))
|
|
63
|
+
: trimmed;
|
|
64
|
+
const absolute = path.resolve(d.cwd(), expanded);
|
|
65
|
+
try {
|
|
66
|
+
return path.normalize(d.realpathSync(absolute));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return path.normalize(absolute);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function comparisonKey(value) {
|
|
73
|
+
const normalized = path.normalize(value);
|
|
74
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
75
|
+
}
|
|
76
|
+
function containsPath(root, candidate) {
|
|
77
|
+
const relative = path.relative(comparisonKey(root), comparisonKey(candidate));
|
|
78
|
+
return (relative === "" ||
|
|
79
|
+
(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)));
|
|
80
|
+
}
|
|
81
|
+
function findMatchingCaptureExclusion(candidate, ignoredPaths) {
|
|
82
|
+
if (!candidate || !ignoredPaths || ignoredPaths.length === 0)
|
|
83
|
+
return null;
|
|
84
|
+
const candidates = Array.isArray(candidate) ? candidate : [candidate];
|
|
85
|
+
for (const ignored of ignoredPaths) {
|
|
86
|
+
const root = normalizeCapturePath(ignored);
|
|
87
|
+
for (const value of candidates) {
|
|
88
|
+
if (containsPath(root, normalizeCapturePath(value)))
|
|
89
|
+
return root;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
function isCaptureIgnored(candidate, ignoredPaths) {
|
|
95
|
+
return findMatchingCaptureExclusion(candidate, ignoredPaths) !== null;
|
|
96
|
+
}
|
|
97
|
+
/** Every local root associated with a hook event, deduped in stable order. */
|
|
98
|
+
function captureScopePaths(input, fallback = process.cwd()) {
|
|
99
|
+
const values = [input.cwd, ...(input.workspace_roots ?? [])]
|
|
100
|
+
.filter((value) => typeof value === "string" && value.length > 0);
|
|
101
|
+
return [...new Set(values.length > 0 ? values : [fallback])];
|
|
102
|
+
}
|
|
103
|
+
function shouldIgnoreCaptureInput(config, input, fallback) {
|
|
104
|
+
return isCaptureIgnored(captureScopePaths(input, fallback), config?.captureIgnoredPaths);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Final egress decision for queued transcript uploads. Jobs written by older
|
|
108
|
+
* CLI versions have no scope metadata; once any exclusion exists, dropping
|
|
109
|
+
* those legacy jobs is safer than guessing that they are public.
|
|
110
|
+
*/
|
|
111
|
+
function shouldSkipCaptureUploadJob(job, config) {
|
|
112
|
+
const ignoredPaths = config?.captureIgnoredPaths ?? [];
|
|
113
|
+
if (ignoredPaths.length === 0)
|
|
114
|
+
return false;
|
|
115
|
+
if (!job.scopePaths || job.scopePaths.length === 0)
|
|
116
|
+
return true;
|
|
117
|
+
return isCaptureIgnored(job.scopePaths, ignoredPaths);
|
|
118
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -33,6 +33,7 @@ const post_1 = require("./commands/post");
|
|
|
33
33
|
const share_1 = require("./commands/share");
|
|
34
34
|
const send_1 = require("./commands/send");
|
|
35
35
|
const react_1 = require("./commands/react");
|
|
36
|
+
const capture_1 = require("./commands/capture");
|
|
36
37
|
const version_1 = require("./version");
|
|
37
38
|
const HELP = `
|
|
38
39
|
Codiedev CLI
|
|
@@ -95,6 +96,11 @@ Feed:
|
|
|
95
96
|
[--mention <name>]... Publish to the team feed
|
|
96
97
|
codiedev react <postId> <emoji> Toggle a reaction on a feed post
|
|
97
98
|
|
|
99
|
+
Privacy:
|
|
100
|
+
codiedev ignore <path> Never capture this folder or its children
|
|
101
|
+
codiedev unignore <path> Stop ignoring a folder
|
|
102
|
+
codiedev ignored [path] List ignored folders, or check one path
|
|
103
|
+
|
|
98
104
|
Capture:
|
|
99
105
|
codiedev note "<text>" Capture a passing thought
|
|
100
106
|
|
|
@@ -130,6 +136,18 @@ Backend: ${process.env.CODIEDEV_URL ?? "https://codiedev.com"}
|
|
|
130
136
|
Token: https://codiedev.com/portal/integrations/claude-code
|
|
131
137
|
Docs: codiedev docs
|
|
132
138
|
`.trim();
|
|
139
|
+
const IGNORE_HELP = `
|
|
140
|
+
codiedev ignore — keep folders out of session capture
|
|
141
|
+
|
|
142
|
+
codiedev ignore <path> Never capture this folder or its children
|
|
143
|
+
codiedev unignore <path> Stop ignoring a folder
|
|
144
|
+
codiedev ignored List ignored folders on this device
|
|
145
|
+
codiedev ignored <path> Check whether a specific path is ignored
|
|
146
|
+
|
|
147
|
+
Exclusions are per-device and cover every child folder. The rule is written
|
|
148
|
+
locally before any capture leaves your machine, so an offline or failed portal
|
|
149
|
+
status sync never weakens it.
|
|
150
|
+
`.trim();
|
|
133
151
|
const COMMAND_HELP = {
|
|
134
152
|
connect: `
|
|
135
153
|
codiedev connect — link your agent CLI to Codiedev
|
|
@@ -237,6 +255,10 @@ codiedev note — capture a passing thought, linked to your session
|
|
|
237
255
|
codiedev note "idempotency key is worth a follow-up"
|
|
238
256
|
codiedev note "timeout threshold feels arbitrary — validate on slow networks"
|
|
239
257
|
`.trim(),
|
|
258
|
+
ignore: IGNORE_HELP,
|
|
259
|
+
unignore: IGNORE_HELP,
|
|
260
|
+
ignored: IGNORE_HELP,
|
|
261
|
+
capture: IGNORE_HELP,
|
|
240
262
|
promote: `
|
|
241
263
|
codiedev promote — promote an auto-extracted artifact to authored
|
|
242
264
|
|
|
@@ -482,6 +504,15 @@ async function main() {
|
|
|
482
504
|
case "react":
|
|
483
505
|
await (0, react_1.runReact)(rest);
|
|
484
506
|
return;
|
|
507
|
+
case "ignore":
|
|
508
|
+
case "unignore":
|
|
509
|
+
case "ignored":
|
|
510
|
+
await (0, capture_1.runCaptureCommand)([command, ...rest]);
|
|
511
|
+
return;
|
|
512
|
+
case "capture":
|
|
513
|
+
// Back-compat alias for the earlier `codiedev capture <sub>` form.
|
|
514
|
+
await (0, capture_1.runCaptureCommand)(rest);
|
|
515
|
+
return;
|
|
485
516
|
default:
|
|
486
517
|
console.error(`Unknown command: ${command}`);
|
|
487
518
|
console.error(HELP);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type CodiedevConfig } from "../utils";
|
|
2
|
+
import { apiRequest } from "./shared";
|
|
3
|
+
export interface CaptureCommandDeps {
|
|
4
|
+
readConfig: () => CodiedevConfig | null;
|
|
5
|
+
writeConfig: (config: CodiedevConfig) => void;
|
|
6
|
+
apiRequest: typeof apiRequest;
|
|
7
|
+
log: (line: string) => void;
|
|
8
|
+
warn: (line: string) => void;
|
|
9
|
+
randomUUID: () => string;
|
|
10
|
+
hostname: () => string;
|
|
11
|
+
normalizePath: (value: string) => string;
|
|
12
|
+
configForRequest: (config: CodiedevConfig) => CodiedevConfig;
|
|
13
|
+
}
|
|
14
|
+
export declare function runCaptureCommand(args: string[], depsOverride?: Partial<CaptureCommandDeps>): Promise<void>;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.runCaptureCommand = runCaptureCommand;
|
|
37
|
+
const crypto = __importStar(require("crypto"));
|
|
38
|
+
const os = __importStar(require("os"));
|
|
39
|
+
const captureExclusions_1 = require("../captureExclusions");
|
|
40
|
+
const utils_1 = require("../utils");
|
|
41
|
+
const shared_1 = require("./shared");
|
|
42
|
+
const DEFAULT_DEPS = {
|
|
43
|
+
readConfig: utils_1.readConfig,
|
|
44
|
+
writeConfig: utils_1.writeConfig,
|
|
45
|
+
apiRequest: shared_1.apiRequest,
|
|
46
|
+
log: console.log,
|
|
47
|
+
warn: console.warn,
|
|
48
|
+
randomUUID: () => crypto.randomUUID(),
|
|
49
|
+
hostname: () => os.hostname(),
|
|
50
|
+
normalizePath: captureExclusions_1.normalizeCapturePath,
|
|
51
|
+
configForRequest: shared_1.applyConfigEnvOverrides,
|
|
52
|
+
};
|
|
53
|
+
function requireConnectedConfig(deps) {
|
|
54
|
+
const config = deps.readConfig();
|
|
55
|
+
if (!config) {
|
|
56
|
+
throw new Error("Not connected. Run `npx codiedev connect` first.");
|
|
57
|
+
}
|
|
58
|
+
return config;
|
|
59
|
+
}
|
|
60
|
+
function withDeviceIdentity(config, deps) {
|
|
61
|
+
return {
|
|
62
|
+
...config,
|
|
63
|
+
deviceId: config.deviceId || deps.randomUUID(),
|
|
64
|
+
deviceName: config.deviceName || deps.hostname(),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
async function syncExclusions(config, deps) {
|
|
68
|
+
try {
|
|
69
|
+
await deps.apiRequest("POST", "/api/cli/capture-exclusions", {
|
|
70
|
+
config: deps.configForRequest(config),
|
|
71
|
+
body: {
|
|
72
|
+
deviceId: config.deviceId,
|
|
73
|
+
deviceName: config.deviceName,
|
|
74
|
+
ignoredPaths: config.captureIgnoredPaths ?? [],
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
deps.log("Portal status synced.");
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
deps.warn(`Privacy setting saved locally, but portal status did not sync: ${error.message}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function requirePath(args) {
|
|
84
|
+
const value = args.slice(1).join(" ").trim();
|
|
85
|
+
if (!value) {
|
|
86
|
+
throw new Error("Usage: codiedev ignore <path> | codiedev unignore <path>");
|
|
87
|
+
}
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
function reportCheck(rawPath, current, deps) {
|
|
91
|
+
const target = deps.normalizePath(rawPath);
|
|
92
|
+
const match = (0, captureExclusions_1.findMatchingCaptureExclusion)(target, current.captureIgnoredPaths);
|
|
93
|
+
if (match) {
|
|
94
|
+
deps.log(`IGNORED: ${target}`);
|
|
95
|
+
deps.log(`Matched exclusion: ${match}`);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
deps.log(`CAPTURED: ${target}`);
|
|
99
|
+
deps.log("No ignored-folder rule matches this path.");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function runCaptureCommand(args, depsOverride = {}) {
|
|
103
|
+
const deps = { ...DEFAULT_DEPS, ...depsOverride };
|
|
104
|
+
const subcommand = args[0];
|
|
105
|
+
const current = requireConnectedConfig(deps);
|
|
106
|
+
if (subcommand === "ignore") {
|
|
107
|
+
const target = deps.normalizePath(requirePath(args));
|
|
108
|
+
const existing = (current.captureIgnoredPaths ?? []).map(deps.normalizePath);
|
|
109
|
+
const nextPaths = existing.includes(target) ? existing : [...existing, target];
|
|
110
|
+
const next = withDeviceIdentity({ ...current, captureIgnoredPaths: nextPaths }, deps);
|
|
111
|
+
deps.writeConfig(next);
|
|
112
|
+
deps.log(`Ignoring ${target} and all child folders.`);
|
|
113
|
+
await syncExclusions(next, deps);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (subcommand === "unignore") {
|
|
117
|
+
const target = deps.normalizePath(requirePath(args));
|
|
118
|
+
const existing = (current.captureIgnoredPaths ?? []).map(deps.normalizePath);
|
|
119
|
+
const next = withDeviceIdentity({ ...current, captureIgnoredPaths: existing.filter((value) => value !== target) }, deps);
|
|
120
|
+
deps.writeConfig(next);
|
|
121
|
+
deps.log(`No longer ignoring ${target}.`);
|
|
122
|
+
await syncExclusions(next, deps);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (subcommand === "ignored") {
|
|
126
|
+
// `ignored` lists everything; `ignored <path>` answers "is this ignored?".
|
|
127
|
+
const query = args.slice(1).join(" ").trim();
|
|
128
|
+
if (query) {
|
|
129
|
+
reportCheck(query, current, deps);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const paths = current.captureIgnoredPaths ?? [];
|
|
133
|
+
if (paths.length === 0) {
|
|
134
|
+
deps.log("No ignored folders configured on this device.");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
deps.log("Ignored folders:");
|
|
138
|
+
for (const value of paths)
|
|
139
|
+
deps.log(` ${value}`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// Back-compat: the earlier `codiedev capture check <path>` form.
|
|
143
|
+
if (subcommand === "check") {
|
|
144
|
+
reportCheck(requirePath(args), current, deps);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
throw new Error(`Unknown command: ${subcommand ?? "(missing)"}. Use ignore, unignore, or ignored.`);
|
|
148
|
+
}
|
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/connect.js
CHANGED
|
@@ -37,6 +37,8 @@ exports.runConnect = runConnect;
|
|
|
37
37
|
const readline = __importStar(require("readline"));
|
|
38
38
|
const https = __importStar(require("https"));
|
|
39
39
|
const http = __importStar(require("http"));
|
|
40
|
+
const crypto = __importStar(require("crypto"));
|
|
41
|
+
const os = __importStar(require("os"));
|
|
40
42
|
const utils_1 = require("./utils");
|
|
41
43
|
const connectFlow_1 = require("./connectFlow");
|
|
42
44
|
const version_1 = require("./version");
|
|
@@ -210,6 +212,9 @@ async function runConnect(args = []) {
|
|
|
210
212
|
repos,
|
|
211
213
|
lastSynced: new Date().toISOString(),
|
|
212
214
|
backendUrl: BACKEND_URL,
|
|
215
|
+
deviceId: saved?.deviceId || crypto.randomUUID(),
|
|
216
|
+
deviceName: saved?.deviceName || os.hostname(),
|
|
217
|
+
captureIgnoredPaths: saved?.captureIgnoredPaths ?? [],
|
|
213
218
|
};
|
|
214
219
|
try {
|
|
215
220
|
(0, utils_1.writeConfig)(config);
|
|
@@ -223,6 +228,20 @@ async function runConnect(args = []) {
|
|
|
223
228
|
// a timeline when a customer says "it connected but it doesn't work."
|
|
224
229
|
// Fire-and-forget — never blocks the connect flow.
|
|
225
230
|
(0, shared_1.recordInstallEvent)(config, { step: "started", ok: true });
|
|
231
|
+
// One-way device status sync. Local config remains authoritative: a network
|
|
232
|
+
// failure here never removes or weakens an ignored path. This also registers
|
|
233
|
+
// a freshly connected device with an empty list so the portal can distinguish
|
|
234
|
+
// "connected, no exclusions" from "old CLI has never reported status".
|
|
235
|
+
try {
|
|
236
|
+
await postJson(`${BACKEND_URL}/api/cli/capture-exclusions`, {
|
|
237
|
+
deviceId: config.deviceId,
|
|
238
|
+
deviceName: config.deviceName,
|
|
239
|
+
ignoredPaths: config.captureIgnoredPaths,
|
|
240
|
+
}, { Authorization: `Bearer ${token}` });
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
console.warn(`Warning: Local capture settings are active, but portal status did not sync — ${err.message}`);
|
|
244
|
+
}
|
|
226
245
|
// Migration: strip MCP server entries written by pre-CLI-only versions
|
|
227
246
|
// of `codiedev connect`. These pointed at `npx codiedev-mcp`, a binary
|
|
228
247
|
// that was removed in 0.3.4 and that 0.7.11+ no longer ships. Without
|
package/dist/hook.js
CHANGED
|
@@ -45,6 +45,7 @@ const utils_1 = require("./utils");
|
|
|
45
45
|
const limitEvent_1 = require("./limitEvent");
|
|
46
46
|
const version_1 = require("./version");
|
|
47
47
|
const captureLog_1 = require("./captureLog");
|
|
48
|
+
const captureExclusions_1 = require("./captureExclusions");
|
|
48
49
|
function getJson(url, bearerToken) {
|
|
49
50
|
return new Promise((resolve, reject) => {
|
|
50
51
|
const parsed = new URL(url);
|
|
@@ -368,6 +369,12 @@ async function main() {
|
|
|
368
369
|
// Write per-session upload state so the throttle can defer later
|
|
369
370
|
// non-final uploads within the same window.
|
|
370
371
|
writeSessionState: utils_1.writeSessionUploadState,
|
|
372
|
+
// Re-read local config in the uploader so adding an exclusion after a
|
|
373
|
+
// job was queued still prevents its transcript from leaving the device.
|
|
374
|
+
shouldSkipJob: (job) => {
|
|
375
|
+
const config = (0, utils_1.readConfig)();
|
|
376
|
+
return (0, captureExclusions_1.shouldSkipCaptureUploadJob)(job, config);
|
|
377
|
+
},
|
|
371
378
|
};
|
|
372
379
|
}
|
|
373
380
|
// Detached uploader child (spawned by the capture path below). The job —
|
|
@@ -441,6 +448,13 @@ async function main() {
|
|
|
441
448
|
catch {
|
|
442
449
|
process.exit(0);
|
|
443
450
|
}
|
|
451
|
+
// Local privacy boundary: every stdin-driven hook mode passes through this
|
|
452
|
+
// check BEFORE prompt discovery, limit-event tails, SessionStart pings,
|
|
453
|
+
// backup, self-heal, transcript reads, or detached dispatch. Ignored means
|
|
454
|
+
// no Codiedev request at all for this session.
|
|
455
|
+
if ((0, captureExclusions_1.shouldIgnoreCaptureInput)((0, utils_1.readConfig)(), hookInput)) {
|
|
456
|
+
process.exit(0);
|
|
457
|
+
}
|
|
444
458
|
// UserPromptSubmit discovery path — separate from session capture.
|
|
445
459
|
if (process.argv[2] === "discover") {
|
|
446
460
|
await runDiscover(hookInput.prompt);
|
|
@@ -563,6 +577,17 @@ async function runCaptureFinish(input) {
|
|
|
563
577
|
const config = (0, utils_1.readConfig)();
|
|
564
578
|
if (!config)
|
|
565
579
|
return;
|
|
580
|
+
// Re-check in the detached child: the user may have added an exclusion
|
|
581
|
+
// after the parent hook dispatched this job but before it started.
|
|
582
|
+
if ((0, captureExclusions_1.shouldIgnoreCaptureInput)(config, input)) {
|
|
583
|
+
(0, captureLog_1.appendCaptureLog)({
|
|
584
|
+
stage: "capture-finish",
|
|
585
|
+
sessionId: input.session_id,
|
|
586
|
+
outcome: "skip",
|
|
587
|
+
detail: "ignored-path",
|
|
588
|
+
});
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
566
591
|
const { session_id, transcript_path, cwd, workspace_roots } = input;
|
|
567
592
|
if (!transcript_path) {
|
|
568
593
|
await postSessionSkip(config, session_id, "no-transcript-path");
|
|
@@ -588,6 +613,7 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
|
|
|
588
613
|
const workspaceRoot = workspace_roots && workspace_roots.length > 0
|
|
589
614
|
? workspace_roots[0]
|
|
590
615
|
: cwd || process.cwd();
|
|
616
|
+
const scopePaths = (0, captureExclusions_1.captureScopePaths)({ cwd, workspace_roots }, workspaceRoot);
|
|
591
617
|
let transcriptContent;
|
|
592
618
|
try {
|
|
593
619
|
transcriptContent = fs.readFileSync(transcript_path, "utf8");
|
|
@@ -718,7 +744,12 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
|
|
|
718
744
|
const hookScriptPath = process.argv[1];
|
|
719
745
|
const tmpFile = path.join(os.tmpdir(), `codiedev-capture-${session_id ?? process.pid}.json`);
|
|
720
746
|
if (decision.mode === "immediate") {
|
|
721
|
-
(0, utils_1.dispatchCaptureDetached)({
|
|
747
|
+
(0, utils_1.dispatchCaptureDetached)({
|
|
748
|
+
url: `${config.backendUrl}/api/captureSession`,
|
|
749
|
+
token: config.token,
|
|
750
|
+
body: payload,
|
|
751
|
+
scopePaths,
|
|
752
|
+
}, hookScriptPath, { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile, openSync: fs.openSync, closeSync: fs.closeSync });
|
|
722
753
|
// Retry any earlier capture jobs that failed to upload (transient network
|
|
723
754
|
// blips), excluding the one we just dispatched. Best-effort, detached.
|
|
724
755
|
// Skip the sweep on deferred path — the deferred child will handle uploading.
|
|
@@ -754,7 +785,12 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
|
|
|
754
785
|
// Deferred: write the job file (newer turns overwrite = supersede), then
|
|
755
786
|
// spawn the deferred-upload child which will sleep, claim, and upload.
|
|
756
787
|
// Same stdio/fd-hygiene contract as dispatchCaptureDetached.
|
|
757
|
-
(0, utils_1.dispatchDeferredUpload)({
|
|
788
|
+
(0, utils_1.dispatchDeferredUpload)({
|
|
789
|
+
url: `${config.backendUrl}/api/captureSession`,
|
|
790
|
+
token: config.token,
|
|
791
|
+
body: payload,
|
|
792
|
+
scopePaths,
|
|
793
|
+
}, hookScriptPath, { spawn: child_process_1.spawn, writeFileSync: fs.writeFileSync, tmpFile, openSync: fs.openSync, closeSync: fs.closeSync }, decision.delayMs);
|
|
758
794
|
}
|
|
759
795
|
}
|
|
760
796
|
catch (err) {
|
package/dist/utils.d.ts
CHANGED
|
@@ -12,9 +12,14 @@ export interface CodiedevConfig {
|
|
|
12
12
|
}>;
|
|
13
13
|
lastSynced: string;
|
|
14
14
|
backendUrl: string;
|
|
15
|
+
deviceId?: string;
|
|
16
|
+
deviceName?: string;
|
|
17
|
+
captureIgnoredPaths?: string[];
|
|
15
18
|
}
|
|
16
19
|
export declare function readConfig(): CodiedevConfig | null;
|
|
17
20
|
export declare function writeConfig(config: CodiedevConfig): void;
|
|
21
|
+
/** Exported for permission-focused tests; normal callers use writeConfig. */
|
|
22
|
+
export declare function writeConfigAtPath(config: CodiedevConfig, configDir: string, configPath: string): void;
|
|
18
23
|
export declare function parseGitRemoteFromConfig(configText: string): string | null;
|
|
19
24
|
export declare function readGitRemoteFromConfig(startDir: string): string | null;
|
|
20
25
|
export declare function getGitRemoteUrl(cwd: string): string | null;
|
|
@@ -67,6 +72,8 @@ export interface CapturePayloadInput {
|
|
|
67
72
|
}
|
|
68
73
|
export declare function buildCapturePayload(input: CapturePayloadInput): Record<string, unknown>;
|
|
69
74
|
export declare function hashToken(token: string): string;
|
|
75
|
+
export declare function extractHookNodePath(cmd: string | undefined): string | undefined;
|
|
76
|
+
export declare function resetStableNodeCacheForTests(): void;
|
|
70
77
|
export declare function claudeCodeInstalled(): boolean;
|
|
71
78
|
export declare function codexInstalled(): boolean;
|
|
72
79
|
export declare function cursorInstalled(): boolean;
|
|
@@ -169,6 +176,7 @@ export interface CaptureUploadJob {
|
|
|
169
176
|
body: Record<string, unknown>;
|
|
170
177
|
attempts?: number;
|
|
171
178
|
queuedAt?: number;
|
|
179
|
+
scopePaths?: string[];
|
|
172
180
|
}
|
|
173
181
|
export declare const MAX_UPLOAD_ATTEMPTS = 5;
|
|
174
182
|
export declare const UPLOAD_TTL_MS: number;
|
|
@@ -260,6 +268,7 @@ interface UploadDeps {
|
|
|
260
268
|
recordSuccess?: (job: CaptureUploadJob) => void;
|
|
261
269
|
log?: (entry: CaptureLogEntry) => void;
|
|
262
270
|
writeSessionState?: (sessionId: string, state: SessionUploadState) => void;
|
|
271
|
+
shouldSkipJob?: (job: CaptureUploadJob) => boolean;
|
|
263
272
|
}
|
|
264
273
|
export interface LastCaptureRecord {
|
|
265
274
|
sessionId: string | undefined;
|
package/dist/utils.js
CHANGED
|
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.DIRECT_UPLOAD_THRESHOLD_CHARS = exports.SWEEP_MIN_AGE_MS = exports.CAPTURE_THROTTLE_MS = exports.FINISH_ORPHAN_AGE_MS = exports.CLAIM_STALE_MS = exports.UPLOAD_TTL_MS = exports.MAX_UPLOAD_ATTEMPTS = void 0;
|
|
37
37
|
exports.readConfig = readConfig;
|
|
38
38
|
exports.writeConfig = writeConfig;
|
|
39
|
+
exports.writeConfigAtPath = writeConfigAtPath;
|
|
39
40
|
exports.parseGitRemoteFromConfig = parseGitRemoteFromConfig;
|
|
40
41
|
exports.readGitRemoteFromConfig = readGitRemoteFromConfig;
|
|
41
42
|
exports.getGitRemoteUrl = getGitRemoteUrl;
|
|
@@ -52,6 +53,8 @@ exports.getGitBranch = getGitBranch;
|
|
|
52
53
|
exports.getGitHeadSha = getGitHeadSha;
|
|
53
54
|
exports.buildCapturePayload = buildCapturePayload;
|
|
54
55
|
exports.hashToken = hashToken;
|
|
56
|
+
exports.extractHookNodePath = extractHookNodePath;
|
|
57
|
+
exports.resetStableNodeCacheForTests = resetStableNodeCacheForTests;
|
|
55
58
|
exports.claudeCodeInstalled = claudeCodeInstalled;
|
|
56
59
|
exports.codexInstalled = codexInstalled;
|
|
57
60
|
exports.cursorInstalled = cursorInstalled;
|
|
@@ -120,10 +123,19 @@ function readConfig() {
|
|
|
120
123
|
}
|
|
121
124
|
}
|
|
122
125
|
function writeConfig(config) {
|
|
123
|
-
|
|
124
|
-
|
|
126
|
+
writeConfigAtPath(config, CONFIG_DIR, CONFIG_PATH);
|
|
127
|
+
}
|
|
128
|
+
/** Exported for permission-focused tests; normal callers use writeConfig. */
|
|
129
|
+
function writeConfigAtPath(config, configDir, configPath) {
|
|
130
|
+
if (!fs.existsSync(configDir)) {
|
|
131
|
+
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
125
132
|
}
|
|
126
|
-
fs.writeFileSync(
|
|
133
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), {
|
|
134
|
+
encoding: "utf8",
|
|
135
|
+
mode: 0o600,
|
|
136
|
+
});
|
|
137
|
+
// writeFile's mode does not replace permissions on a pre-existing file.
|
|
138
|
+
fs.chmodSync(configPath, 0o600);
|
|
127
139
|
}
|
|
128
140
|
// Parse a remote URL out of git-config INI text WITHOUT invoking git. Prefers
|
|
129
141
|
// the "origin" remote; falls back to the first remote found. Returns null if
|
|
@@ -733,7 +745,94 @@ const VSCODE_USER_MCP_PATH = path.join(VSCODE_USER_DIR, "mcp.json");
|
|
|
733
745
|
function shellQuote(s) {
|
|
734
746
|
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
735
747
|
}
|
|
736
|
-
|
|
748
|
+
// Pull the `<node>` prefix out of a codiedev hook command we previously wrote.
|
|
749
|
+
// Commands take the form `'<node>' '<hookScript>' <subcommand>` (each path
|
|
750
|
+
// single-quoted by shellQuote). Returns undefined for the legacy
|
|
751
|
+
// `npx codiedev-hook <subcommand>` form (no explicit node path) or if the
|
|
752
|
+
// command doesn't start with a quoted token. Exported for unit testing.
|
|
753
|
+
function extractHookNodePath(cmd) {
|
|
754
|
+
if (!cmd)
|
|
755
|
+
return undefined;
|
|
756
|
+
// Match the first single-quoted token, honoring shellQuote's `'\''` escape.
|
|
757
|
+
const m = cmd.match(/^'((?:[^']|'\\'')*)'/);
|
|
758
|
+
if (!m)
|
|
759
|
+
return undefined;
|
|
760
|
+
return m[1].replace(/'\\''/g, "'");
|
|
761
|
+
}
|
|
762
|
+
// `process.execPath` is the *resolved* path to the running node binary, so under
|
|
763
|
+
// Homebrew it's the version-specific Cellar path (e.g.
|
|
764
|
+
// /opt/homebrew/Cellar/node/26.4.0/bin/node). Baking that into the hook means a
|
|
765
|
+
// later `brew upgrade node` moves the Cellar dir and the hook path 404s. Prefer
|
|
766
|
+
// a stable alias for the *same* binary:
|
|
767
|
+
// 1. A node path from a hook we previously wrote that still resolves — usually
|
|
768
|
+
// a symlink the user deliberately pinned (e.g. /opt/homebrew/bin/node). A
|
|
769
|
+
// re-`connect`/self-heal then honors it instead of clobbering it.
|
|
770
|
+
// 2. Otherwise, `node` as found on PATH when it points at the binary we're
|
|
771
|
+
// running (the Homebrew symlink, an nvm `current` alias, etc.).
|
|
772
|
+
// 3. Last resort: the resolved `process.execPath`. Always valid right now, and
|
|
773
|
+
// still an absolute path a GUI-launched agent can execute without PATH.
|
|
774
|
+
function realpathOrSelf(p) {
|
|
775
|
+
try {
|
|
776
|
+
return fs.realpathSync(p);
|
|
777
|
+
}
|
|
778
|
+
catch {
|
|
779
|
+
return p;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
// A stable `node` alias on PATH (Homebrew symlink, nvm `current`, …) that points
|
|
783
|
+
// at the very binary we're running, or undefined if none. `installHook` writes
|
|
784
|
+
// five hooks per run, so memoize: `command -v node` is a synchronous subprocess
|
|
785
|
+
// spawn and its result can't change within a process. `null` = "looked, none".
|
|
786
|
+
let cachedStableNodeOnPath;
|
|
787
|
+
// Test-only: the memo lives for the process lifetime, so suites that stage
|
|
788
|
+
// their own PATH/symlink fixtures must clear it between tests.
|
|
789
|
+
function resetStableNodeCacheForTests() {
|
|
790
|
+
cachedStableNodeOnPath = undefined;
|
|
791
|
+
}
|
|
792
|
+
function stableNodeOnPath() {
|
|
793
|
+
if (cachedStableNodeOnPath !== undefined) {
|
|
794
|
+
return cachedStableNodeOnPath ?? undefined;
|
|
795
|
+
}
|
|
796
|
+
const execReal = realpathOrSelf(process.execPath);
|
|
797
|
+
try {
|
|
798
|
+
const onPath = (0, child_process_1.execSync)("command -v node", {
|
|
799
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
800
|
+
})
|
|
801
|
+
.toString()
|
|
802
|
+
.trim();
|
|
803
|
+
if (onPath && onPath !== process.execPath && realpathOrSelf(onPath) === execReal) {
|
|
804
|
+
cachedStableNodeOnPath = onPath;
|
|
805
|
+
return onPath;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
catch {
|
|
809
|
+
// No node on PATH (e.g. GUI-launched agent) — fall through.
|
|
810
|
+
}
|
|
811
|
+
cachedStableNodeOnPath = null;
|
|
812
|
+
return undefined;
|
|
813
|
+
}
|
|
814
|
+
function resolveHookNodePath(preferredNodePath) {
|
|
815
|
+
const execReal = realpathOrSelf(process.execPath);
|
|
816
|
+
// 1. Honor a still-working node path from the existing hook — but only when
|
|
817
|
+
// it's a *different* string than the resolved realpath. If the existing
|
|
818
|
+
// path already IS `process.execPath` it's the version-specific realpath we
|
|
819
|
+
// want to upgrade away from (that's the Homebrew bug), so fall through to
|
|
820
|
+
// hunt for a stable alias in step 2.
|
|
821
|
+
if (preferredNodePath &&
|
|
822
|
+
preferredNodePath !== process.execPath &&
|
|
823
|
+
fs.existsSync(preferredNodePath) &&
|
|
824
|
+
realpathOrSelf(preferredNodePath) === execReal) {
|
|
825
|
+
return preferredNodePath;
|
|
826
|
+
}
|
|
827
|
+
// 2. Prefer a stable `node` on PATH over the version-specific realpath.
|
|
828
|
+
const onPath = stableNodeOnPath();
|
|
829
|
+
if (onPath)
|
|
830
|
+
return onPath;
|
|
831
|
+
// 3. Last resort: the resolved absolute path. Always valid right now, and
|
|
832
|
+
// still an absolute path a GUI-launched agent can execute without PATH.
|
|
833
|
+
return process.execPath;
|
|
834
|
+
}
|
|
835
|
+
function resolveHookCommand(subcommand, preferredNodePath) {
|
|
737
836
|
try {
|
|
738
837
|
const cliEntry = process.argv[1];
|
|
739
838
|
if (cliEntry) {
|
|
@@ -741,7 +840,8 @@ function resolveHookCommand(subcommand) {
|
|
|
741
840
|
const distDir = path.dirname(realCli);
|
|
742
841
|
const hookScript = path.join(distDir, "hook.js");
|
|
743
842
|
if (fs.existsSync(hookScript)) {
|
|
744
|
-
|
|
843
|
+
const nodePath = resolveHookNodePath(preferredNodePath);
|
|
844
|
+
return `${shellQuote(nodePath)} ${shellQuote(hookScript)} ${subcommand}`;
|
|
745
845
|
}
|
|
746
846
|
}
|
|
747
847
|
}
|
|
@@ -798,6 +898,22 @@ function upsertCodiedevHook(hooks, eventName, subcommand, matcher = ".*") {
|
|
|
798
898
|
if (!hooks[eventName])
|
|
799
899
|
hooks[eventName] = [];
|
|
800
900
|
const existing = hooks[eventName];
|
|
901
|
+
// Before stripping the old codiedev entry, remember the node path it used so a
|
|
902
|
+
// re-run/upgrade can honor a stable symlink the user pinned instead of
|
|
903
|
+
// overwriting it with the version-specific `process.execPath` (see
|
|
904
|
+
// resolveHookNodePath). Done in an explicit scan rather than as a side effect
|
|
905
|
+
// of the filter predicate below, which stays a pure predicate.
|
|
906
|
+
let preservedNodePath;
|
|
907
|
+
for (const hook of existing) {
|
|
908
|
+
const inner = hook.hooks;
|
|
909
|
+
if (!Array.isArray(inner))
|
|
910
|
+
continue;
|
|
911
|
+
const codiedevInner = inner.find((h) => (0, detection_1.isCodiedevHookCommand)(h.command));
|
|
912
|
+
if (codiedevInner) {
|
|
913
|
+
preservedNodePath = extractHookNodePath(codiedevInner.command);
|
|
914
|
+
break;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
801
917
|
const filtered = existing.filter((hook) => {
|
|
802
918
|
const inner = hook.hooks;
|
|
803
919
|
if (Array.isArray(inner)) {
|
|
@@ -807,7 +923,7 @@ function upsertCodiedevHook(hooks, eventName, subcommand, matcher = ".*") {
|
|
|
807
923
|
});
|
|
808
924
|
filtered.push({
|
|
809
925
|
matcher,
|
|
810
|
-
hooks: [{ type: "command", command: resolveHookCommand(subcommand) }],
|
|
926
|
+
hooks: [{ type: "command", command: resolveHookCommand(subcommand, preservedNodePath) }],
|
|
811
927
|
});
|
|
812
928
|
hooks[eventName] = filtered;
|
|
813
929
|
}
|
|
@@ -2093,6 +2209,19 @@ async function runCaptureUpload(jobFile, deps) {
|
|
|
2093
2209
|
return;
|
|
2094
2210
|
}
|
|
2095
2211
|
const sessionId = job.body?.sessionId;
|
|
2212
|
+
if (deps.shouldSkipJob?.(job)) {
|
|
2213
|
+
if (deps.log) {
|
|
2214
|
+
try {
|
|
2215
|
+
deps.log({ stage: "upload", sessionId, outcome: "skip", detail: "ignored-path" });
|
|
2216
|
+
}
|
|
2217
|
+
catch { /* log is best-effort */ }
|
|
2218
|
+
}
|
|
2219
|
+
try {
|
|
2220
|
+
deps.unlinkSync(jobFile);
|
|
2221
|
+
}
|
|
2222
|
+
catch { /* gone */ }
|
|
2223
|
+
return;
|
|
2224
|
+
}
|
|
2096
2225
|
try {
|
|
2097
2226
|
// Big transcript + capable deps → direct-to-storage: fetch a one-time
|
|
2098
2227
|
// upload URL, PUT the raw bytes, send captureSession the storageId. Any
|
package/package.json
CHANGED