@thecat69/cache-ctrl 1.1.1 → 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 +98 -61
- package/cache_ctrl.ts +0 -30
- package/package.json +1 -1
- package/skills/cache-ctrl-caller/SKILL.md +51 -126
- package/skills/cache-ctrl-external/SKILL.md +28 -63
- package/skills/cache-ctrl-local/SKILL.md +59 -150
- package/src/analysis/pageRank.ts +1 -4
- package/src/cache/cacheManager.ts +1 -2
- package/src/cache/externalCache.ts +40 -22
- package/src/commands/checkFiles.ts +2 -2
- package/src/commands/invalidate.ts +5 -22
- package/src/commands/touch.ts +6 -23
- package/src/commands/uninstall.ts +103 -0
- package/src/commands/update.ts +65 -0
- package/src/commands/watch.ts +47 -30
- package/src/commands/writeLocal.ts +6 -8
- package/src/index.ts +65 -34
- package/src/types/cache.ts +0 -12
- package/src/types/commands.ts +26 -23
- package/src/types/result.ts +0 -3
- package/src/commands/checkFreshness.ts +0 -123
- package/src/http/freshnessChecker.ts +0 -138
package/src/commands/watch.ts
CHANGED
|
@@ -28,8 +28,16 @@ type BunWatchFunction = (
|
|
|
28
28
|
callback: BunWatchCallback,
|
|
29
29
|
) => WatcherHandle;
|
|
30
30
|
|
|
31
|
-
function resolveBunWatch(): Result<BunWatchFunction> {
|
|
32
|
-
const
|
|
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");
|
|
33
41
|
if (typeof watchFn !== "function") {
|
|
34
42
|
return {
|
|
35
43
|
ok: false,
|
|
@@ -104,19 +112,38 @@ export async function resolveSourceFilePaths(
|
|
|
104
112
|
return [...sourcePaths];
|
|
105
113
|
}
|
|
106
114
|
|
|
107
|
-
|
|
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>> {
|
|
108
135
|
try {
|
|
109
|
-
const sourceFilePaths = await resolveSourceFilePaths(repoRoot);
|
|
110
|
-
const graph = await buildGraph(sourceFilePaths, repoRoot);
|
|
111
|
-
const graphCachePath = resolveGraphCachePath(repoRoot);
|
|
136
|
+
const sourceFilePaths = await dependencies.resolveSourceFilePaths(repoRoot);
|
|
137
|
+
const graph = await dependencies.buildGraph(sourceFilePaths, repoRoot);
|
|
138
|
+
const graphCachePath = dependencies.resolveGraphCachePath(repoRoot);
|
|
112
139
|
const graphPayload: GraphCacheFile = {
|
|
113
140
|
files: serializeGraphToCache(graph),
|
|
114
141
|
computed_at: new Date().toISOString(),
|
|
115
142
|
};
|
|
116
|
-
const writeResult = await writeCache(graphCachePath, graphPayload, "replace");
|
|
143
|
+
const writeResult = await dependencies.writeCache(graphCachePath, graphPayload, "replace");
|
|
117
144
|
if (!writeResult.ok) {
|
|
118
145
|
process.stderr.write(`[watch] Failed to update graph cache: ${writeResult.error}\n`);
|
|
119
|
-
return;
|
|
146
|
+
return writeResult;
|
|
120
147
|
}
|
|
121
148
|
if (verbose) {
|
|
122
149
|
if (changedPath !== undefined) {
|
|
@@ -125,9 +152,11 @@ async function rebuildGraphCache(repoRoot: string, changedPath: string | undefin
|
|
|
125
152
|
process.stdout.write(`[watch] Initial graph computed: ${graph.size} files\n`);
|
|
126
153
|
}
|
|
127
154
|
}
|
|
155
|
+
return { ok: true, value: undefined };
|
|
128
156
|
} catch (err) {
|
|
129
|
-
const
|
|
130
|
-
process.stderr.write(`[watch] Failed to rebuild graph: ${
|
|
157
|
+
const unknownError = toUnknownResult(err);
|
|
158
|
+
process.stderr.write(`[watch] Failed to rebuild graph: ${unknownError.error}\n`);
|
|
159
|
+
return unknownError;
|
|
131
160
|
}
|
|
132
161
|
}
|
|
133
162
|
|
|
@@ -142,25 +171,9 @@ export async function watchCommand(args: WatchArgs): Promise<Result<never>> {
|
|
|
142
171
|
try {
|
|
143
172
|
const repoRoot = await findRepoRoot(process.cwd());
|
|
144
173
|
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const initialPayload: GraphCacheFile = {
|
|
149
|
-
files: serializeGraphToCache(initialGraph),
|
|
150
|
-
computed_at: new Date().toISOString(),
|
|
151
|
-
};
|
|
152
|
-
const initialWriteResult = await writeCache(graphCachePath, initialPayload, "replace");
|
|
153
|
-
if (!initialWriteResult.ok) {
|
|
154
|
-
const errorMessage = `[watch] Failed to write initial graph cache: ${initialWriteResult.error}`;
|
|
155
|
-
process.stderr.write(`${errorMessage}\n`);
|
|
156
|
-
return {
|
|
157
|
-
ok: false,
|
|
158
|
-
error: errorMessage,
|
|
159
|
-
code: ErrorCode.UNKNOWN,
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
if (args.verbose) {
|
|
163
|
-
process.stdout.write(`[watch] Initial graph computed: ${initialGraph.size} files\n`);
|
|
174
|
+
const initialRebuildResult = await rebuildGraphCache(repoRoot, undefined, args.verbose === true);
|
|
175
|
+
if (!initialRebuildResult.ok) {
|
|
176
|
+
return initialRebuildResult;
|
|
164
177
|
}
|
|
165
178
|
|
|
166
179
|
let pendingChangedPath: string | undefined;
|
|
@@ -180,7 +193,11 @@ export async function watchCommand(args: WatchArgs): Promise<Result<never>> {
|
|
|
180
193
|
rebuildQueued = false;
|
|
181
194
|
const changedPath = pendingChangedPath;
|
|
182
195
|
pendingChangedPath = undefined;
|
|
183
|
-
await rebuildGraphCache(repoRoot, changedPath, args.verbose === true);
|
|
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
|
+
}
|
|
184
201
|
} while (rebuildQueued);
|
|
185
202
|
} finally {
|
|
186
203
|
rebuildInProgress = false;
|
|
@@ -30,13 +30,6 @@ function getSubmittedTrackedPaths(rawTrackedFiles: unknown): string[] {
|
|
|
30
30
|
return rawTrackedFiles.filter(hasStringPath).map((entry) => entry.path);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
function toPlainObjectRecord(rawValue: unknown): Record<string, unknown> {
|
|
34
|
-
if (typeof rawValue === "object" && rawValue !== null && !Array.isArray(rawValue)) {
|
|
35
|
-
return Object.fromEntries(Object.entries(rawValue));
|
|
36
|
-
}
|
|
37
|
-
return {};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
33
|
/**
|
|
41
34
|
* Validates and writes local context cache content with per-path merge semantics.
|
|
42
35
|
*
|
|
@@ -58,7 +51,12 @@ export async function writeLocalCommand(args: WriteArgs): Promise<Result<WriteRe
|
|
|
58
51
|
const resolvedTrackedFiles = await resolveTrackedFileStats(submittedPaths.map((path) => ({ path })), repoRoot);
|
|
59
52
|
const survivingSubmitted = resolvedTrackedFiles.filter((trackedFile) => trackedFile.mtime !== 0);
|
|
60
53
|
|
|
61
|
-
const submittedFacts =
|
|
54
|
+
const submittedFacts =
|
|
55
|
+
typeof contentWithTimestamp["facts"] === "object" &&
|
|
56
|
+
contentWithTimestamp["facts"] !== null &&
|
|
57
|
+
!Array.isArray(contentWithTimestamp["facts"])
|
|
58
|
+
? (contentWithTimestamp["facts"] as Record<string, unknown>)
|
|
59
|
+
: {};
|
|
62
60
|
const rawSubmittedFacts = contentWithTimestamp["facts"];
|
|
63
61
|
if (typeof rawSubmittedFacts === "object" && rawSubmittedFacts !== null && !Array.isArray(rawSubmittedFacts)) {
|
|
64
62
|
const violatingPaths = Object.keys(submittedFacts).filter((path) => !guardedPaths.has(path));
|
package/src/index.ts
CHANGED
|
@@ -5,12 +5,13 @@ import { flushCommand } from "./commands/flush.js";
|
|
|
5
5
|
import { invalidateCommand } from "./commands/invalidate.js";
|
|
6
6
|
import { touchCommand } from "./commands/touch.js";
|
|
7
7
|
import { pruneCommand } from "./commands/prune.js";
|
|
8
|
-
import { checkFreshnessCommand } from "./commands/checkFreshness.js";
|
|
9
8
|
import { checkFilesCommand } from "./commands/checkFiles.js";
|
|
10
9
|
import { searchCommand } from "./commands/search.js";
|
|
11
10
|
import { writeLocalCommand } from "./commands/writeLocal.js";
|
|
12
11
|
import { writeExternalCommand } from "./commands/writeExternal.js";
|
|
13
12
|
import { installCommand } from "./commands/install.js";
|
|
13
|
+
import { updateCommand } from "./commands/update.js";
|
|
14
|
+
import { uninstallCommand } from "./commands/uninstall.js";
|
|
14
15
|
import { graphCommand } from "./commands/graph.js";
|
|
15
16
|
import { mapCommand } from "./commands/map.js";
|
|
16
17
|
import { watchCommand } from "./commands/watch.js";
|
|
@@ -25,12 +26,13 @@ type CommandName =
|
|
|
25
26
|
| "invalidate"
|
|
26
27
|
| "touch"
|
|
27
28
|
| "prune"
|
|
28
|
-
| "check-freshness"
|
|
29
29
|
| "check-files"
|
|
30
30
|
| "search"
|
|
31
31
|
| "write-local"
|
|
32
32
|
| "write-external"
|
|
33
33
|
| "install"
|
|
34
|
+
| "update"
|
|
35
|
+
| "uninstall"
|
|
34
36
|
| "graph"
|
|
35
37
|
| "map"
|
|
36
38
|
| "watch"
|
|
@@ -129,19 +131,6 @@ const COMMAND_HELP: Record<CommandName, CommandHelp> = {
|
|
|
129
131
|
" --delete Actually delete the stale entries (dry-run if omitted)",
|
|
130
132
|
].join("\n"),
|
|
131
133
|
},
|
|
132
|
-
"check-freshness": {
|
|
133
|
-
usage: "check-freshness <subject-keyword> [--url <url>]",
|
|
134
|
-
description: "Send HTTP HEAD requests to verify source freshness",
|
|
135
|
-
details: [
|
|
136
|
-
" Arguments:",
|
|
137
|
-
" <subject-keyword> Keyword identifying the cache entry to check",
|
|
138
|
-
"",
|
|
139
|
-
" Options:",
|
|
140
|
-
" --url <url> Override the URL used for the HEAD request",
|
|
141
|
-
"",
|
|
142
|
-
" Output: HTTP response metadata and freshness verdict.",
|
|
143
|
-
].join("\n"),
|
|
144
|
-
},
|
|
145
134
|
"check-files": {
|
|
146
135
|
usage: "check-files",
|
|
147
136
|
description: "Compare tracked local files against stored mtime/hash",
|
|
@@ -202,6 +191,32 @@ const COMMAND_HELP: Record<CommandName, CommandHelp> = {
|
|
|
202
191
|
" Output: JSON object describing installed tool/skill paths.",
|
|
203
192
|
].join("\n"),
|
|
204
193
|
},
|
|
194
|
+
update: {
|
|
195
|
+
usage: "update [--config-dir <path>]",
|
|
196
|
+
description: "Update npm package globally and refresh OpenCode integration",
|
|
197
|
+
details: [
|
|
198
|
+
" Arguments:",
|
|
199
|
+
" (none)",
|
|
200
|
+
"",
|
|
201
|
+
" Options:",
|
|
202
|
+
" --config-dir <path> Override the OpenCode config directory (default: platform-specific)",
|
|
203
|
+
"",
|
|
204
|
+
" Output: JSON object with package update status, installed paths, and warnings.",
|
|
205
|
+
].join("\n"),
|
|
206
|
+
},
|
|
207
|
+
uninstall: {
|
|
208
|
+
usage: "uninstall [--config-dir <path>]",
|
|
209
|
+
description: "Remove OpenCode integration files and uninstall global npm package",
|
|
210
|
+
details: [
|
|
211
|
+
" Arguments:",
|
|
212
|
+
" (none)",
|
|
213
|
+
"",
|
|
214
|
+
" Options:",
|
|
215
|
+
" --config-dir <path> Override the OpenCode config directory (default: platform-specific)",
|
|
216
|
+
"",
|
|
217
|
+
" Output: JSON object with removed paths, npm uninstall status, and warnings.",
|
|
218
|
+
].join("\n"),
|
|
219
|
+
},
|
|
205
220
|
graph: {
|
|
206
221
|
usage: "graph [--max-tokens <number>] [--seed <path>[,<path>...]]",
|
|
207
222
|
description: "Return a PageRank-ranked dependency graph under a token budget",
|
|
@@ -349,7 +364,6 @@ export { usageError };
|
|
|
349
364
|
const VALUE_FLAGS = new Set([
|
|
350
365
|
"data",
|
|
351
366
|
"agent",
|
|
352
|
-
"url",
|
|
353
367
|
"max-age",
|
|
354
368
|
"filter",
|
|
355
369
|
"folder",
|
|
@@ -423,7 +437,7 @@ async function main(): Promise<void> {
|
|
|
423
437
|
|
|
424
438
|
const command = args[0];
|
|
425
439
|
if (!command) {
|
|
426
|
-
usageError("Usage: cache-ctrl <command> [args]. Commands: list, inspect, flush, invalidate, touch, prune, check-
|
|
440
|
+
usageError("Usage: cache-ctrl <command> [args]. Commands: list, inspect, flush, invalidate, touch, prune, check-files, search, write-local, write-external, install, update, uninstall, graph, map, watch, version");
|
|
427
441
|
}
|
|
428
442
|
|
|
429
443
|
switch (command) {
|
|
@@ -574,22 +588,6 @@ async function main(): Promise<void> {
|
|
|
574
588
|
break;
|
|
575
589
|
}
|
|
576
590
|
|
|
577
|
-
case "check-freshness": {
|
|
578
|
-
const subject = args[1];
|
|
579
|
-
if (!subject) {
|
|
580
|
-
usageError("Usage: cache-ctrl check-freshness <subject-keyword> [--url <url>]");
|
|
581
|
-
}
|
|
582
|
-
const url = typeof flags.url === "string" ? flags.url : undefined;
|
|
583
|
-
const result = await checkFreshnessCommand({ subject, ...(url !== undefined ? { url } : {}) });
|
|
584
|
-
if (result.ok) {
|
|
585
|
-
printResult(result, pretty);
|
|
586
|
-
} else {
|
|
587
|
-
printError(result, pretty);
|
|
588
|
-
process.exit(1);
|
|
589
|
-
}
|
|
590
|
-
break;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
591
|
case "check-files": {
|
|
594
592
|
const result = await checkFilesCommand();
|
|
595
593
|
if (result.ok) {
|
|
@@ -676,6 +674,9 @@ async function main(): Promise<void> {
|
|
|
676
674
|
}
|
|
677
675
|
|
|
678
676
|
case "install": {
|
|
677
|
+
if (flags["config-dir"] === true) {
|
|
678
|
+
usageError("--config-dir requires a value: --config-dir <path>");
|
|
679
|
+
}
|
|
679
680
|
const configDir = typeof flags["config-dir"] === "string" ? flags["config-dir"] : undefined;
|
|
680
681
|
const result = await installCommand({ ...(configDir !== undefined ? { configDir } : {}) });
|
|
681
682
|
if (result.ok) {
|
|
@@ -687,6 +688,36 @@ async function main(): Promise<void> {
|
|
|
687
688
|
break;
|
|
688
689
|
}
|
|
689
690
|
|
|
691
|
+
case "update": {
|
|
692
|
+
if (flags["config-dir"] === true) {
|
|
693
|
+
usageError("--config-dir requires a value: --config-dir <path>");
|
|
694
|
+
}
|
|
695
|
+
const configDir = typeof flags["config-dir"] === "string" ? flags["config-dir"] : undefined;
|
|
696
|
+
const result = await updateCommand({ ...(configDir !== undefined ? { configDir } : {}) });
|
|
697
|
+
if (result.ok) {
|
|
698
|
+
printResult(result, pretty);
|
|
699
|
+
} else {
|
|
700
|
+
printError(result, pretty);
|
|
701
|
+
process.exit(1);
|
|
702
|
+
}
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
case "uninstall": {
|
|
707
|
+
if (flags["config-dir"] === true) {
|
|
708
|
+
usageError("--config-dir requires a value: --config-dir <path>");
|
|
709
|
+
}
|
|
710
|
+
const configDir = typeof flags["config-dir"] === "string" ? flags["config-dir"] : undefined;
|
|
711
|
+
const result = await uninstallCommand({ ...(configDir !== undefined ? { configDir } : {}) });
|
|
712
|
+
if (result.ok) {
|
|
713
|
+
printResult(result, pretty);
|
|
714
|
+
} else {
|
|
715
|
+
printError(result, pretty);
|
|
716
|
+
process.exit(1);
|
|
717
|
+
}
|
|
718
|
+
break;
|
|
719
|
+
}
|
|
720
|
+
|
|
690
721
|
case "graph": {
|
|
691
722
|
if (flags["max-tokens"] === true) {
|
|
692
723
|
usageError("--max-tokens requires a numeric value");
|
|
@@ -770,7 +801,7 @@ async function main(): Promise<void> {
|
|
|
770
801
|
}
|
|
771
802
|
|
|
772
803
|
default:
|
|
773
|
-
usageError(`Unknown command: "${command}". Commands: list, inspect, flush, invalidate, touch, prune, check-
|
|
804
|
+
usageError(`Unknown command: "${command}". Commands: list, inspect, flush, invalidate, touch, prune, check-files, search, write-local, write-external, install, update, uninstall, graph, map, watch, version`);
|
|
774
805
|
}
|
|
775
806
|
}
|
|
776
807
|
|
package/src/types/cache.ts
CHANGED
|
@@ -21,17 +21,6 @@ const SourceSchema = z.object({
|
|
|
21
21
|
version: z.string().optional(),
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
-
const HeaderMetaSchema = z.object({
|
|
25
|
-
etag: z.string().optional(),
|
|
26
|
-
last_modified: z.string().optional(),
|
|
27
|
-
checked_at: z.string(),
|
|
28
|
-
// "unchecked" = entry written without HTTP check
|
|
29
|
-
status: z.enum(["fresh", "stale", "unchecked"]),
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
/** Stored HTTP validator metadata for one source URL in an external cache entry. */
|
|
33
|
-
export type HeaderMeta = z.infer<typeof HeaderMetaSchema>;
|
|
34
|
-
|
|
35
24
|
/**
|
|
36
25
|
* Validates external context cache JSON files stored under `.ai/external-context-gatherer_cache/`.
|
|
37
26
|
*/
|
|
@@ -40,7 +29,6 @@ export const ExternalCacheFileSchema = z.looseObject({
|
|
|
40
29
|
description: z.string(),
|
|
41
30
|
fetched_at: z.string(),
|
|
42
31
|
sources: z.array(SourceSchema),
|
|
43
|
-
header_metadata: z.record(z.string(), HeaderMetaSchema),
|
|
44
32
|
});
|
|
45
33
|
|
|
46
34
|
/** Validates one tracked file baseline used by local file-change detection. */
|
package/src/types/commands.ts
CHANGED
|
@@ -125,29 +125,6 @@ export type PruneResult = {
|
|
|
125
125
|
};
|
|
126
126
|
};
|
|
127
127
|
|
|
128
|
-
// ── check-freshness ───────────────────────────────────────────────────────────
|
|
129
|
-
|
|
130
|
-
/** Arguments accepted by the `check-freshness` command. */
|
|
131
|
-
export interface CheckFreshnessArgs {
|
|
132
|
-
subject: string;
|
|
133
|
-
url?: string;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** Success payload shape returned by the `check-freshness` command. */
|
|
137
|
-
export type CheckFreshnessResult = {
|
|
138
|
-
ok: true;
|
|
139
|
-
value: {
|
|
140
|
-
subject: string;
|
|
141
|
-
sources: Array<{
|
|
142
|
-
url: string;
|
|
143
|
-
status: "fresh" | "stale" | "error";
|
|
144
|
-
http_status?: number;
|
|
145
|
-
error?: string;
|
|
146
|
-
}>;
|
|
147
|
-
overall: "fresh" | "stale" | "error";
|
|
148
|
-
};
|
|
149
|
-
};
|
|
150
|
-
|
|
151
128
|
// ── check-files ───────────────────────────────────────────────────────────────
|
|
152
129
|
|
|
153
130
|
/** Success payload shape returned by the `check-files` command. */
|
|
@@ -288,3 +265,29 @@ export type VersionArgs = Record<string, never>;
|
|
|
288
265
|
|
|
289
266
|
/** Success payload shape returned by the `version` command. */
|
|
290
267
|
export type VersionResult = { value: { version: string } };
|
|
268
|
+
|
|
269
|
+
// ── update / uninstall ────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
/** Arguments accepted by the `update` command. */
|
|
272
|
+
export interface UpdateArgs {
|
|
273
|
+
configDir?: string;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Success payload shape returned by the `update` command. */
|
|
277
|
+
export interface UpdateResult {
|
|
278
|
+
packageUpdated: boolean;
|
|
279
|
+
installedPaths: string[];
|
|
280
|
+
warnings: string[];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Arguments accepted by the `uninstall` command. */
|
|
284
|
+
export interface UninstallArgs {
|
|
285
|
+
configDir?: string;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Success payload shape returned by the `uninstall` command. */
|
|
289
|
+
export interface UninstallResult {
|
|
290
|
+
removed: string[];
|
|
291
|
+
packageUninstalled: boolean;
|
|
292
|
+
warnings: string[];
|
|
293
|
+
}
|
package/src/types/result.ts
CHANGED
|
@@ -28,9 +28,6 @@ export enum ErrorCode {
|
|
|
28
28
|
/** Returned when keyword matching yields multiple top-scoring candidates. */
|
|
29
29
|
AMBIGUOUS_MATCH = "AMBIGUOUS_MATCH",
|
|
30
30
|
|
|
31
|
-
/** Returned when a user-specified URL is not present in the matched entry's sources list. */
|
|
32
|
-
URL_NOT_FOUND = "URL_NOT_FOUND",
|
|
33
|
-
|
|
34
31
|
/** Returned for unexpected internal exceptions converted at command boundaries. */
|
|
35
32
|
UNKNOWN = "UNKNOWN",
|
|
36
33
|
}
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
import { findRepoRoot, readCache, writeCache } from "../cache/cacheManager.js";
|
|
2
|
-
import { isExternalStale, mergeHeaderMetadata, resolveTopExternalMatch } from "../cache/externalCache.js";
|
|
3
|
-
import { checkFreshness } from "../http/freshnessChecker.js";
|
|
4
|
-
import type { ExternalCacheFile, HeaderMeta } from "../types/cache.js";
|
|
5
|
-
import { ExternalCacheFileSchema } from "../types/cache.js";
|
|
6
|
-
import { ErrorCode, type Result } from "../types/result.js";
|
|
7
|
-
import type { CheckFreshnessArgs, CheckFreshnessResult } from "../types/commands.js";
|
|
8
|
-
import { getFileStem } from "../utils/fileStem.js";
|
|
9
|
-
import { toUnknownResult } from "../utils/errors.js";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Performs HTTP freshness checks for the matched external cache entry sources.
|
|
13
|
-
*
|
|
14
|
-
* @param args - {@link CheckFreshnessArgs} command arguments.
|
|
15
|
-
* @returns Promise<Result<CheckFreshnessResult["value"]>>; common failures include
|
|
16
|
-
* NO_MATCH, URL_NOT_FOUND, PARSE_ERROR, FILE_READ_ERROR/FILE_WRITE_ERROR, and UNKNOWN.
|
|
17
|
-
*/
|
|
18
|
-
export async function checkFreshnessCommand(args: CheckFreshnessArgs): Promise<Result<CheckFreshnessResult["value"]>> {
|
|
19
|
-
try {
|
|
20
|
-
const repoRoot = await findRepoRoot(process.cwd());
|
|
21
|
-
|
|
22
|
-
// Find the best-matching external cache entry file path
|
|
23
|
-
const matchResult = await resolveTopExternalMatch(repoRoot, args.subject);
|
|
24
|
-
if (!matchResult.ok) return matchResult;
|
|
25
|
-
|
|
26
|
-
const filePath = matchResult.value;
|
|
27
|
-
|
|
28
|
-
// Load the matched entry's data
|
|
29
|
-
const readResult = await readCache(filePath);
|
|
30
|
-
if (!readResult.ok) return readResult;
|
|
31
|
-
|
|
32
|
-
const parseResult = ExternalCacheFileSchema.safeParse(readResult.value);
|
|
33
|
-
if (!parseResult.success) {
|
|
34
|
-
return { ok: false, error: `Malformed external cache file: ${filePath}`, code: ErrorCode.PARSE_ERROR };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const cacheEntry = parseResult.data;
|
|
38
|
-
const stem = getFileStem(filePath);
|
|
39
|
-
const subject = cacheEntry.subject ?? stem;
|
|
40
|
-
|
|
41
|
-
// Determine which URLs to check
|
|
42
|
-
const sources = cacheEntry.sources ?? [];
|
|
43
|
-
const specificSource = args.url ? sources.find((source) => source.url === args.url) : undefined;
|
|
44
|
-
if (args.url && !specificSource) {
|
|
45
|
-
return {
|
|
46
|
-
ok: false,
|
|
47
|
-
error: `URL not found in sources for subject '${subject}'`,
|
|
48
|
-
code: ErrorCode.URL_NOT_FOUND,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
const urlsToCheck = specificSource ? [specificSource] : sources;
|
|
52
|
-
|
|
53
|
-
// Check freshness for each URL
|
|
54
|
-
const sourceResults: Array<{
|
|
55
|
-
url: string;
|
|
56
|
-
status: "fresh" | "stale" | "error";
|
|
57
|
-
http_status?: number;
|
|
58
|
-
error?: string;
|
|
59
|
-
}> = [];
|
|
60
|
-
|
|
61
|
-
const headerUpdates: Record<string, HeaderMeta> = {};
|
|
62
|
-
|
|
63
|
-
for (const source of urlsToCheck) {
|
|
64
|
-
const stored = cacheEntry.header_metadata?.[source.url];
|
|
65
|
-
const result = await checkFreshness({
|
|
66
|
-
url: source.url,
|
|
67
|
-
...(stored?.etag !== undefined ? { etag: stored.etag } : {}),
|
|
68
|
-
...(stored?.last_modified !== undefined ? { last_modified: stored.last_modified } : {}),
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
sourceResults.push({
|
|
72
|
-
url: result.url,
|
|
73
|
-
status: result.status,
|
|
74
|
-
...(result.http_status !== undefined ? { http_status: result.http_status } : {}),
|
|
75
|
-
...(result.error !== undefined ? { error: result.error } : {}),
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
if (result.status !== "error") {
|
|
79
|
-
headerUpdates[source.url] = {
|
|
80
|
-
...(result.etag !== undefined ? { etag: result.etag } : {}),
|
|
81
|
-
...(result.last_modified !== undefined ? { last_modified: result.last_modified } : {}),
|
|
82
|
-
checked_at: new Date().toISOString(),
|
|
83
|
-
status: result.status,
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Only write back if at least one URL succeeded
|
|
89
|
-
const hasSuccessfulChecks = Object.keys(headerUpdates).length > 0;
|
|
90
|
-
if (hasSuccessfulChecks) {
|
|
91
|
-
const updated = mergeHeaderMetadata(cacheEntry, headerUpdates);
|
|
92
|
-
const writeResult = await writeCache(filePath, { header_metadata: updated.header_metadata });
|
|
93
|
-
if (!writeResult.ok) return writeResult;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Determine overall status: entryIsOld always wins (stale by age), then anyStale, then allError
|
|
97
|
-
const allError = sourceResults.every((r) => r.status === "error");
|
|
98
|
-
const anyStale = sourceResults.some((r) => r.status === "stale");
|
|
99
|
-
const entryIsOld = isExternalStale(cacheEntry);
|
|
100
|
-
|
|
101
|
-
let overall: "fresh" | "stale" | "error";
|
|
102
|
-
if (entryIsOld) {
|
|
103
|
-
overall = "stale";
|
|
104
|
-
} else if (anyStale) {
|
|
105
|
-
overall = "stale";
|
|
106
|
-
} else if (allError && sourceResults.length > 0) {
|
|
107
|
-
overall = "error";
|
|
108
|
-
} else {
|
|
109
|
-
overall = "fresh";
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return {
|
|
113
|
-
ok: true,
|
|
114
|
-
value: {
|
|
115
|
-
subject,
|
|
116
|
-
sources: sourceResults,
|
|
117
|
-
overall,
|
|
118
|
-
},
|
|
119
|
-
};
|
|
120
|
-
} catch (err) {
|
|
121
|
-
return toUnknownResult(err);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
/** Input payload for one HTTP freshness check request. */
|
|
2
|
-
export interface FreshnessCheckInput {
|
|
3
|
-
url: string;
|
|
4
|
-
etag?: string;
|
|
5
|
-
last_modified?: string;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Output payload for one HTTP freshness check request.
|
|
10
|
-
*
|
|
11
|
-
* @remarks Status semantics: HTTP 304 maps to `fresh`, HTTP 200 maps to `stale`, and
|
|
12
|
-
* all other outcomes (network errors, blocked URLs, non-200/304 responses) map to `error`.
|
|
13
|
-
*/
|
|
14
|
-
export interface FreshnessCheckOutput {
|
|
15
|
-
url: string;
|
|
16
|
-
status: "fresh" | "stale" | "error";
|
|
17
|
-
http_status?: number;
|
|
18
|
-
etag?: string;
|
|
19
|
-
last_modified?: string;
|
|
20
|
-
error?: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* RFC-1918 / loopback / link-local / ULA / mapped-IPv6 IP pattern.
|
|
25
|
-
* Blocks raw IP literals only — does NOT do DNS resolution.
|
|
26
|
-
*
|
|
27
|
-
* Covers:
|
|
28
|
-
* - 127.x loopback IPv4
|
|
29
|
-
* - ::1 loopback IPv6 (URL.hostname returns "[::1]")
|
|
30
|
-
* - localhost loopback hostname
|
|
31
|
-
* - 10.x RFC-1918 class A
|
|
32
|
-
* - 169.254.x link-local IPv4
|
|
33
|
-
* - 172.16–31.x RFC-1918 class B
|
|
34
|
-
* - 192.168.x RFC-1918 class C
|
|
35
|
-
* - 0.0.0.0 unspecified IPv4
|
|
36
|
-
* - fc00::/7 RFC-4193 unique-local IPv6 (ULA — fc or fd prefix)
|
|
37
|
-
* - ::ffff: IPv4-mapped IPv6
|
|
38
|
-
*/
|
|
39
|
-
const PRIVATE_IP_PATTERN =
|
|
40
|
-
/^(127\.|localhost$|10\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|0\.0\.0\.0$|\[::1\]$|::1$|::ffff:|\[::ffff:|f[cd][0-9a-f]{0,2}:|\[f[cd][0-9a-f]{0,2}:)/i;
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Validates whether a URL is eligible for outbound freshness checks.
|
|
44
|
-
*
|
|
45
|
-
* @remarks Security control for SSRF risk reduction. Blocks non-HTTP(S) schemes and host
|
|
46
|
-
* patterns that target loopback/private address space or raw IP-style local endpoints
|
|
47
|
-
* (for example localhost, RFC1918 IPv4 ranges, loopback/link-local, and mapped/ULA IPv6).
|
|
48
|
-
*/
|
|
49
|
-
export function isAllowedUrl(url: string): { allowed: boolean; reason?: string } {
|
|
50
|
-
try {
|
|
51
|
-
const parsed = new URL(url);
|
|
52
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
53
|
-
return { allowed: false, reason: `Disallowed URL scheme — only http and https are permitted: ${url}` };
|
|
54
|
-
}
|
|
55
|
-
if (PRIVATE_IP_PATTERN.test(parsed.hostname)) {
|
|
56
|
-
return { allowed: false, reason: `Requests to private/loopback addresses are not permitted: ${url}` };
|
|
57
|
-
}
|
|
58
|
-
return { allowed: true };
|
|
59
|
-
} catch {
|
|
60
|
-
return { allowed: false, reason: `Invalid URL: ${url}` };
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Performs a conditional HTTP HEAD freshness check.
|
|
66
|
-
*
|
|
67
|
-
* @param input - URL plus optional stored validators (`etag`, `last_modified`).
|
|
68
|
-
* @returns Freshness verdict and response metadata for one URL.
|
|
69
|
-
* @remarks Uses a 10-second abort timeout, sends conditional headers when available,
|
|
70
|
-
* maps 304→fresh and 200→stale, and reports all other outcomes as `error`.
|
|
71
|
-
*/
|
|
72
|
-
export async function checkFreshness(input: FreshnessCheckInput): Promise<FreshnessCheckOutput> {
|
|
73
|
-
const allowCheck = isAllowedUrl(input.url);
|
|
74
|
-
if (!allowCheck.allowed) {
|
|
75
|
-
const reason = allowCheck.reason ?? `Freshness check blocked for URL: ${input.url}`;
|
|
76
|
-
return {
|
|
77
|
-
url: input.url,
|
|
78
|
-
status: "error",
|
|
79
|
-
error: reason,
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const controller = new AbortController();
|
|
84
|
-
const timeoutId = setTimeout(() => controller.abort(), 10_000);
|
|
85
|
-
|
|
86
|
-
try {
|
|
87
|
-
const headers: Record<string, string> = {};
|
|
88
|
-
if (input.etag) {
|
|
89
|
-
headers["If-None-Match"] = input.etag;
|
|
90
|
-
}
|
|
91
|
-
if (input.last_modified) {
|
|
92
|
-
headers["If-Modified-Since"] = input.last_modified;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const response = await fetch(input.url, {
|
|
96
|
-
method: "HEAD",
|
|
97
|
-
headers,
|
|
98
|
-
signal: controller.signal,
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
if (response.status === 304) {
|
|
102
|
-
return {
|
|
103
|
-
url: input.url,
|
|
104
|
-
status: "fresh",
|
|
105
|
-
http_status: 304,
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (response.status === 200) {
|
|
110
|
-
const etag = response.headers.get("etag") ?? undefined;
|
|
111
|
-
const lastModified = response.headers.get("last-modified") ?? undefined;
|
|
112
|
-
return {
|
|
113
|
-
url: input.url,
|
|
114
|
-
status: "stale",
|
|
115
|
-
http_status: 200,
|
|
116
|
-
...(etag !== undefined ? { etag } : {}),
|
|
117
|
-
...(lastModified !== undefined ? { last_modified: lastModified } : {}),
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// 4xx/5xx
|
|
122
|
-
return {
|
|
123
|
-
url: input.url,
|
|
124
|
-
status: "error",
|
|
125
|
-
http_status: response.status,
|
|
126
|
-
error: `HTTP ${response.status}: ${response.statusText}`,
|
|
127
|
-
};
|
|
128
|
-
} catch (err) {
|
|
129
|
-
const error = err as Error;
|
|
130
|
-
return {
|
|
131
|
-
url: input.url,
|
|
132
|
-
status: "error",
|
|
133
|
-
error: error.message,
|
|
134
|
-
};
|
|
135
|
-
} finally {
|
|
136
|
-
clearTimeout(timeoutId);
|
|
137
|
-
}
|
|
138
|
-
}
|