@thecat69/cache-ctrl 1.0.0 → 1.2.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.
- package/README.md +289 -78
- package/cache_ctrl.ts +107 -25
- package/package.json +2 -1
- package/skills/cache-ctrl-caller/SKILL.md +53 -114
- package/skills/cache-ctrl-external/SKILL.md +29 -89
- package/skills/cache-ctrl-local/SKILL.md +82 -164
- package/src/analysis/graphBuilder.ts +85 -0
- package/src/analysis/pageRank.ts +164 -0
- package/src/analysis/symbolExtractor.ts +240 -0
- package/src/cache/cacheManager.ts +53 -4
- package/src/cache/externalCache.ts +72 -77
- package/src/cache/graphCache.ts +12 -0
- package/src/cache/localCache.ts +2 -0
- package/src/commands/checkFiles.ts +9 -6
- package/src/commands/flush.ts +9 -2
- package/src/commands/graph.ts +131 -0
- package/src/commands/inspect.ts +13 -181
- package/src/commands/inspectExternal.ts +79 -0
- package/src/commands/inspectLocal.ts +134 -0
- package/src/commands/install.ts +6 -0
- package/src/commands/invalidate.ts +24 -24
- package/src/commands/list.ts +11 -11
- package/src/commands/map.ts +87 -0
- package/src/commands/prune.ts +20 -8
- package/src/commands/search.ts +9 -2
- package/src/commands/touch.ts +15 -25
- package/src/commands/uninstall.ts +103 -0
- package/src/commands/update.ts +65 -0
- package/src/commands/version.ts +14 -0
- package/src/commands/watch.ts +270 -0
- package/src/commands/writeExternal.ts +51 -0
- package/src/commands/writeLocal.ts +121 -0
- package/src/files/changeDetector.ts +15 -0
- package/src/files/gitFiles.ts +15 -0
- package/src/files/openCodeInstaller.ts +21 -2
- package/src/index.ts +314 -58
- package/src/search/keywordSearch.ts +24 -0
- package/src/types/cache.ts +38 -26
- package/src/types/commands.ts +123 -22
- package/src/types/result.ts +26 -9
- package/src/utils/errors.ts +14 -0
- package/src/utils/traversal.ts +42 -0
- package/src/commands/checkFreshness.ts +0 -123
- package/src/commands/write.ts +0 -170
- package/src/http/freshnessChecker.ts +0 -116
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { findRepoRoot, readCache } from "../cache/cacheManager.js";
|
|
2
|
+
import { resolveLocalCachePath } from "../cache/localCache.js";
|
|
3
|
+
import { LocalCacheFileSchema } from "../types/cache.js";
|
|
4
|
+
import type { MapArgs, MapDepth, MapResult } from "../types/commands.js";
|
|
5
|
+
import { ErrorCode, type Result } from "../types/result.js";
|
|
6
|
+
import { toUnknownResult } from "../utils/errors.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Returns a semantic map view of local context cache content.
|
|
10
|
+
*
|
|
11
|
+
* @param args - {@link MapArgs} command arguments.
|
|
12
|
+
* @returns Promise<Result<MapResult["value"]>>; common failures include FILE_NOT_FOUND,
|
|
13
|
+
* PARSE_ERROR, FILE_READ_ERROR, and UNKNOWN.
|
|
14
|
+
*/
|
|
15
|
+
export async function mapCommand(args: MapArgs): Promise<Result<MapResult["value"]>> {
|
|
16
|
+
try {
|
|
17
|
+
const depth: MapDepth = args.depth ?? "overview";
|
|
18
|
+
const repoRoot = await findRepoRoot(process.cwd());
|
|
19
|
+
const contextPath = resolveLocalCachePath(repoRoot);
|
|
20
|
+
|
|
21
|
+
const readResult = await readCache(contextPath);
|
|
22
|
+
if (!readResult.ok) {
|
|
23
|
+
if (readResult.code === ErrorCode.FILE_NOT_FOUND) {
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
error: "context.json not found — run local-context-gatherer to populate the cache",
|
|
27
|
+
code: ErrorCode.FILE_NOT_FOUND,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return readResult;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const parseResult = LocalCacheFileSchema.safeParse(readResult.value);
|
|
34
|
+
if (!parseResult.success) {
|
|
35
|
+
return {
|
|
36
|
+
ok: false,
|
|
37
|
+
error: `Malformed local cache file: ${contextPath}`,
|
|
38
|
+
code: ErrorCode.PARSE_ERROR,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const parsed = parseResult.data;
|
|
43
|
+
const allFacts = parsed.facts ?? {};
|
|
44
|
+
const folderPrefix = args.folder;
|
|
45
|
+
const filteredFacts =
|
|
46
|
+
folderPrefix !== undefined
|
|
47
|
+
? Object.fromEntries(
|
|
48
|
+
Object.entries(allFacts).filter(
|
|
49
|
+
([filePath]) => filePath === folderPrefix || filePath.startsWith(`${folderPrefix}/`),
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
: allFacts;
|
|
53
|
+
|
|
54
|
+
const files = Object.entries(filteredFacts)
|
|
55
|
+
.map(([path, fileFacts]) => ({
|
|
56
|
+
path,
|
|
57
|
+
...(fileFacts.summary !== undefined ? { summary: fileFacts.summary } : {}),
|
|
58
|
+
...(fileFacts.role !== undefined ? { role: fileFacts.role } : {}),
|
|
59
|
+
...(fileFacts.importance !== undefined ? { importance: fileFacts.importance } : {}),
|
|
60
|
+
...(depth === "full" && fileFacts.facts !== undefined ? { facts: fileFacts.facts } : {}),
|
|
61
|
+
}))
|
|
62
|
+
.sort((a, b) => {
|
|
63
|
+
const aImportance = a.importance ?? Number.POSITIVE_INFINITY;
|
|
64
|
+
const bImportance = b.importance ?? Number.POSITIVE_INFINITY;
|
|
65
|
+
if (aImportance !== bImportance) {
|
|
66
|
+
return aImportance - bImportance;
|
|
67
|
+
}
|
|
68
|
+
return a.path.localeCompare(b.path);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
ok: true,
|
|
73
|
+
value: {
|
|
74
|
+
depth,
|
|
75
|
+
global_facts: parsed.global_facts ?? [],
|
|
76
|
+
files,
|
|
77
|
+
...((depth === "modules" || depth === "full") && parsed.modules !== undefined
|
|
78
|
+
? { modules: parsed.modules }
|
|
79
|
+
: {}),
|
|
80
|
+
total_files: files.length,
|
|
81
|
+
...(args.folder !== undefined ? { folder_filter: args.folder } : {}),
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
} catch (err) {
|
|
85
|
+
return toUnknownResult(err);
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/commands/prune.ts
CHANGED
|
@@ -6,17 +6,32 @@ import { ExternalCacheFileSchema } from "../types/cache.js";
|
|
|
6
6
|
import { ErrorCode, type Result } from "../types/result.js";
|
|
7
7
|
import type { PruneArgs, PruneResult } from "../types/commands.js";
|
|
8
8
|
import { getFileStem } from "../utils/fileStem.js";
|
|
9
|
+
import { toUnknownResult } from "../utils/errors.js";
|
|
9
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Parses duration text in `<number><unit>` form.
|
|
13
|
+
*
|
|
14
|
+
* @param duration - Duration string where unit is one of `s`, `m`, `h`, `d`.
|
|
15
|
+
* @returns Milliseconds value, or `null` when format is invalid.
|
|
16
|
+
*/
|
|
10
17
|
export function parseDurationMs(duration: string): number | null {
|
|
11
|
-
const match = /^(\d+)(h|d)$/.exec(duration);
|
|
18
|
+
const match = /^(\d+)(s|m|h|d)$/.exec(duration);
|
|
12
19
|
if (!match) return null;
|
|
13
20
|
const value = parseInt(match[1]!, 10);
|
|
14
21
|
const unit = match[2]!;
|
|
22
|
+
if (unit === "s") return value * 1_000;
|
|
23
|
+
if (unit === "m") return value * 60_000;
|
|
15
24
|
if (unit === "h") return value * 3_600_000;
|
|
16
|
-
|
|
17
|
-
return null;
|
|
25
|
+
return value * 86_400_000;
|
|
18
26
|
}
|
|
19
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Prunes stale cache entries by invalidating or deleting them.
|
|
30
|
+
*
|
|
31
|
+
* @param args - {@link PruneArgs} command arguments.
|
|
32
|
+
* @returns Promise<Result<PruneResult["value"]>>; common failures include INVALID_ARGS,
|
|
33
|
+
* FILE_READ_ERROR, FILE_WRITE_ERROR, and UNKNOWN.
|
|
34
|
+
*/
|
|
20
35
|
export async function pruneCommand(args: PruneArgs): Promise<Result<PruneResult["value"]>> {
|
|
21
36
|
try {
|
|
22
37
|
const repoRoot = await findRepoRoot(process.cwd());
|
|
@@ -82,9 +97,7 @@ export async function pruneCommand(args: PruneArgs): Promise<Result<PruneResult[
|
|
|
82
97
|
// Only invalidate if the file already exists
|
|
83
98
|
const readResult = await readCache(localPath);
|
|
84
99
|
if (!readResult.ok) {
|
|
85
|
-
if (readResult.code
|
|
86
|
-
// Nothing to prune — skip silently
|
|
87
|
-
} else {
|
|
100
|
+
if (readResult.code !== ErrorCode.FILE_NOT_FOUND) {
|
|
88
101
|
return readResult;
|
|
89
102
|
}
|
|
90
103
|
} else {
|
|
@@ -104,7 +117,6 @@ export async function pruneCommand(args: PruneArgs): Promise<Result<PruneResult[
|
|
|
104
117
|
},
|
|
105
118
|
};
|
|
106
119
|
} catch (err) {
|
|
107
|
-
|
|
108
|
-
return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
|
|
120
|
+
return toUnknownResult(err);
|
|
109
121
|
}
|
|
110
122
|
}
|
package/src/commands/search.ts
CHANGED
|
@@ -5,7 +5,15 @@ import type { CacheEntry } from "../types/cache.js";
|
|
|
5
5
|
import { LocalCacheFileSchema } from "../types/cache.js";
|
|
6
6
|
import { ErrorCode, type Result } from "../types/result.js";
|
|
7
7
|
import type { SearchArgs, SearchResult } from "../types/commands.js";
|
|
8
|
+
import { toUnknownResult } from "../utils/errors.js";
|
|
8
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Searches cache entries across namespaces using keyword-based scoring.
|
|
12
|
+
*
|
|
13
|
+
* @param args - {@link SearchArgs} command arguments.
|
|
14
|
+
* @returns Promise<Result<SearchResult["value"]>>; common failures include FILE_READ_ERROR
|
|
15
|
+
* (external directory listing), PARSE_ERROR (malformed local cache), and UNKNOWN.
|
|
16
|
+
*/
|
|
9
17
|
export async function searchCommand(args: SearchArgs): Promise<Result<SearchResult["value"]>> {
|
|
10
18
|
try {
|
|
11
19
|
const repoRoot = await findRepoRoot(process.cwd());
|
|
@@ -51,7 +59,6 @@ export async function searchCommand(args: SearchArgs): Promise<Result<SearchResu
|
|
|
51
59
|
})),
|
|
52
60
|
};
|
|
53
61
|
} catch (err) {
|
|
54
|
-
|
|
55
|
-
return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
|
|
62
|
+
return toUnknownResult(err);
|
|
56
63
|
}
|
|
57
64
|
}
|
package/src/commands/touch.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
import { findRepoRoot,
|
|
2
|
-
import {
|
|
1
|
+
import { findRepoRoot, writeCache } from "../cache/cacheManager.js";
|
|
2
|
+
import { updateExternalFetchedAt } from "../cache/externalCache.js";
|
|
3
3
|
import { resolveLocalCachePath } from "../cache/localCache.js";
|
|
4
|
-
import {
|
|
4
|
+
import { type Result } from "../types/result.js";
|
|
5
5
|
import type { TouchArgs, TouchResult } from "../types/commands.js";
|
|
6
|
-
import {
|
|
6
|
+
import { toUnknownResult } from "../utils/errors.js";
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Marks cache entries fresh by setting timestamps to current UTC time.
|
|
10
|
+
*
|
|
11
|
+
* @param args - {@link TouchArgs} command arguments.
|
|
12
|
+
* @returns Promise<Result<TouchResult["value"]>>; common failures include INVALID_ARGS,
|
|
13
|
+
* NO_MATCH, FILE_WRITE_ERROR, and UNKNOWN.
|
|
14
|
+
*/
|
|
8
15
|
export async function touchCommand(args: TouchArgs): Promise<Result<TouchResult["value"]>> {
|
|
9
16
|
try {
|
|
10
17
|
const repoRoot = await findRepoRoot(process.cwd());
|
|
@@ -12,25 +19,9 @@ export async function touchCommand(args: TouchArgs): Promise<Result<TouchResult[
|
|
|
12
19
|
const touched: string[] = [];
|
|
13
20
|
|
|
14
21
|
if (args.agent === "external") {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const subjectCheck = validateSubject(args.subject);
|
|
19
|
-
if (!subjectCheck.ok) return subjectCheck;
|
|
20
|
-
const matchResult = await resolveTopExternalMatch(repoRoot, args.subject);
|
|
21
|
-
if (!matchResult.ok) return matchResult;
|
|
22
|
-
filesToTouch = [matchResult.value];
|
|
23
|
-
} else {
|
|
24
|
-
const filesResult = await listCacheFiles("external", repoRoot);
|
|
25
|
-
if (!filesResult.ok) return filesResult;
|
|
26
|
-
filesToTouch = filesResult.value;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
for (const filePath of filesToTouch) {
|
|
30
|
-
const writeResult = await writeCache(filePath, { fetched_at: newTimestamp });
|
|
31
|
-
if (!writeResult.ok) return writeResult;
|
|
32
|
-
touched.push(filePath);
|
|
33
|
-
}
|
|
22
|
+
const updateResult = await updateExternalFetchedAt(repoRoot, args.subject, newTimestamp);
|
|
23
|
+
if (!updateResult.ok) return updateResult;
|
|
24
|
+
touched.push(...updateResult.value);
|
|
34
25
|
} else {
|
|
35
26
|
// local
|
|
36
27
|
const localPath = resolveLocalCachePath(repoRoot);
|
|
@@ -41,7 +32,6 @@ export async function touchCommand(args: TouchArgs): Promise<Result<TouchResult[
|
|
|
41
32
|
|
|
42
33
|
return { ok: true, value: { touched, new_timestamp: newTimestamp } };
|
|
43
34
|
} catch (err) {
|
|
44
|
-
|
|
45
|
-
return { ok: false, error: error.message, code: ErrorCode.UNKNOWN };
|
|
35
|
+
return toUnknownResult(err);
|
|
46
36
|
}
|
|
47
37
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { readdir, rm, unlink } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { resolveOpenCodeConfigDir } from "../files/openCodeInstaller.js";
|
|
6
|
+
import type { UninstallArgs, UninstallResult } from "../types/commands.js";
|
|
7
|
+
import { ErrorCode, type Result } from "../types/result.js";
|
|
8
|
+
|
|
9
|
+
const textDecoder = new TextDecoder();
|
|
10
|
+
const CACHE_CTRL_SKILL_DIR_PATTERN = /^cache-ctrl-/;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Removes cache-ctrl OpenCode integration files and uninstalls the global npm package.
|
|
14
|
+
*/
|
|
15
|
+
export async function uninstallCommand(args: UninstallArgs): Promise<Result<UninstallResult>> {
|
|
16
|
+
try {
|
|
17
|
+
if (args.configDir !== undefined) {
|
|
18
|
+
const absConfigDir = path.isAbsolute(args.configDir)
|
|
19
|
+
? path.resolve(args.configDir)
|
|
20
|
+
: path.resolve(process.cwd(), args.configDir);
|
|
21
|
+
const home = os.homedir();
|
|
22
|
+
if (!absConfigDir.startsWith(home + path.sep) && absConfigDir !== home) {
|
|
23
|
+
return {
|
|
24
|
+
ok: false,
|
|
25
|
+
error: `--config-dir must be within the user home directory, got: ${args.configDir}`,
|
|
26
|
+
code: ErrorCode.INVALID_ARGS,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const removed: string[] = [];
|
|
32
|
+
const warnings: string[] = [];
|
|
33
|
+
let packageUninstalled = true;
|
|
34
|
+
|
|
35
|
+
const configDir = resolveOpenCodeConfigDir(args.configDir);
|
|
36
|
+
const toolFilePath = path.join(configDir, "tools", "cache_ctrl.ts");
|
|
37
|
+
const skillsDirPath = path.join(configDir, "skills");
|
|
38
|
+
const localBinaryPath = path.join(os.homedir(), ".local", "bin", "cache-ctrl");
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
await unlink(toolFilePath);
|
|
42
|
+
removed.push(toolFilePath);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
45
|
+
warnings.push(`Tool file not found: ${toolFilePath}`);
|
|
46
|
+
} else {
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const skillEntries = await readdir(skillsDirPath, { withFileTypes: true });
|
|
53
|
+
for (const skillEntry of skillEntries) {
|
|
54
|
+
if (!skillEntry.isDirectory() || !CACHE_CTRL_SKILL_DIR_PATTERN.test(skillEntry.name)) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const skillPath = path.join(skillsDirPath, skillEntry.name);
|
|
58
|
+
await rm(skillPath, { recursive: true });
|
|
59
|
+
removed.push(skillPath);
|
|
60
|
+
}
|
|
61
|
+
} catch (err) {
|
|
62
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
63
|
+
warnings.push(`Skills directory not found: ${skillsDirPath}`);
|
|
64
|
+
} else {
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
await unlink(localBinaryPath);
|
|
71
|
+
removed.push(localBinaryPath);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
74
|
+
warnings.push(`Local binary not found: ${localBinaryPath}`);
|
|
75
|
+
} else {
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const uninstallProcess = Bun.spawnSync(["npm", "uninstall", "-g", "@thecat69/cache-ctrl"]);
|
|
81
|
+
if (uninstallProcess.exitCode !== 0) {
|
|
82
|
+
packageUninstalled = false;
|
|
83
|
+
const npmError = textDecoder.decode(uninstallProcess.stderr);
|
|
84
|
+
warnings.push(npmError.length > 0 ? npmError : "npm uninstall -g @thecat69/cache-ctrl failed");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
ok: true,
|
|
89
|
+
value: {
|
|
90
|
+
removed,
|
|
91
|
+
packageUninstalled,
|
|
92
|
+
warnings,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
} catch (err) {
|
|
96
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
error: message,
|
|
100
|
+
code: ErrorCode.UNKNOWN,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { UpdateArgs, UpdateResult } from "../types/commands.js";
|
|
5
|
+
import { ErrorCode, type Result } from "../types/result.js";
|
|
6
|
+
|
|
7
|
+
import { installCommand } from "./install.js";
|
|
8
|
+
|
|
9
|
+
const textDecoder = new TextDecoder();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Updates the globally installed npm package and refreshes OpenCode integration files.
|
|
13
|
+
*/
|
|
14
|
+
export async function updateCommand(args: UpdateArgs): Promise<Result<UpdateResult>> {
|
|
15
|
+
try {
|
|
16
|
+
if (args.configDir !== undefined) {
|
|
17
|
+
const absConfigDir = path.isAbsolute(args.configDir)
|
|
18
|
+
? path.resolve(args.configDir)
|
|
19
|
+
: path.resolve(process.cwd(), args.configDir);
|
|
20
|
+
const home = os.homedir();
|
|
21
|
+
if (!absConfigDir.startsWith(home + path.sep) && absConfigDir !== home) {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
error: `--config-dir must be within the user home directory, got: ${args.configDir}`,
|
|
25
|
+
code: ErrorCode.INVALID_ARGS,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const warnings: string[] = [];
|
|
31
|
+
let packageUpdated = true;
|
|
32
|
+
|
|
33
|
+
const installProcess = Bun.spawnSync(["npm", "install", "-g", "@thecat69/cache-ctrl@latest"]);
|
|
34
|
+
if (installProcess.exitCode !== 0) {
|
|
35
|
+
packageUpdated = false;
|
|
36
|
+
const npmError = textDecoder.decode(installProcess.stderr);
|
|
37
|
+
warnings.push(npmError.length > 0 ? npmError : "npm install -g @thecat69/cache-ctrl@latest failed");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const installResult = await installCommand({ ...(args.configDir !== undefined ? { configDir: args.configDir } : {}) });
|
|
41
|
+
if (!installResult.ok) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
error: installResult.error,
|
|
45
|
+
code: installResult.code,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
value: {
|
|
52
|
+
packageUpdated,
|
|
53
|
+
installedPaths: [installResult.value.toolPath, ...installResult.value.skillPaths],
|
|
54
|
+
warnings,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
} catch (err) {
|
|
58
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
error: message,
|
|
62
|
+
code: ErrorCode.UNKNOWN,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { VersionArgs, VersionResult } from "../types/commands.js";
|
|
2
|
+
import type { Result } from "../types/result.js";
|
|
3
|
+
|
|
4
|
+
import packageJson from "../../package.json" with { type: "json" };
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns the package version from `package.json`.
|
|
8
|
+
*
|
|
9
|
+
* @param args - {@link VersionArgs} command arguments (unused).
|
|
10
|
+
* @returns `Result<VersionResult["value"]>` containing the CLI version.
|
|
11
|
+
*/
|
|
12
|
+
export function versionCommand(_args: VersionArgs = {}): Result<VersionResult["value"]> {
|
|
13
|
+
return { ok: true, value: { version: packageJson.version } };
|
|
14
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
import { buildGraph, type DependencyGraph } from "../analysis/graphBuilder.js";
|
|
5
|
+
import { findRepoRoot, writeCache } from "../cache/cacheManager.js";
|
|
6
|
+
import { resolveGraphCachePath } from "../cache/graphCache.js";
|
|
7
|
+
import { getGitTrackedFiles } from "../files/gitFiles.js";
|
|
8
|
+
import type { GraphCacheFile } from "../types/cache.js";
|
|
9
|
+
import type { WatchArgs } from "../types/commands.js";
|
|
10
|
+
import { ErrorCode, type Result } from "../types/result.js";
|
|
11
|
+
import { toUnknownResult } from "../utils/errors.js";
|
|
12
|
+
|
|
13
|
+
const WATCH_DEBOUNCE_MS = 200;
|
|
14
|
+
const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx"]);
|
|
15
|
+
|
|
16
|
+
type TrackedFilesProvider = (repoRoot: string) => Promise<string[]>;
|
|
17
|
+
type WatchEvent = "rename" | "change";
|
|
18
|
+
type BunWatchCallback = (event: WatchEvent, filename: string | null) => void;
|
|
19
|
+
|
|
20
|
+
interface WatcherHandle {
|
|
21
|
+
close?: () => void;
|
|
22
|
+
stop?: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type BunWatchFunction = (
|
|
26
|
+
watchPath: string,
|
|
27
|
+
options: { recursive: boolean },
|
|
28
|
+
callback: BunWatchCallback,
|
|
29
|
+
) => WatcherHandle;
|
|
30
|
+
|
|
31
|
+
export function resolveBunWatch(): Result<BunWatchFunction> {
|
|
32
|
+
const bunRuntime = Reflect.get(globalThis, "Bun");
|
|
33
|
+
if (typeof bunRuntime !== "object" || bunRuntime === null) {
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
error: "Bun.watch is not available in this runtime",
|
|
37
|
+
code: ErrorCode.UNKNOWN,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const watchFn = Reflect.get(bunRuntime, "watch");
|
|
41
|
+
if (typeof watchFn !== "function") {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
error: "Bun.watch is not available in this runtime",
|
|
45
|
+
code: ErrorCode.UNKNOWN,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return { ok: true, value: watchFn };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Checks whether a file path is a supported source file for graph analysis.
|
|
53
|
+
*
|
|
54
|
+
* @param filePath - Absolute or relative file path.
|
|
55
|
+
* @returns `true` when extension is one of `.ts`, `.tsx`, `.js`, `.jsx`.
|
|
56
|
+
*/
|
|
57
|
+
export function isSourceFile(filePath: string): boolean {
|
|
58
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
59
|
+
return SOURCE_EXTENSIONS.has(extension);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Converts in-memory dependency graph nodes into graph cache file shape.
|
|
64
|
+
*
|
|
65
|
+
* @param graph - Dependency graph to serialize.
|
|
66
|
+
* @returns `GraphCacheFile["files"]` payload suitable for `graph.json`.
|
|
67
|
+
*/
|
|
68
|
+
export function serializeGraphToCache(graph: DependencyGraph): GraphCacheFile["files"] {
|
|
69
|
+
const files: GraphCacheFile["files"] = {};
|
|
70
|
+
|
|
71
|
+
for (const [filePath, node] of graph.entries()) {
|
|
72
|
+
files[filePath] = {
|
|
73
|
+
rank: 0.0,
|
|
74
|
+
deps: node.deps,
|
|
75
|
+
defs: node.defs,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return files;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolves tracked source files to safe absolute paths for graph building.
|
|
84
|
+
*
|
|
85
|
+
* @param repoRoot - Repository root used for path resolution.
|
|
86
|
+
* @param trackedFilesProvider - Optional provider for tracked paths (defaults to git).
|
|
87
|
+
* @returns Absolute, de-duplicated source file paths constrained to `repoRoot`.
|
|
88
|
+
*/
|
|
89
|
+
export async function resolveSourceFilePaths(
|
|
90
|
+
repoRoot: string,
|
|
91
|
+
trackedFilesProvider: TrackedFilesProvider = getGitTrackedFiles,
|
|
92
|
+
): Promise<string[]> {
|
|
93
|
+
const trackedFiles = await trackedFilesProvider(repoRoot);
|
|
94
|
+
const normalizedRepoRoot = path.resolve(repoRoot);
|
|
95
|
+
const repoPrefix = `${normalizedRepoRoot}${path.sep}`;
|
|
96
|
+
const sourcePaths = new Set<string>();
|
|
97
|
+
|
|
98
|
+
await Promise.all(
|
|
99
|
+
trackedFiles.filter(isSourceFile).map(async (relPath) => {
|
|
100
|
+
const absolutePath = path.join(normalizedRepoRoot, relPath);
|
|
101
|
+
try {
|
|
102
|
+
const resolvedPath = await realpath(absolutePath);
|
|
103
|
+
if (resolvedPath === normalizedRepoRoot || resolvedPath.startsWith(repoPrefix)) {
|
|
104
|
+
sourcePaths.add(resolvedPath);
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// Ignore missing or unreadable paths from tracked files.
|
|
108
|
+
}
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return [...sourcePaths];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface RebuildGraphCacheDependencies {
|
|
116
|
+
resolveSourceFilePaths: typeof resolveSourceFilePaths;
|
|
117
|
+
buildGraph: typeof buildGraph;
|
|
118
|
+
resolveGraphCachePath: typeof resolveGraphCachePath;
|
|
119
|
+
writeCache: typeof writeCache;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const defaultRebuildGraphCacheDependencies: RebuildGraphCacheDependencies = {
|
|
123
|
+
resolveSourceFilePaths,
|
|
124
|
+
buildGraph,
|
|
125
|
+
resolveGraphCachePath,
|
|
126
|
+
writeCache,
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export async function rebuildGraphCache(
|
|
130
|
+
repoRoot: string,
|
|
131
|
+
changedPath: string | undefined,
|
|
132
|
+
verbose: boolean,
|
|
133
|
+
dependencies: RebuildGraphCacheDependencies = defaultRebuildGraphCacheDependencies,
|
|
134
|
+
): Promise<Result<void>> {
|
|
135
|
+
try {
|
|
136
|
+
const sourceFilePaths = await dependencies.resolveSourceFilePaths(repoRoot);
|
|
137
|
+
const graph = await dependencies.buildGraph(sourceFilePaths, repoRoot);
|
|
138
|
+
const graphCachePath = dependencies.resolveGraphCachePath(repoRoot);
|
|
139
|
+
const graphPayload: GraphCacheFile = {
|
|
140
|
+
files: serializeGraphToCache(graph),
|
|
141
|
+
computed_at: new Date().toISOString(),
|
|
142
|
+
};
|
|
143
|
+
const writeResult = await dependencies.writeCache(graphCachePath, graphPayload, "replace");
|
|
144
|
+
if (!writeResult.ok) {
|
|
145
|
+
process.stderr.write(`[watch] Failed to update graph cache: ${writeResult.error}\n`);
|
|
146
|
+
return writeResult;
|
|
147
|
+
}
|
|
148
|
+
if (verbose) {
|
|
149
|
+
if (changedPath !== undefined) {
|
|
150
|
+
process.stdout.write(`[watch] Graph updated: ${graph.size} files, changed: ${changedPath}\n`);
|
|
151
|
+
} else {
|
|
152
|
+
process.stdout.write(`[watch] Initial graph computed: ${graph.size} files\n`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { ok: true, value: undefined };
|
|
156
|
+
} catch (err) {
|
|
157
|
+
const unknownError = toUnknownResult(err);
|
|
158
|
+
process.stderr.write(`[watch] Failed to rebuild graph: ${unknownError.error}\n`);
|
|
159
|
+
return unknownError;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Starts the long-running graph watch daemon.
|
|
165
|
+
*
|
|
166
|
+
* @param args - {@link WatchArgs} command arguments.
|
|
167
|
+
* @returns Promise<Result<never>>; common failures include FILE_WRITE_ERROR via wrapped
|
|
168
|
+
* UNKNOWN, runtime unavailability errors, and UNKNOWN.
|
|
169
|
+
*/
|
|
170
|
+
export async function watchCommand(args: WatchArgs): Promise<Result<never>> {
|
|
171
|
+
try {
|
|
172
|
+
const repoRoot = await findRepoRoot(process.cwd());
|
|
173
|
+
|
|
174
|
+
const initialRebuildResult = await rebuildGraphCache(repoRoot, undefined, args.verbose === true);
|
|
175
|
+
if (!initialRebuildResult.ok) {
|
|
176
|
+
return initialRebuildResult;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let pendingChangedPath: string | undefined;
|
|
180
|
+
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
181
|
+
let rebuildInProgress = false;
|
|
182
|
+
let rebuildQueued = false;
|
|
183
|
+
|
|
184
|
+
const triggerRebuild = async (): Promise<void> => {
|
|
185
|
+
if (rebuildInProgress) {
|
|
186
|
+
rebuildQueued = true;
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
rebuildInProgress = true;
|
|
191
|
+
try {
|
|
192
|
+
do {
|
|
193
|
+
rebuildQueued = false;
|
|
194
|
+
const changedPath = pendingChangedPath;
|
|
195
|
+
pendingChangedPath = undefined;
|
|
196
|
+
const rebuildResult = await rebuildGraphCache(repoRoot, changedPath, args.verbose === true);
|
|
197
|
+
if (!rebuildResult.ok) {
|
|
198
|
+
// Error already logged in rebuildGraphCache; continue watching for future changes.
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
} while (rebuildQueued);
|
|
202
|
+
} finally {
|
|
203
|
+
rebuildInProgress = false;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const scheduleRebuild = (changedPath: string): void => {
|
|
208
|
+
pendingChangedPath = changedPath;
|
|
209
|
+
|
|
210
|
+
if (debounceTimer !== undefined) {
|
|
211
|
+
clearTimeout(debounceTimer);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
debounceTimer = setTimeout(() => {
|
|
215
|
+
debounceTimer = undefined;
|
|
216
|
+
void triggerRebuild();
|
|
217
|
+
}, WATCH_DEBOUNCE_MS);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const watchResult = resolveBunWatch();
|
|
221
|
+
if (!watchResult.ok) {
|
|
222
|
+
process.stderr.write(`[watch] ${watchResult.error}\n`);
|
|
223
|
+
return watchResult;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const watcher = watchResult.value(repoRoot, { recursive: true }, (_event, filename) => {
|
|
227
|
+
if (filename === null) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const absolutePath = path.join(repoRoot, filename);
|
|
232
|
+
if (!isSourceFile(absolutePath)) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (args.verbose) {
|
|
237
|
+
process.stdout.write(`[watch] File changed: ${absolutePath}, recomputing...\n`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
scheduleRebuild(absolutePath);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const shutdown = (): void => {
|
|
244
|
+
if (debounceTimer !== undefined) {
|
|
245
|
+
clearTimeout(debounceTimer);
|
|
246
|
+
}
|
|
247
|
+
if (args.verbose) {
|
|
248
|
+
process.stdout.write("[watch] Shutting down\n");
|
|
249
|
+
}
|
|
250
|
+
if (typeof watcher.close === "function") {
|
|
251
|
+
watcher.close();
|
|
252
|
+
} else if (typeof watcher.stop === "function") {
|
|
253
|
+
watcher.stop();
|
|
254
|
+
}
|
|
255
|
+
process.exit(0);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
process.once("SIGINT", shutdown);
|
|
259
|
+
process.once("SIGTERM", shutdown);
|
|
260
|
+
|
|
261
|
+
return new Promise<never>(() => {
|
|
262
|
+
// Keep command alive until signal-based shutdown.
|
|
263
|
+
});
|
|
264
|
+
} catch (err) {
|
|
265
|
+
const unknownError = toUnknownResult(err);
|
|
266
|
+
const message = unknownError.error;
|
|
267
|
+
process.stderr.write(`[watch] Failed to start watcher: ${message}\n`);
|
|
268
|
+
return unknownError;
|
|
269
|
+
}
|
|
270
|
+
}
|