howtofixcli 0.7.8 → 0.7.9

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.
Files changed (152) hide show
  1. package/dist/chunk-27TRKD65.js +2 -0
  2. package/dist/chunk-3FYCFLNK.js +1 -0
  3. package/dist/chunk-56SEAGAQ.js +1 -0
  4. package/dist/chunk-63ZCQDXH.js +6 -0
  5. package/dist/chunk-6FUWMLWO.js +1 -0
  6. package/dist/chunk-7CHDNTH6.js +1 -0
  7. package/dist/chunk-ACOMNDUL.js +185 -0
  8. package/dist/chunk-AEVPET7X.js +2 -0
  9. package/dist/chunk-AGGIYXN7.js +2 -0
  10. package/dist/chunk-BHVBO4SC.js +1 -0
  11. package/dist/chunk-FPXVDP32.js +8 -0
  12. package/dist/chunk-GIUYIKH3.js +109 -0
  13. package/dist/chunk-GQCAJ56I.js +5 -0
  14. package/dist/chunk-GXB7RZHH.js +1 -0
  15. package/dist/chunk-IGEY3AWF.js +1 -0
  16. package/dist/chunk-ILDDDLRC.js +1 -0
  17. package/dist/chunk-JFYVRWJI.js +2 -0
  18. package/dist/chunk-MVLCGU3G.js +1 -0
  19. package/dist/chunk-N345KWWK.js +1 -0
  20. package/dist/chunk-NBQFPCRT.js +1 -0
  21. package/dist/chunk-OIPSLIZE.js +5 -0
  22. package/dist/chunk-ORJ2QZBG.js +3 -0
  23. package/dist/chunk-PCALBZOO.js +2 -0
  24. package/dist/chunk-PDNBGUYP.js +2 -0
  25. package/dist/chunk-S656RE4A.js +1 -0
  26. package/dist/chunk-TSEKPGK4.js +1 -0
  27. package/dist/chunk-U6CI6KXN.js +34 -0
  28. package/dist/chunk-UTYFLLSM.js +1 -0
  29. package/dist/chunk-V3R4FYTJ.js +1 -0
  30. package/dist/chunk-V656XVXV.js +8 -0
  31. package/dist/chunk-WKUJ7CFO.js +1 -0
  32. package/dist/chunk-WOT6VMZA.js +1 -0
  33. package/dist/chunk-WUPU6LTD.js +4 -0
  34. package/dist/chunk-WX3V5P7U.js +5 -0
  35. package/dist/chunk-ZKZ6WCI3.js +2 -0
  36. package/dist/main.js +94 -536
  37. package/package.json +15 -62
  38. package/dist/account-credential.js +0 -136
  39. package/dist/account-credential.test.js +0 -38
  40. package/dist/activity.js +0 -124
  41. package/dist/agent-defs.js +0 -237
  42. package/dist/agent-limiter.js +0 -68
  43. package/dist/agents.js +0 -554
  44. package/dist/anim.js +0 -361
  45. package/dist/args.js +0 -228
  46. package/dist/at-file.js +0 -28
  47. package/dist/brand-extension.js +0 -87
  48. package/dist/brand.js +0 -76
  49. package/dist/browser.js +0 -58
  50. package/dist/cache-report.js +0 -139
  51. package/dist/checkpoint.js +0 -437
  52. package/dist/claims.js +0 -167
  53. package/dist/cli-auth.js +0 -142
  54. package/dist/cli-checkin.js +0 -120
  55. package/dist/cli-credentials.js +0 -254
  56. package/dist/clipboard.js +0 -149
  57. package/dist/collapse.js +0 -129
  58. package/dist/commands.js +0 -301
  59. package/dist/compaction-guard.js +0 -81
  60. package/dist/context-extension.js +0 -17
  61. package/dist/dcp/commands.js +0 -172
  62. package/dist/dcp/compress-state.js +0 -139
  63. package/dist/dcp/compress.js +0 -329
  64. package/dist/dcp/dcp-extension.js +0 -288
  65. package/dist/dcp/dedup.js +0 -93
  66. package/dist/dcp/ids.js +0 -189
  67. package/dist/dcp/index.js +0 -25
  68. package/dist/dcp/prompts.js +0 -68
  69. package/dist/dcp/protected-content.js +0 -221
  70. package/dist/dcp/protected.js +0 -161
  71. package/dist/dcp/prune.js +0 -418
  72. package/dist/dcp/state.js +0 -176
  73. package/dist/device-id.js +0 -90
  74. package/dist/device-id.test.js +0 -23
  75. package/dist/errors.js +0 -63
  76. package/dist/extensions.js +0 -48
  77. package/dist/fatal.js +0 -35
  78. package/dist/feed.js +0 -498
  79. package/dist/fs-atomic.js +0 -54
  80. package/dist/gateway-errors.js +0 -90
  81. package/dist/git.js +0 -230
  82. package/dist/goal-extension.js +0 -88
  83. package/dist/goal.js +0 -222
  84. package/dist/hooks-extension.js +0 -176
  85. package/dist/hooks.js +0 -443
  86. package/dist/howtofix-account.js +0 -715
  87. package/dist/hud-line.js +0 -153
  88. package/dist/hwid.js +0 -130
  89. package/dist/interrupt.js +0 -60
  90. package/dist/json-events.js +0 -50
  91. package/dist/level-style.js +0 -112
  92. package/dist/loop-guard.js +0 -95
  93. package/dist/lsp-tools.js +0 -32
  94. package/dist/lsp.js +0 -347
  95. package/dist/mcp-config.js +0 -85
  96. package/dist/mcp-lifecycle.js +0 -147
  97. package/dist/model-catalog.js +0 -217
  98. package/dist/model-resolve.js +0 -65
  99. package/dist/notes.js +0 -78
  100. package/dist/oauth-login.js +0 -568
  101. package/dist/paths.js +0 -61
  102. package/dist/permissions.js +0 -298
  103. package/dist/plan-mode.js +0 -363
  104. package/dist/preset-command.js +0 -227
  105. package/dist/preset-extension.js +0 -35
  106. package/dist/preset-mcp.js +0 -292
  107. package/dist/presets.js +0 -727
  108. package/dist/prompt-files.js +0 -103
  109. package/dist/prompt-text.js +0 -41
  110. package/dist/proxy.js +0 -137
  111. package/dist/quality/ast.js +0 -196
  112. package/dist/quality/cognitive.js +0 -83
  113. package/dist/quality/cyclomatic.js +0 -30
  114. package/dist/quality/halstead.js +0 -83
  115. package/dist/quality/index.js +0 -18
  116. package/dist/quality/length.js +0 -3
  117. package/dist/quality/maintainability.js +0 -15
  118. package/dist/quality/nesting.js +0 -57
  119. package/dist/quality/report.js +0 -803
  120. package/dist/quality/smells.js +0 -67
  121. package/dist/quality-tool.js +0 -63
  122. package/dist/read-only-commands.js +0 -597
  123. package/dist/repo-map.js +0 -292
  124. package/dist/rtk.js +0 -193
  125. package/dist/sandbox.js +0 -105
  126. package/dist/schema.js +0 -214
  127. package/dist/session-lifecycle.js +0 -19
  128. package/dist/session-retention.js +0 -111
  129. package/dist/session.js +0 -446
  130. package/dist/sessions.js +0 -100
  131. package/dist/settings.js +0 -148
  132. package/dist/skills.js +0 -35
  133. package/dist/slop-tool.js +0 -96
  134. package/dist/spend-cap.js +0 -43
  135. package/dist/subagent.js +0 -171
  136. package/dist/tar.js +0 -146
  137. package/dist/theme.js +0 -79
  138. package/dist/thinking.js +0 -45
  139. package/dist/todo.js +0 -114
  140. package/dist/tool-loadout.js +0 -97
  141. package/dist/tool-render.js +0 -769
  142. package/dist/tools.js +0 -176
  143. package/dist/ui.js +0 -4757
  144. package/dist/update.js +0 -533
  145. package/dist/usage-segment.js +0 -69
  146. package/dist/web-tools.js +0 -46
  147. package/dist/web.js +0 -265
  148. package/dist/welcome.js +0 -179
  149. package/dist/workflow-engine.js +0 -246
  150. package/dist/workflow-progress.js +0 -446
  151. package/dist/workflow-view.js +0 -195
  152. package/dist/workflow.js +0 -349
package/dist/presets.js DELETED
@@ -1,727 +0,0 @@
1
- /**
2
- * Presets: developer-provided bundles of skills and prompt text.
3
- *
4
- * A preset is a directory holding a `preset.json` manifest, a `skills/` tree and
5
- * an optional `prompt.md`. It is never installed on its own. Registering a skill
6
- * puts its name and description into the system prompt of every request, so a
7
- * preset is a catalogue entry the user opts into — not something that arrives
8
- * with the install and quietly makes every request heavier.
9
- *
10
- * Nothing here executes anything from a preset. A preset is text: instructions
11
- * the model reads. The only side effect of installing one is copying files into
12
- * a directory this module owns, after checking that every path stays inside the
13
- * preset it came from.
14
- *
15
- * Two sources feed the catalogue:
16
- *
17
- * - bundled — the suite shipped with the package (`presets/` at the package
18
- * root, copied to `dist/presets/` by both build paths). These are
19
- * the developer-provided presets.
20
- * - installed — what the user has opted into, under `AGENT_DIR/presets` and
21
- * `<cwd>/.howtofix/presets`.
22
- */
23
- import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
24
- import { tmpdir } from "node:os";
25
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
26
- import { fileURLToPath } from "node:url";
27
- import { execFileSync } from "node:child_process";
28
- import { createHash } from "node:crypto";
29
- import { AGENT_DIR } from "./paths.js";
30
- import { validateMcpBlock } from "./preset-mcp.js";
31
- import { isSafeRelativePath, parseTarGz, subtreeEntries } from "./tar.js";
32
- /** User-global install root. */
33
- export function userPresetsDir() {
34
- return join(AGENT_DIR, "presets");
35
- }
36
- /** Project-scoped install root. */
37
- export function projectPresetsDir(cwd) {
38
- return join(cwd, ".howtofix", "presets");
39
- }
40
- /** Integration presets stay dormant until the user explicitly enables them. */
41
- export function isPresetDisabled(dir) {
42
- return existsSync(join(dir, ".disabled"));
43
- }
44
- /**
45
- * The preset suite bundled with this install (`presets/` at the package root,
46
- * copied to `dist/presets/`).
47
- *
48
- * Resolved from `import.meta.url` for the same reason `bundledAssetsDir` is: at
49
- * runtime the working directory is the user's project, which has none of our
50
- * assets in it.
51
- */
52
- export function bundledPresetsDir() {
53
- return join(dirname(fileURLToPath(import.meta.url)), "presets");
54
- }
55
- /** Both install roots, in load order: project first, then user-global. */
56
- export function presetRoots(cwd) {
57
- return [projectPresetsDir(cwd), userPresetsDir()];
58
- }
59
- const NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
60
- const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
61
- function isRecord(value) {
62
- return typeof value === "object" && value !== null && !Array.isArray(value);
63
- }
64
- /**
65
- * Check that `child` resolves inside `root`.
66
- *
67
- * The whole point of a preset is that it is a self-contained directory of text.
68
- * A manifest naming `../../.ssh` would copy a user's keys into a skill tree and
69
- * hand them to the model on the next call, so every path a manifest contributes
70
- * is resolved and compared against the preset root before it is used.
71
- */
72
- function insideRoot(root, child) {
73
- const rel = relative(resolve(root), resolve(child));
74
- return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
75
- }
76
- /**
77
- * Validate a manifest and its referenced files.
78
- *
79
- * Returns every problem rather than the first, so a preset author sees the whole
80
- * list in one run instead of fixing them one per attempt.
81
- */
82
- export function validatePreset(dir, value) {
83
- const problems = [];
84
- if (!isRecord(value))
85
- return ["preset.json must be a JSON object"];
86
- const str = (key) => {
87
- const raw = value[key];
88
- if (typeof raw !== "string" || raw.trim() === "") {
89
- problems.push(`${key} must be a non-empty string`);
90
- return "";
91
- }
92
- return raw.trim();
93
- };
94
- const name = str("name");
95
- const title = str("title");
96
- const description = str("description");
97
- const category = str("category");
98
- const version = str("version");
99
- if (name && !NAME_PATTERN.test(name)) {
100
- problems.push(`name "${name}" must be lowercase letters, digits and dashes`);
101
- }
102
- if (version && !SEMVER_PATTERN.test(version)) {
103
- problems.push(`version "${version}" is not a version like 1.0.0`);
104
- }
105
- const rawSkills = value.skills;
106
- if (!Array.isArray(rawSkills) || rawSkills.length === 0) {
107
- problems.push("skills must be a non-empty array of directory names");
108
- }
109
- else {
110
- for (const entry of rawSkills) {
111
- if (typeof entry !== "string" || entry.trim() === "") {
112
- problems.push("every skills entry must be a non-empty string");
113
- continue;
114
- }
115
- const skillDir = join(dir, "skills", entry.trim());
116
- if (!insideRoot(join(dir, "skills"), skillDir)) {
117
- problems.push(`skill "${entry}" escapes the preset directory`);
118
- continue;
119
- }
120
- if (!existsSync(join(skillDir, "SKILL.md"))) {
121
- problems.push(`skill "${entry}" has no SKILL.md at ${join("skills", entry)}`);
122
- }
123
- }
124
- }
125
- const rawPrompt = value.prompt;
126
- if (rawPrompt !== undefined) {
127
- if (typeof rawPrompt !== "string" || rawPrompt.trim() === "") {
128
- problems.push("prompt must be a non-empty string when present");
129
- }
130
- else {
131
- const promptPath = join(dir, rawPrompt.trim());
132
- if (!insideRoot(dir, promptPath)) {
133
- problems.push(`prompt "${rawPrompt}" escapes the preset directory`);
134
- }
135
- else if (!existsSync(promptPath)) {
136
- problems.push(`prompt file "${rawPrompt}" does not exist`);
137
- }
138
- }
139
- }
140
- const rawTags = value.tags;
141
- if (rawTags !== undefined && !Array.isArray(rawTags)) {
142
- problems.push("tags must be an array when present");
143
- }
144
- problems.push(...validateMcpBlock(value.mcp));
145
- return problems;
146
- }
147
- /** Read and validate one preset directory. */
148
- export function readPreset(dir) {
149
- const manifestPath = join(dir, "preset.json");
150
- if (!existsSync(manifestPath))
151
- return { problems: [`no preset.json in ${dir}`] };
152
- let parsed;
153
- try {
154
- parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
155
- }
156
- catch (err) {
157
- return { problems: [`preset.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`] };
158
- }
159
- const problems = validatePreset(dir, parsed);
160
- if (problems.length > 0)
161
- return { problems };
162
- const record = parsed;
163
- const tags = Array.isArray(record.tags) ? record.tags.filter((t) => typeof t === "string") : [];
164
- return {
165
- problems: [],
166
- manifest: {
167
- name: String(record.name),
168
- title: String(record.title),
169
- description: String(record.description),
170
- category: String(record.category),
171
- tags,
172
- version: String(record.version),
173
- skills: record.skills.map((s) => String(s)),
174
- prompt: typeof record.prompt === "string" ? record.prompt : undefined,
175
- mcp: record.mcp && typeof record.mcp === "object" && !Array.isArray(record.mcp) ? record.mcp : undefined,
176
- integrates: typeof record.integrates === "string" && record.integrates.trim() !== "" ? record.integrates.trim() : undefined,
177
- },
178
- };
179
- }
180
- /** Directories that may hold presets, ignoring ones that are missing. */
181
- function existingDirs(roots) {
182
- return roots.filter((root) => {
183
- try {
184
- return statSync(root).isDirectory();
185
- }
186
- catch {
187
- return false;
188
- }
189
- });
190
- }
191
- /**
192
- * Walk one root and read every preset under it.
193
- *
194
- * Two layouts are supported, because the catalogue and an install do not have the
195
- * same shape: the bundled suite groups presets by category
196
- * (`<root>/<category>/<name>/`), while an install lands directly under the root
197
- * (`<root>/<name>/`) — a category in the install path would be a second copy of
198
- * the manifest's own `category` field, free to disagree with it. A directory that
199
- * fails to validate is reported, not silently dropped: a broken preset that
200
- * vanishes from the menu is indistinguishable from one that was never installed.
201
- */
202
- function scanRoot(root, source) {
203
- const entries = [];
204
- const problems = [];
205
- const readOne = (dir, label) => {
206
- if (!existsSync(join(dir, "preset.json")))
207
- return;
208
- const { manifest, problems: issues } = readPreset(dir);
209
- if (manifest)
210
- entries.push({ manifest, dir, source });
211
- else
212
- problems.push(`${label}: ${issues.join("; ")}`);
213
- };
214
- // The root can disappear between the `isDirectory()` guard and this read — a
215
- // removal in another session, a cleanup — and an ENOENT thrown here would take
216
- // down whatever asked for the catalogue. A missing root is an empty catalogue.
217
- let firstLevel;
218
- try {
219
- firstLevel = readdirSync(root).sort();
220
- }
221
- catch {
222
- return { entries, problems };
223
- }
224
- for (const first of firstLevel) {
225
- // A dot-entry is never a preset: the install lock and an in-progress staging
226
- // directory both live here, and a staging directory holds a `preset.json` —
227
- // so without this it would be catalogued as a preset that exists only while
228
- // another install is running, and the menu would list a name that vanishes.
229
- if (first.startsWith("."))
230
- continue;
231
- const firstDir = join(root, first);
232
- try {
233
- if (!statSync(firstDir).isDirectory())
234
- continue;
235
- }
236
- catch {
237
- continue;
238
- }
239
- // Flat layout: the preset is this directory.
240
- if (existsSync(join(firstDir, "preset.json"))) {
241
- readOne(firstDir, first);
242
- continue;
243
- }
244
- // Grouped layout: `<category>/<name>/`.
245
- let names;
246
- try {
247
- names = readdirSync(firstDir).sort();
248
- }
249
- catch {
250
- continue;
251
- }
252
- for (const name of names) {
253
- if (name.startsWith("."))
254
- continue;
255
- const dir = join(firstDir, name);
256
- try {
257
- if (!statSync(dir).isDirectory())
258
- continue;
259
- }
260
- catch {
261
- continue;
262
- }
263
- readOne(dir, join(first, name));
264
- }
265
- }
266
- return { entries, problems };
267
- }
268
- /** The full catalogue: bundled presets plus everything installed. */
269
- export function listCatalog(cwd) {
270
- const bundled = scanRoot(bundledPresetsDir(), "bundled");
271
- const entries = [...bundled.entries];
272
- const problems = [...bundled.problems];
273
- // An installed preset of the same name replaces the bundled one: opting into a
274
- // preset and then editing it locally must not be silently overridden by the
275
- // copy that ships with the package.
276
- const seen = new Map(entries.map((entry) => [entry.manifest.name, entry]));
277
- for (const root of existingDirs(presetRoots(cwd))) {
278
- const scanned = scanRoot(root, "installed");
279
- problems.push(...scanned.problems);
280
- for (const entry of scanned.entries) {
281
- const previous = seen.get(entry.manifest.name);
282
- if (previous) {
283
- const index = entries.indexOf(previous);
284
- if (index >= 0)
285
- entries[index] = entry;
286
- seen.set(entry.manifest.name, entry);
287
- }
288
- else {
289
- entries.push(entry);
290
- seen.set(entry.manifest.name, entry);
291
- }
292
- }
293
- }
294
- return { entries, problems };
295
- }
296
- /** Category names present in the catalogue, sorted. */
297
- export function categoriesOf(entries) {
298
- return [...new Set(entries.map((entry) => entry.manifest.category))].sort();
299
- }
300
- /** Presets in one category, sorted by title. */
301
- export function presetsIn(entries, category) {
302
- return entries.filter((entry) => entry.manifest.category === category).sort((a, b) => a.manifest.title.localeCompare(b.manifest.title));
303
- }
304
- /**
305
- * Skill directories contributed by installed presets.
306
- *
307
- * Returned as search paths for the SDK resource loader, which scans a directory
308
- * for skill subdirectories. Only *installed* presets contribute: a bundled preset
309
- * that was never opted into must not put its skills into the prompt.
310
- */
311
- export function installedPresetSkillPaths(cwd) {
312
- const paths = [];
313
- for (const root of existingDirs(presetRoots(cwd))) {
314
- const { entries } = scanRoot(root, "installed");
315
- for (const entry of entries)
316
- if (!isPresetDisabled(entry.dir))
317
- paths.push(join(entry.dir, "skills"));
318
- }
319
- return paths;
320
- }
321
- /**
322
- * Prompt text contributed by installed presets.
323
- *
324
- * A preset's `prompt.md` is appended to the system prompt as its own section. It
325
- * is never allowed to replace the prompt: a preset that could rewrite the rules
326
- * could remove the ones that keep it honest.
327
- */
328
- export function installedPresetPrompts(cwd) {
329
- const out = [];
330
- for (const root of existingDirs(presetRoots(cwd))) {
331
- const { entries } = scanRoot(root, "installed");
332
- for (const entry of entries) {
333
- if (isPresetDisabled(entry.dir))
334
- continue;
335
- const rel = entry.manifest.prompt;
336
- if (!rel)
337
- continue;
338
- const file = join(entry.dir, rel);
339
- if (!insideRoot(entry.dir, file))
340
- continue;
341
- try {
342
- const text = readFileSync(file, "utf8").trim();
343
- if (text)
344
- out.push({ name: entry.manifest.name, title: entry.manifest.title, text });
345
- }
346
- catch {
347
- // A prompt file that disappeared between validation and read is a missing
348
- // section, not a failed session.
349
- }
350
- }
351
- }
352
- return out;
353
- }
354
- /** Total bytes of a preset's skills, for the "how much does this cost" line. */
355
- export function presetSize(dir, manifest) {
356
- let total = 0;
357
- for (const skill of manifest.skills) {
358
- try {
359
- total += statSync(join(dir, "skills", skill, "SKILL.md")).size;
360
- }
361
- catch {
362
- // Missing files were already reported by validation.
363
- }
364
- }
365
- return total;
366
- }
367
- /**
368
- * Sleep without yielding, for the synchronous install path.
369
- *
370
- * `copyPreset` is synchronous, so it cannot await a timer. `Atomics.wait` on a
371
- * shared buffer is the one way to block the current thread for a fixed time
372
- * without spinning a core.
373
- */
374
- function sleepSync(ms) {
375
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
376
- }
377
- /** Name of the lock that serialises installs across processes. */
378
- const INSTALL_LOCK = ".install.lock";
379
- /**
380
- * Run `body` while holding the install lock for `targetRoot`.
381
- *
382
- * The in-process `presetBusy` flag stops one CLI from racing itself, but nothing
383
- * stopped two sessions — or a session and a script — from installing into the
384
- * same project at the same time. That is a real collision: the swap below removes
385
- * a directory and renames another into its place, and a second process doing the
386
- * same between those two steps leaves a project with no preset, or with one
387
- * process's files where the other's should be.
388
- *
389
- * A lock file rather than a directory: `wx` is atomic on every filesystem this
390
- * runs on, and the pid inside makes a stuck lock diagnosable. A lock older than
391
- * two minutes is treated as abandoned and taken over — a killed process must not
392
- * wedge installs until someone deletes a file by hand.
393
- */
394
- function withInstallLock(targetRoot, body) {
395
- mkdirSync(targetRoot, { recursive: true });
396
- const lockPath = join(targetRoot, INSTALL_LOCK);
397
- const deadline = Date.now() + 60_000;
398
- for (;;) {
399
- try {
400
- writeFileSync(lockPath, `${process.pid}\n`, { flag: "wx" });
401
- break;
402
- }
403
- catch (err) {
404
- if (err.code !== "EEXIST")
405
- throw err;
406
- let stale = false;
407
- try {
408
- stale = Date.now() - statSync(lockPath).mtimeMs > 120_000;
409
- }
410
- catch {
411
- // The holder released it between the failed create and this stat. Retry.
412
- continue;
413
- }
414
- if (stale) {
415
- rmSync(lockPath, { force: true });
416
- continue;
417
- }
418
- if (Date.now() > deadline) {
419
- throw new Error(`another install is holding ${lockPath}; remove it if no install is running`);
420
- }
421
- sleepSync(100);
422
- }
423
- }
424
- try {
425
- // Clean up staging directories left by an install that was killed. They are
426
- // only safe to remove while holding the lock — another install may be using
427
- // its own right now — and they are never collected otherwise, so a few
428
- // crashed installs would otherwise accumulate megabytes in the project.
429
- for (const name of readdirSync(targetRoot)) {
430
- if (!name.startsWith(".staging-"))
431
- continue;
432
- rmSync(join(targetRoot, name), { recursive: true, force: true });
433
- }
434
- return body();
435
- }
436
- finally {
437
- rmSync(lockPath, { force: true });
438
- }
439
- }
440
- /**
441
- * Put `sourceDir` at `targetRoot/<name>`, replacing whatever is there.
442
- *
443
- * Staged and renamed rather than removed and copied. The previous version did
444
- * `rmSync(target)` then `cpSync(source, target)`, and between those two calls the
445
- * preset did not exist — a reader in that window (a reload, a second session
446
- * starting) saw a project with no preset, and a crash left it that way. It also
447
- * let two concurrent installs interleave: A removes, B removes, A copies, B
448
- * copies, and the result is a mix of both.
449
- *
450
- * The staging directory is created *inside* `targetRoot` so the rename stays on
451
- * one filesystem, where it is atomic. It is named with a leading dot, and the
452
- * catalogue scan skips dot-entries, so a staging directory in progress is never
453
- * mistaken for an installed preset.
454
- */
455
- function copyPreset(sourceDir, targetRoot, manifest) {
456
- const target = join(targetRoot, manifest.name);
457
- return withInstallLock(targetRoot, () => {
458
- mkdirSync(targetRoot, { recursive: true });
459
- const staging = mkdtempSync(join(targetRoot, ".staging-"));
460
- try {
461
- cpSync(sourceDir, staging, { recursive: true });
462
- // Replace rather than merge: a reinstall must not leave a skill from the
463
- // previous version behind, where it would keep loading with no file on disk
464
- // that explains where it came from.
465
- rmSync(target, { recursive: true, force: true });
466
- renameSync(staging, target);
467
- }
468
- catch (err) {
469
- rmSync(staging, { recursive: true, force: true });
470
- throw err;
471
- }
472
- return target;
473
- });
474
- }
475
- /** Install a preset from a local directory. */
476
- export function installFromDirectory(sourceDir, targetRoot) {
477
- if (!existsSync(sourceDir))
478
- return { ok: false, problems: [`no such directory: ${sourceDir}`] };
479
- const { manifest, problems } = readPreset(sourceDir);
480
- if (!manifest)
481
- return { ok: false, problems };
482
- const target = copyPreset(sourceDir, targetRoot, manifest);
483
- return { ok: true, name: manifest.name, target, problems: [] };
484
- }
485
- /**
486
- * Install a preset from a git URL.
487
- *
488
- * A shallow clone into a temporary directory, then the same validation a local
489
- * directory gets. The clone is what makes this safe to accept from a URL: the
490
- * repository is data to be read, never a program to be run — no install hooks,
491
- * no post-checkout script, nothing executed from the checkout.
492
- */
493
- export function installFromGit(url, targetRoot) {
494
- const staging = mkdtempSync(join(tmpdir(), "howtofix-preset-"));
495
- try {
496
- execFileSync("git", ["clone", "--depth", "1", "--quiet", url, staging], { stdio: "pipe" });
497
- return installFromDirectory(staging, targetRoot);
498
- }
499
- catch (err) {
500
- const message = err instanceof Error ? err.message : String(err);
501
- return { ok: false, problems: [`git clone failed: ${message.split("\n")[0]}`] };
502
- }
503
- finally {
504
- rmSync(staging, { recursive: true, force: true });
505
- }
506
- }
507
- /** Install from a git URL or a local path, whichever the argument looks like. */
508
- export function installFrom(source, targetRoot) {
509
- const looksLikeGit = /^(https?:\/\/|git@|git:\/\/)/.test(source);
510
- return looksLikeGit ? installFromGit(source, targetRoot) : installFromDirectory(resolve(source), targetRoot);
511
- }
512
- /**
513
- * Remove an installed preset.
514
- *
515
- * Only from the install roots — a bundled preset is part of the package and is
516
- * "removed" by never opting into it. Returns the paths that were removed.
517
- */
518
- export function removeInstalled(name, cwd) {
519
- const removed = [];
520
- const problems = [];
521
- if (!NAME_PATTERN.test(name))
522
- return { removed, problems: [`"${name}" is not a preset name`] };
523
- for (const root of presetRoots(cwd)) {
524
- const target = join(root, name);
525
- if (!existsSync(target))
526
- continue;
527
- if (!insideRoot(root, target)) {
528
- problems.push(`refusing to remove ${target}: outside ${root}`);
529
- continue;
530
- }
531
- rmSync(target, { recursive: true, force: true });
532
- removed.push(target);
533
- }
534
- if (removed.length === 0)
535
- problems.push(`"${name}" is not installed`);
536
- return { removed, problems };
537
- }
538
- /** Where a new install should land: the project root when there is one. */
539
- export function defaultInstallRoot(cwd) {
540
- return projectPresetsDir(cwd);
541
- }
542
- /** Digest of a directory tree: sha256 over `hash path` lines, sorted by path. */
543
- export function treeDigest(dir) {
544
- const files = [];
545
- const walk = (d, prefix) => {
546
- for (const entry of readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
547
- const p = join(d, entry.name);
548
- const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
549
- // Symlinks are skipped: a clone resolves them, a tarball records a link
550
- // entry, and hashing one side only would report a permanent mismatch.
551
- if (entry.isSymbolicLink())
552
- continue;
553
- if (entry.isDirectory())
554
- walk(p, rel);
555
- else if (entry.isFile())
556
- files.push([rel, createHash("sha256").update(readFileSync(p)).digest("hex")]);
557
- }
558
- };
559
- walk(dir, "");
560
- let bytes = 0;
561
- for (const [rel] of files)
562
- bytes += statSync(join(dir, rel)).size;
563
- const manifest = files.map(([rel, hash]) => `${hash} ${rel}`).join("\n");
564
- return {
565
- digest: createHash("sha256").update(manifest).digest("hex").slice(0, 16),
566
- fileCount: files.length,
567
- bytes,
568
- };
569
- }
570
- /** Read the bundled registry. A missing or malformed file is an empty list, not a crash. */
571
- export function readHub() {
572
- const file = join(bundledPresetsDir(), "hub.json");
573
- try {
574
- const raw = readFileSync(file, "utf8");
575
- const parsed = JSON.parse(raw);
576
- const list = isRecord(parsed) && Array.isArray(parsed.entries) ? parsed.entries : [];
577
- const entries = [];
578
- for (const item of list) {
579
- if (!isRecord(item))
580
- continue;
581
- if (typeof item.name !== "string" || typeof item.repo !== "string" || typeof item.sha !== "string")
582
- continue;
583
- if (typeof item.path !== "string" || typeof item.category !== "string")
584
- continue;
585
- entries.push({
586
- name: item.name,
587
- title: typeof item.title === "string" ? item.title : item.name,
588
- description: typeof item.description === "string" ? item.description : "",
589
- category: item.category,
590
- skill: typeof item.skill === "string" ? item.skill : item.name,
591
- path: item.path,
592
- repo: item.repo,
593
- sha: item.sha,
594
- license: typeof item.license === "string" ? item.license : "unknown",
595
- stars: typeof item.stars === "number" ? item.stars : 0,
596
- sizeKb: typeof item.sizeKb === "number" ? item.sizeKb : 0,
597
- // Carried through, not dropped: the install verifies this against what it
598
- // downloads, and an entry whose digest is silently discarded would look
599
- // verified while checking nothing.
600
- ...(typeof item.digest === "string" ? { digest: item.digest } : {}),
601
- ...(typeof item.fileCount === "number" ? { fileCount: item.fileCount } : {}),
602
- // Carried explicitly: this builder names every field, so a field added to
603
- // the generator and forgotten here is dropped silently and the registry
604
- // looks complete while saying nothing. That happened once with `digest`.
605
- ...(typeof item.modelInvocable === "boolean" ? { modelInvocable: item.modelInvocable } : {}),
606
- });
607
- }
608
- return { entries, problems: [] };
609
- }
610
- catch (err) {
611
- if (err?.code === "ENOENT")
612
- return { entries: [], problems: [] };
613
- return { entries: [], problems: [`registry unreadable: ${err instanceof Error ? err.message : String(err)}`] };
614
- }
615
- }
616
- /** Entries of the registry, grouped for the menu. */
617
- export function hubCategories(entries) {
618
- return [...new Set(entries.map((e) => e.category))].sort();
619
- }
620
- export function hubIn(entries, category) {
621
- return entries.filter((e) => e.category === category).sort((a, b) => b.stars - a.stars || a.name.localeCompare(b.name));
622
- }
623
- export function hubFind(entries, name) {
624
- return entries.find((e) => e.name === name);
625
- }
626
- /** Where a downloaded tarball for an entry comes from. */
627
- export function hubTarballUrl(entry) {
628
- return `https://codeload.github.com/${entry.repo}/tar.gz/${entry.sha}`;
629
- }
630
- /**
631
- * Download and install a registry entry.
632
- *
633
- * The archive is treated as data: entries are read, each path is checked against
634
- * the destination, and nothing in it is executed. The pinned `sha` means the
635
- * bytes are the ones the registry was built from, not whatever upstream has
636
- * pushed since.
637
- *
638
- * The timeout is 120s rather than 60s because the largest entry in the registry
639
- * is a 24 MB archive, and a timeout that a supported entry cannot meet turns a
640
- * slow connection into "this preset is broken".
641
- */
642
- export async function installFromHub(entry, targetRoot, timeoutMs = 120_000) {
643
- const staging = mkdtempSync(join(tmpdir(), "howtofix-hub-"));
644
- try {
645
- const response = await fetch(hubTarballUrl(entry), {
646
- signal: AbortSignal.timeout(timeoutMs),
647
- redirect: "follow",
648
- });
649
- if (!response.ok) {
650
- return { ok: false, problems: [`${entry.repo}: HTTP ${response.status} from codeload`] };
651
- }
652
- const archive = Buffer.from(await response.arrayBuffer());
653
- const entries = parseTarGz(archive);
654
- const files = subtreeEntries(entries, entry.path).filter((e) => e.kind === "file" || e.kind === "directory");
655
- if (!files.some((e) => e.path === "SKILL.md")) {
656
- return { ok: false, problems: [`${entry.repo}@${entry.sha.slice(0, 7)} has no ${entry.path}/SKILL.md`] };
657
- }
658
- // Write into staging as `<preset>/skills/<skill>/…`, which is the layout a
659
- // preset directory has, so the same validation and the same install path run
660
- // for a downloaded preset as for a local one.
661
- const presetDir = join(staging, "preset", entry.name);
662
- const skillRoot = join(presetDir, "skills", entry.skill);
663
- let written = 0;
664
- for (const file of files) {
665
- if (!isSafeRelativePath(file.path)) {
666
- return { ok: false, problems: [`refusing unsafe path in archive: ${file.path}`] };
667
- }
668
- const destination = join(skillRoot, file.path);
669
- if (!insideRoot(skillRoot, destination)) {
670
- return { ok: false, problems: [`refusing path outside the skill: ${file.path}`] };
671
- }
672
- if (file.kind === "directory") {
673
- mkdirSync(destination, { recursive: true });
674
- continue;
675
- }
676
- mkdirSync(dirname(destination), { recursive: true });
677
- writeFileSync(destination, file.data ?? Buffer.alloc(0));
678
- written += 1;
679
- }
680
- const manifest = {
681
- name: entry.name,
682
- title: entry.title,
683
- description: entry.description,
684
- category: entry.category,
685
- version: "1.0.0",
686
- skills: [entry.skill],
687
- source: `${entry.repo}@${entry.sha}`,
688
- license: entry.license,
689
- };
690
- writeFileSync(join(presetDir, "preset.json"), `${JSON.stringify(manifest, null, 2)}\n`);
691
- // Verify what actually arrived against what the registry recorded. The `sha`
692
- // already pins the commit; this catches the other case — the commit is still
693
- // reachable but the skill's composition under it is not what was reviewed.
694
- // Every registry entry carries a digest (build-preset-hub.mjs writes one
695
- // for all 911); an entry without it cannot be verified against what was
696
- // reviewed, so it is refused rather than installed unchecked.
697
- if (!entry.digest) {
698
- return {
699
- ok: false,
700
- problems: [`${entry.repo}@${entry.sha.slice(0, 7)}: registry entry has no digest`, "refusing to install; content cannot be verified against what was reviewed"],
701
- };
702
- }
703
- const actual = treeDigest(skillRoot);
704
- if (actual.digest !== entry.digest) {
705
- return {
706
- ok: false,
707
- problems: [
708
- `${entry.repo}@${entry.sha.slice(0, 7)}: content changed under a pinned commit`,
709
- `expected ${entry.digest} (${entry.fileCount ?? "?"} files), got ${actual.digest} (${actual.fileCount} files)`,
710
- "refusing to install; the registry needs regenerating",
711
- ],
712
- };
713
- }
714
- const problems = validatePreset(presetDir, manifest);
715
- if (problems.length > 0)
716
- return { ok: false, problems };
717
- const target = copyPreset(presetDir, targetRoot, manifest);
718
- return { ok: true, name: entry.name, target, problems: [], skill: entry.skill, modelInvocable: entry.modelInvocable };
719
- }
720
- catch (err) {
721
- const message = err instanceof Error ? err.message : String(err);
722
- return { ok: false, problems: [`download failed: ${message.split("\n")[0]}`] };
723
- }
724
- finally {
725
- rmSync(staging, { recursive: true, force: true });
726
- }
727
- }