@autopilot-harness/cli 0.1.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.
Files changed (93) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +55 -0
  3. package/assets/autopilot-harness-hook.mjs +193 -0
  4. package/assets/vendor/migrations/001_initial.sql +62 -0
  5. package/assets/vendor/migrations/002_pending_followup.sql +4 -0
  6. package/assets/vendor/migrations/003_reviewing_item.sql +1 -0
  7. package/assets/vendor/runtime.mjs +5524 -0
  8. package/dist/assets/autopilot-harness-hook.mjs +193 -0
  9. package/dist/assets/vendor/migrations/001_initial.sql +62 -0
  10. package/dist/assets/vendor/migrations/002_pending_followup.sql +4 -0
  11. package/dist/assets/vendor/migrations/003_reviewing_item.sql +1 -0
  12. package/dist/assets/vendor/runtime.mjs +5524 -0
  13. package/dist/bin.d.ts +3 -0
  14. package/dist/bin.d.ts.map +1 -0
  15. package/dist/bin.js +300 -0
  16. package/dist/bin.js.map +1 -0
  17. package/dist/index.d.ts +23 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +13 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/init/config-merge.d.ts +27 -0
  22. package/dist/init/config-merge.d.ts.map +1 -0
  23. package/dist/init/config-merge.js +115 -0
  24. package/dist/init/config-merge.js.map +1 -0
  25. package/dist/init/default-config.d.ts +17 -0
  26. package/dist/init/default-config.d.ts.map +1 -0
  27. package/dist/init/default-config.js +101 -0
  28. package/dist/init/default-config.js.map +1 -0
  29. package/dist/init/hooks-merge.d.ts +25 -0
  30. package/dist/init/hooks-merge.d.ts.map +1 -0
  31. package/dist/init/hooks-merge.js +134 -0
  32. package/dist/init/hooks-merge.js.map +1 -0
  33. package/dist/init/install.d.ts +31 -0
  34. package/dist/init/install.d.ts.map +1 -0
  35. package/dist/init/install.js +829 -0
  36. package/dist/init/install.js.map +1 -0
  37. package/dist/init/platforms.d.ts +60 -0
  38. package/dist/init/platforms.d.ts.map +1 -0
  39. package/dist/init/platforms.js +275 -0
  40. package/dist/init/platforms.js.map +1 -0
  41. package/dist/init/tui.d.ts +65 -0
  42. package/dist/init/tui.d.ts.map +1 -0
  43. package/dist/init/tui.js +549 -0
  44. package/dist/init/tui.js.map +1 -0
  45. package/dist/init/types.d.ts +75 -0
  46. package/dist/init/types.d.ts.map +1 -0
  47. package/dist/init/types.js +9 -0
  48. package/dist/init/types.js.map +1 -0
  49. package/dist/init/wizard-copy.d.ts +74 -0
  50. package/dist/init/wizard-copy.d.ts.map +1 -0
  51. package/dist/init/wizard-copy.js +173 -0
  52. package/dist/init/wizard-copy.js.map +1 -0
  53. package/dist/init/wizard-helpers.d.ts +103 -0
  54. package/dist/init/wizard-helpers.d.ts.map +1 -0
  55. package/dist/init/wizard-helpers.js +724 -0
  56. package/dist/init/wizard-helpers.js.map +1 -0
  57. package/dist/locale-set.d.ts +24 -0
  58. package/dist/locale-set.d.ts.map +1 -0
  59. package/dist/locale-set.js +286 -0
  60. package/dist/locale-set.js.map +1 -0
  61. package/dist/names.d.ts +5 -0
  62. package/dist/names.d.ts.map +1 -0
  63. package/dist/names.js +5 -0
  64. package/dist/names.js.map +1 -0
  65. package/dist/project-fs.d.ts +48 -0
  66. package/dist/project-fs.d.ts.map +1 -0
  67. package/dist/project-fs.js +210 -0
  68. package/dist/project-fs.js.map +1 -0
  69. package/dist/read-untrusted-file.d.ts +41 -0
  70. package/dist/read-untrusted-file.d.ts.map +1 -0
  71. package/dist/read-untrusted-file.js +362 -0
  72. package/dist/read-untrusted-file.js.map +1 -0
  73. package/dist/session.d.ts +38 -0
  74. package/dist/session.d.ts.map +1 -0
  75. package/dist/session.js +242 -0
  76. package/dist/session.js.map +1 -0
  77. package/dist/status-doctor.d.ts +22 -0
  78. package/dist/status-doctor.d.ts.map +1 -0
  79. package/dist/status-doctor.js +712 -0
  80. package/dist/status-doctor.js.map +1 -0
  81. package/dist/uninstall.d.ts +31 -0
  82. package/dist/uninstall.d.ts.map +1 -0
  83. package/dist/uninstall.js +333 -0
  84. package/dist/uninstall.js.map +1 -0
  85. package/dist/upgrade.d.ts +29 -0
  86. package/dist/upgrade.d.ts.map +1 -0
  87. package/dist/upgrade.js +290 -0
  88. package/dist/upgrade.js.map +1 -0
  89. package/dist/vendor-entry.d.ts +6 -0
  90. package/dist/vendor-entry.d.ts.map +1 -0
  91. package/dist/vendor-entry.js +16 -0
  92. package/dist/vendor-entry.js.map +1 -0
  93. package/package.json +54 -0
@@ -0,0 +1,829 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { defaultConfigYaml } from "./default-config.js";
6
+ import { mergeHooksJson, validateHooksShape } from "./hooks-merge.js";
7
+ import { PACKAGE_VERSION } from "./types.js";
8
+ import { applyPlansGitignore, applyAutopilotRuntimeGitignore, assertNotSymlink, assertParentDirInProject, assertRealpathInside, assertPairInsideOrUnlinkAll, assertWrittenInsideProject, isRealDirectory, isRealRegularFile, mkdirRealDirSync, normalizePlansDir, writeQuickstart, assertPresentRealFile, } from "./wizard-helpers.js";
9
+ import { skillDescriptions } from "@autopilot-harness/i18n";
10
+ import { DEFAULT_AUTOPILOT_IGNORE_TEXT } from "@autopilot-harness/core";
11
+ import { readConfigInstallHints, readConfigPlatformsOrThrow } from "./config-merge.js";
12
+ import { applyPlatformsToConfigYaml, assertInstallablePlatforms, MAX_PLATFORM_BINDINGS, mergePlatformBindings, mergedIncludesAllRequested, normalizeBinding, primaryBinding, } from "./platforms.js";
13
+ import { MAX_UNTRUSTED_TEXT_BYTES, readUntrustedUtf8File, copyFileReplaceSync, copyFileNoFollowExclSync, writeFileReplaceSync, renameReplaceSync, } from "../read-untrusted-file.js";
14
+ export { mergeHooksJson, stripAutopilotHooks, isAutopilotCommand, countAutopilotDuplicates, validateHooksShape, hasCompleteAutopilotHooks, summarizeAutopilotHooks, autopilotStopHasUnlimitedLoop, autopilotHookCommand, } from "./hooks-merge.js";
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ /** Skills written by init; uninstall must remove the same set. */
17
+ export const AUTOPILOT_SKILL_NAMES = [
18
+ "autopilot-on",
19
+ "autopilot-run",
20
+ "autopilot-off",
21
+ "autopilot-resume",
22
+ "autopilot-replan",
23
+ ];
24
+ /** Workflow docs written by init; uninstall must remove the same set. */
25
+ export const AUTOPILOT_WORKFLOW_FILES = [
26
+ "autopilot-planning.md",
27
+ "autopilot-executing.md",
28
+ ];
29
+ const SKILL_NAMES = AUTOPILOT_SKILL_NAMES;
30
+ const WORKFLOW_FILES = AUTOPILOT_WORKFLOW_FILES;
31
+ function resolvePackageRoots() {
32
+ // src/init → ../../ = packages/cli; dist/init → ../../ = packages/cli
33
+ const cliRoot = path.resolve(__dirname, "../..");
34
+ const candidates = [
35
+ path.resolve(cliRoot, "../templates"),
36
+ path.resolve(cliRoot, "node_modules/@autopilot-harness/templates"),
37
+ ];
38
+ const templatesRoot = candidates.find((p) => isRealDirectory(path.join(p, "skills"))) ??
39
+ candidates[0];
40
+ return { cliRoot, templatesRoot };
41
+ }
42
+ function resolveHookAsset(cliRoot) {
43
+ const candidates = [
44
+ path.join(cliRoot, "assets", "autopilot-harness-hook.mjs"),
45
+ path.join(cliRoot, "dist", "assets", "autopilot-harness-hook.mjs"),
46
+ ];
47
+ return candidates.find((p) => isRealRegularFile(p)) ?? null;
48
+ }
49
+ function resolveVendorRoot(cliRoot) {
50
+ const candidates = [
51
+ path.join(cliRoot, "assets", "vendor"),
52
+ path.join(cliRoot, "dist", "assets", "vendor"),
53
+ ];
54
+ // Both files must come from the same vendor root (no assets/dist mix).
55
+ for (const dir of candidates) {
56
+ const runtime = path.join(dir, "runtime.mjs");
57
+ const mig = path.join(dir, "migrations", "001_initial.sql");
58
+ if (!isRealRegularFile(runtime) || !isRealRegularFile(mig))
59
+ continue;
60
+ if (!isRealDirectory(dir) || !isRealDirectory(path.join(dir, "migrations"))) {
61
+ continue;
62
+ }
63
+ try {
64
+ assertNotSymlink(dir, "vendor/");
65
+ assertNotSymlink(runtime, "vendor/runtime.mjs");
66
+ assertNotSymlink(path.join(dir, "migrations"), "vendor/migrations/");
67
+ assertNotSymlink(mig, "vendor/migrations/001_initial.sql");
68
+ }
69
+ catch {
70
+ continue;
71
+ }
72
+ return dir;
73
+ }
74
+ return null;
75
+ }
76
+ function copyVendorDir(cliRoot, destBin, projectRoot) {
77
+ const vendorRoot = resolveVendorRoot(cliRoot);
78
+ if (!vendorRoot) {
79
+ throw new Error("Missing assets/vendor/runtime.mjs or migrations — run pnpm bundle-vendor (or pnpm build)");
80
+ }
81
+ const runtimeSrc = path.join(vendorRoot, "runtime.mjs");
82
+ const migSrcDir = path.join(vendorRoot, "migrations");
83
+ const migFiles = fs
84
+ .readdirSync(migSrcDir)
85
+ .filter((f) => /^\d{3}_.+\.sql$/.test(f))
86
+ .sort();
87
+ if (!migFiles.includes("001_initial.sql")) {
88
+ throw new Error("Missing vendor/migrations/001_initial.sql");
89
+ }
90
+ const destVendor = path.join(destBin, "vendor");
91
+ mkdirRealDirSync(destVendor, ".autopilot/bin/vendor/", projectRoot);
92
+ const runtimeDest = path.join(destVendor, "runtime.mjs");
93
+ assertNotSymlink(runtimeDest, ".autopilot/bin/vendor/runtime.mjs");
94
+ const migDestDir = path.join(destVendor, "migrations");
95
+ mkdirRealDirSync(migDestDir, ".autopilot/bin/vendor/migrations/", projectRoot);
96
+ // Stage temps first so a mid-stage failure does not wipe a good prior pair.
97
+ // Commit migrations before runtime: a torn upgrade then keeps old runtime + new
98
+ // SQL (still loadable); the reverse (new runtime + old SQL) is worse for migrate.
99
+ assertParentDirInProject(projectRoot, runtimeDest, ".autopilot/bin/vendor/");
100
+ assertParentDirInProject(projectRoot, path.join(migDestDir, "001_initial.sql"), ".autopilot/bin/vendor/migrations/");
101
+ const token = `${process.pid}.${randomBytes(8).toString("hex")}`;
102
+ const runtimeTmp = `${runtimeDest}.${token}.tmp`;
103
+ const migTmps = [];
104
+ for (const f of migFiles) {
105
+ const dest = path.join(migDestDir, f);
106
+ assertNotSymlink(dest, `.autopilot/bin/vendor/migrations/${f}`);
107
+ migTmps.push({
108
+ tmp: `${dest}.${token}.tmp`,
109
+ dest,
110
+ label: `vendor/migrations/${f}`,
111
+ });
112
+ }
113
+ try {
114
+ copyFileNoFollowExclSync(runtimeSrc, runtimeTmp, "vendor/runtime.mjs");
115
+ for (const m of migTmps) {
116
+ copyFileNoFollowExclSync(path.join(migSrcDir, path.basename(m.dest)), m.tmp, m.label);
117
+ }
118
+ for (const m of migTmps) {
119
+ renameReplaceSync(m.tmp, m.dest);
120
+ }
121
+ renameReplaceSync(runtimeTmp, runtimeDest);
122
+ }
123
+ catch (err) {
124
+ try {
125
+ fs.unlinkSync(runtimeTmp);
126
+ }
127
+ catch {
128
+ /* ignore */
129
+ }
130
+ for (const m of migTmps) {
131
+ try {
132
+ fs.unlinkSync(m.tmp);
133
+ }
134
+ catch {
135
+ /* ignore */
136
+ }
137
+ }
138
+ throw err;
139
+ }
140
+ // Post-write: parent symlink race may have landed files outside the project.
141
+ assertPairInsideOrUnlinkAll(projectRoot, [
142
+ ...migTmps.map((m) => [m.dest, `.autopilot/bin/vendor/migrations/${path.basename(m.dest)}`]),
143
+ [runtimeDest, ".autopilot/bin/vendor/runtime.mjs"],
144
+ ]);
145
+ }
146
+ function copyHookAsset(cliRoot, destBin, projectRoot) {
147
+ const src = resolveHookAsset(cliRoot);
148
+ if (!src) {
149
+ throw new Error("Missing autopilot-harness-hook.mjs asset in CLI package");
150
+ }
151
+ mkdirRealDirSync(destBin, ".autopilot/bin/", projectRoot);
152
+ // Vendor first: if this fails, leave the previous hook intact.
153
+ copyVendorDir(cliRoot, destBin, projectRoot);
154
+ const hookDest = path.join(destBin, "autopilot-harness-hook.mjs");
155
+ assertNotSymlink(hookDest, ".autopilot/bin/autopilot-harness-hook.mjs");
156
+ assertParentDirInProject(projectRoot, hookDest, ".autopilot/bin/");
157
+ copyFileReplaceSync(src, hookDest);
158
+ assertWrittenInsideProject(projectRoot, hookDest, ".autopilot/bin/autopilot-harness-hook.mjs");
159
+ }
160
+ /** Read hooks.json; refuse to clobber an existing unreadable file. */
161
+ function readHooksFile(filePath) {
162
+ let raw;
163
+ try {
164
+ // Do not use existsSync first: dangling symlinks look "missing" there,
165
+ // but O_NOFOLLOW open fails with ELOOP — fail closed before mutate.
166
+ raw = readUntrustedUtf8File(filePath, MAX_UNTRUSTED_TEXT_BYTES, ".cursor/hooks.json");
167
+ }
168
+ catch (err) {
169
+ const code = err?.code;
170
+ if (code === "ENOENT") {
171
+ return { ok: true, value: null };
172
+ }
173
+ const msg = err instanceof Error ? err.message : String(err);
174
+ return { ok: false, error: `Cannot read ${filePath}: ${msg}` };
175
+ }
176
+ try {
177
+ const parsed = JSON.parse(raw);
178
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
179
+ return {
180
+ ok: false,
181
+ error: `${filePath} is not a JSON object; fix or remove it before init.`,
182
+ };
183
+ }
184
+ const obj = parsed;
185
+ if (obj.hooks != null && typeof obj.hooks !== "object") {
186
+ return {
187
+ ok: false,
188
+ error: `${filePath} has invalid "hooks" field; fix or remove it before init.`,
189
+ };
190
+ }
191
+ if (!obj.hooks) {
192
+ return { ok: true, value: { version: obj.version ?? 1, hooks: {} } };
193
+ }
194
+ if (Array.isArray(obj.hooks)) {
195
+ return {
196
+ ok: false,
197
+ error: `${filePath}: "hooks" must be an object, not an array.`,
198
+ };
199
+ }
200
+ const shapeError = validateHooksShape(obj);
201
+ if (shapeError) {
202
+ return { ok: false, error: `${filePath}: ${shapeError}` };
203
+ }
204
+ return { ok: true, value: obj };
205
+ }
206
+ catch {
207
+ return {
208
+ ok: false,
209
+ error: `${filePath} is not valid JSON; fix or remove it before init.`,
210
+ };
211
+ }
212
+ }
213
+ function resolveAutopilotIgnoreTemplate(templatesRoot) {
214
+ const templatePath = path.join(templatesRoot, ".autopilotignore");
215
+ if (isRealRegularFile(templatePath)) {
216
+ return readUntrustedUtf8File(templatePath, MAX_UNTRUSTED_TEXT_BYTES, "templates/.autopilotignore");
217
+ }
218
+ return DEFAULT_AUTOPILOT_IGNORE_TEXT;
219
+ }
220
+ /** Active or intentionally disabled pattern lines (trimmed), for merge dedupe. */
221
+ function autopilotIgnoreOwnedPatterns(text) {
222
+ const out = new Set();
223
+ for (const raw of text.split(/\r?\n/)) {
224
+ const trimmed = raw.trim();
225
+ if (!trimmed)
226
+ continue;
227
+ if (trimmed.startsWith("#")) {
228
+ // "# *.png" / "#*.png" = user disabled that pattern — do not re-merge it.
229
+ const rest = trimmed.replace(/^#\s*/, "").trim();
230
+ if (rest && !/\s/.test(rest))
231
+ out.add(rest);
232
+ continue;
233
+ }
234
+ out.add(trimmed);
235
+ }
236
+ return out;
237
+ }
238
+ /** Template pattern lines to consider for merge (non-comment only). */
239
+ function autopilotIgnorePatternLines(text) {
240
+ const out = [];
241
+ for (const raw of text.split(/\r?\n/)) {
242
+ const line = raw.trim();
243
+ if (!line || line.startsWith("#"))
244
+ continue;
245
+ out.push(line);
246
+ }
247
+ return out;
248
+ }
249
+ /**
250
+ * Write `.autopilotignore` when missing; when present, append template pattern
251
+ * lines that are not already present or commented-out (never delete user lines).
252
+ */
253
+ export function ensureAutopilotIgnore(projectRoot, templatesRoot) {
254
+ const dest = path.join(projectRoot, ".autopilotignore");
255
+ let contents = resolveAutopilotIgnoreTemplate(templatesRoot);
256
+ if (!contents.endsWith("\n"))
257
+ contents += "\n";
258
+ let existing = null;
259
+ let existed = false;
260
+ try {
261
+ assertNotSymlink(dest, ".autopilotignore");
262
+ const st = fs.lstatSync(dest);
263
+ existed = true;
264
+ if (!st.isFile()) {
265
+ throw new Error(".autopilotignore exists and is not a regular file");
266
+ }
267
+ existing = readUntrustedUtf8File(dest, MAX_UNTRUSTED_TEXT_BYTES, ".autopilotignore");
268
+ }
269
+ catch (err) {
270
+ const code = err?.code;
271
+ const msg = err instanceof Error ? err.message : String(err);
272
+ if (code === "ENOENT" && !existed) {
273
+ existing = null;
274
+ }
275
+ else if (existed ||
276
+ code === "ELOOP" ||
277
+ /is a symlink/i.test(msg)) {
278
+ // Present but unreadable/unsafe (incl. symlink before lstat) — never
279
+ // overwrite; skip merge. Runtime falls back to DEFAULT patterns.
280
+ return null;
281
+ }
282
+ else {
283
+ throw err;
284
+ }
285
+ }
286
+ if (existing === null) {
287
+ writeFileAtomic(dest, contents, projectRoot, ".autopilotignore");
288
+ return ".autopilotignore";
289
+ }
290
+ const have = autopilotIgnoreOwnedPatterns(existing);
291
+ const missing = autopilotIgnorePatternLines(contents).filter((line) => !have.has(line));
292
+ if (missing.length === 0)
293
+ return null;
294
+ let next = existing;
295
+ if (!next.endsWith("\n"))
296
+ next += "\n";
297
+ next +=
298
+ "\n# --- merged from Autopilot defaults (upgrade/init) ---\n" +
299
+ missing.join("\n") +
300
+ "\n";
301
+ // Do not write a merge that exceeds the untrusted size cap — runtime would
302
+ // reject the file and fall back to DEFAULT, silently dropping user rules.
303
+ if (Buffer.byteLength(next, "utf8") > MAX_UNTRUSTED_TEXT_BYTES) {
304
+ return null;
305
+ }
306
+ writeFileAtomic(dest, next, projectRoot, ".autopilotignore");
307
+ return ".autopilotignore";
308
+ }
309
+ function writeFileAtomic(filePath, contents, projectRoot, parentLabel) {
310
+ assertParentDirInProject(projectRoot, filePath, parentLabel);
311
+ writeFileReplaceSync(filePath, contents);
312
+ assertWrittenInsideProject(projectRoot, filePath, path.basename(filePath));
313
+ }
314
+ function renderSkill(template, description) {
315
+ const escaped = description
316
+ .replace(/\\/g, "\\\\")
317
+ .replace(/"/g, '\\"')
318
+ .replace(/\n/g, "\\n")
319
+ .replace(/\r/g, "\\r");
320
+ return template.replaceAll("{{description}}", escaped);
321
+ }
322
+ function resolveInstallPlatforms(opts) {
323
+ if (opts.platforms && opts.platforms.length > 0) {
324
+ return mergePlatformBindings([], opts.platforms);
325
+ }
326
+ const b = normalizeBinding(opts.platform, opts.surface);
327
+ return b ? [b] : [{ id: "cursor", surface: "ide" }];
328
+ }
329
+ /** True when `opts.platforms` could not fully fit under {@link MAX_PLATFORM_BINDINGS}. */
330
+ function platformsExceedCap(opts, resolved) {
331
+ if (!opts.platforms || opts.platforms.length === 0)
332
+ return false;
333
+ return !mergedIncludesAllRequested(resolved, opts.platforms);
334
+ }
335
+ function assertSupported(platforms, locale) {
336
+ const platformErr = assertInstallablePlatforms(platforms);
337
+ if (platformErr)
338
+ return platformErr;
339
+ if (locale !== "en" && locale !== "zh-CN") {
340
+ return `Unsupported locale "${locale}" (en | zh-CN).`;
341
+ }
342
+ return null;
343
+ }
344
+ /** On --force refresh, skills must follow config.yml locale (not CLI flag default). */
345
+ function resolveInstallLocale(optsLocale, configExists, force, configPath) {
346
+ if (configExists && force) {
347
+ try {
348
+ const yaml = readUntrustedUtf8File(configPath, MAX_UNTRUSTED_TEXT_BYTES, ".autopilot/config.yml");
349
+ const hints = readConfigInstallHints(yaml);
350
+ return hints.locale === "zh-CN" ? "zh-CN" : "en";
351
+ }
352
+ catch {
353
+ // Fall through to opts / default.
354
+ }
355
+ }
356
+ return optsLocale === "zh-CN" ? "zh-CN" : "en";
357
+ }
358
+ function installSkills(templatesRoot, projectRoot, locale) {
359
+ const written = [];
360
+ const descriptions = skillDescriptions(locale);
361
+ const skillsRoot = path.join(projectRoot, ".cursor", "skills");
362
+ assertNotSymlink(skillsRoot, ".cursor/skills/");
363
+ for (const name of SKILL_NAMES) {
364
+ const tplPath = path.join(templatesRoot, "skills", name, "SKILL.md.tpl");
365
+ assertPresentRealFile(tplPath, `skill template ${name}`);
366
+ const destDir = path.join(skillsRoot, name);
367
+ mkdirRealDirSync(destDir, `.cursor/skills/${name}/`, projectRoot);
368
+ assertRealpathInside(projectRoot, destDir, `.cursor/skills/${name}/`);
369
+ const body = renderSkill(readUntrustedUtf8File(tplPath, MAX_UNTRUSTED_TEXT_BYTES, `skill template ${name}`), descriptions[name] ?? name);
370
+ const dest = path.join(destDir, "SKILL.md");
371
+ assertNotSymlink(dest, `.cursor/skills/${name}/SKILL.md`);
372
+ writeFileAtomic(dest, body, projectRoot, `.cursor/skills/${name}/`);
373
+ written.push(path.relative(projectRoot, dest));
374
+ }
375
+ return written;
376
+ }
377
+ function installWorkflows(templatesRoot, projectRoot) {
378
+ const written = [];
379
+ const docsDir = path.join(projectRoot, "docs");
380
+ const autopilotDocs = path.join(docsDir, "autopilot");
381
+ const destDir = path.join(autopilotDocs, "workflows");
382
+ assertNotSymlink(docsDir, "docs/");
383
+ assertNotSymlink(autopilotDocs, "docs/autopilot/");
384
+ assertNotSymlink(destDir, "docs/autopilot/workflows/");
385
+ mkdirRealDirSync(destDir, "docs/autopilot/workflows/", projectRoot);
386
+ assertRealpathInside(projectRoot, destDir, "docs/autopilot/workflows/");
387
+ for (const name of WORKFLOW_FILES) {
388
+ const src = path.join(templatesRoot, "workflows", name);
389
+ assertPresentRealFile(src, `workflow template ${name}`);
390
+ const dest = path.join(destDir, name);
391
+ assertNotSymlink(dest, `docs/autopilot/workflows/${name}`);
392
+ assertParentDirInProject(projectRoot, dest, "docs/autopilot/workflows/");
393
+ copyFileReplaceSync(src, dest);
394
+ assertWrittenInsideProject(projectRoot, dest, `docs/autopilot/workflows/${name}`);
395
+ written.push(path.relative(projectRoot, dest));
396
+ }
397
+ return written;
398
+ }
399
+ function ensurePlansReadme(projectRoot, plansDir = "plans") {
400
+ if (typeof projectRoot !== "string" || projectRoot.trim() === "") {
401
+ throw new Error("projectRoot must be a non-empty string");
402
+ }
403
+ const normalized = normalizePlansDir(plansDir);
404
+ if (!normalized.ok) {
405
+ throw new Error(normalized.error);
406
+ }
407
+ const safePlansDir = normalized.value;
408
+ const resolvedRoot = path.resolve(projectRoot.trim());
409
+ const plansRoot = path.join(resolvedRoot, safePlansDir);
410
+ const resolvedPlans = path.resolve(plansRoot);
411
+ if (resolvedPlans !== resolvedRoot &&
412
+ !resolvedPlans.startsWith(resolvedRoot + path.sep)) {
413
+ throw new Error("plansDir resolves outside the project root");
414
+ }
415
+ // Refuse symlink/file occupying plansDir (existsSync lies on dangling).
416
+ mkdirRealDirSync(plansRoot, "plansDir", resolvedRoot);
417
+ assertRealpathInside(resolvedRoot, plansRoot, "plansDir");
418
+ const readme = path.join(plansRoot, "README.md");
419
+ try {
420
+ const st = fs.lstatSync(readme);
421
+ if (st.isSymbolicLink()) {
422
+ throw new Error("plans README is a symlink; refusing to open");
423
+ }
424
+ if (st.isFile())
425
+ return null;
426
+ throw new Error("plans README exists and is not a regular file");
427
+ }
428
+ catch (err) {
429
+ const code = err?.code;
430
+ if (code !== "ENOENT")
431
+ throw err;
432
+ }
433
+ writeFileAtomic(readme, `# Plans
434
+
435
+ Per-track Autopilot artifacts live here:
436
+
437
+ \`\`\`text
438
+ ${safePlansDir}/<slug>/brief.md
439
+ ${safePlansDir}/<slug>/plan.md
440
+ ${safePlansDir}/<slug>/checklist.md
441
+ \`\`\`
442
+
443
+ Start with \`/autopilot-on\`, then \`/autopilot-run\` when the checklist is ready.
444
+ `, resolvedRoot, "plansDir");
445
+ return path.relative(resolvedRoot, readme);
446
+ }
447
+ /**
448
+ * Read-only checks before force-refresh / upgrade mutates the project.
449
+ * Ensures templates, hook asset, and hooks.json are merge-safe (fail closed).
450
+ */
451
+ export function preflightForceRefresh(projectRoot) {
452
+ if (typeof projectRoot !== "string" || projectRoot.trim() === "") {
453
+ return { ok: false, error: "projectRoot must be a non-empty string" };
454
+ }
455
+ const root = path.resolve(projectRoot.trim());
456
+ const { cliRoot, templatesRoot } = resolvePackageRoots();
457
+ if (!isRealDirectory(path.join(templatesRoot, "skills"))) {
458
+ return {
459
+ ok: false,
460
+ error: `Templates package not found at ${templatesRoot}`,
461
+ };
462
+ }
463
+ for (const name of SKILL_NAMES) {
464
+ const tplPath = path.join(templatesRoot, "skills", name, "SKILL.md.tpl");
465
+ try {
466
+ assertPresentRealFile(tplPath, `skill template ${name}`);
467
+ }
468
+ catch (err) {
469
+ const msg = err instanceof Error ? err.message : String(err);
470
+ return { ok: false, error: msg };
471
+ }
472
+ }
473
+ for (const name of WORKFLOW_FILES) {
474
+ const src = path.join(templatesRoot, "workflows", name);
475
+ try {
476
+ assertPresentRealFile(src, `workflow template ${name}`);
477
+ }
478
+ catch (err) {
479
+ const msg = err instanceof Error ? err.message : String(err);
480
+ return { ok: false, error: msg };
481
+ }
482
+ }
483
+ if (!resolveHookAsset(cliRoot)) {
484
+ return {
485
+ ok: false,
486
+ error: "Missing autopilot-harness-hook.mjs asset in CLI package",
487
+ };
488
+ }
489
+ if (!resolveVendorRoot(cliRoot)) {
490
+ return {
491
+ ok: false,
492
+ error: "Missing assets/vendor/runtime.mjs or migrations — run pnpm bundle-vendor (or pnpm build)",
493
+ };
494
+ }
495
+ const hooksPath = path.join(root, ".cursor", "hooks.json");
496
+ const hooksRead = readHooksFile(hooksPath);
497
+ if (!hooksRead.ok) {
498
+ return { ok: false, error: hooksRead.error };
499
+ }
500
+ return { ok: true };
501
+ }
502
+ /**
503
+ * Non-interactive init (`--yes`). Writes .autopilot + merges .cursor/hooks.json.
504
+ * `--force` refreshes hook/skills/pin/hooks merge but does **not** overwrite
505
+ * an existing config.yml, except when `mergePlatforms` / `--add-platform`
506
+ * updates the `platforms` list (committed only after hooks succeed).
507
+ */
508
+ export function installInitYes(opts) {
509
+ if (typeof opts.projectRoot !== "string" || opts.projectRoot.trim() === "") {
510
+ return { ok: false, error: "projectRoot must be a non-empty string" };
511
+ }
512
+ const requestedPlatforms = resolveInstallPlatforms(opts);
513
+ if (platformsExceedCap(opts, requestedPlatforms)) {
514
+ return {
515
+ ok: false,
516
+ error: `platforms list exceeds cap of ${MAX_PLATFORM_BINDINGS} unique entries; trim the list and retry`,
517
+ };
518
+ }
519
+ const unsupported = assertSupported(requestedPlatforms, opts.locale);
520
+ if (unsupported) {
521
+ return { ok: false, error: unsupported };
522
+ }
523
+ const projectRoot = path.resolve(opts.projectRoot.trim());
524
+ const autopilotDir = path.join(projectRoot, ".autopilot");
525
+ const configPath = path.join(autopilotDir, "config.yml");
526
+ const cursorDir = path.join(projectRoot, ".cursor");
527
+ const hooksPath = path.join(cursorDir, "hooks.json");
528
+ const mergePlatforms = Boolean(opts.mergePlatforms);
529
+ // Adding hosts into an existing config requires the force/refresh path.
530
+ const force = Boolean(opts.force) || mergePlatforms;
531
+ try {
532
+ assertNotSymlink(autopilotDir, ".autopilot/");
533
+ assertNotSymlink(configPath, ".autopilot/config.yml");
534
+ assertNotSymlink(cursorDir, ".cursor/");
535
+ assertNotSymlink(hooksPath, ".cursor/hooks.json");
536
+ }
537
+ catch (err) {
538
+ const msg = err instanceof Error ? err.message : String(err);
539
+ return { ok: false, error: msg };
540
+ }
541
+ // Prefer lstat: existsSync is false for dangling symlinks (already refused
542
+ // above) and true for non-files; only a regular file counts as initialized.
543
+ let configExists = false;
544
+ try {
545
+ const st = fs.lstatSync(configPath);
546
+ if (!st.isFile()) {
547
+ return {
548
+ ok: false,
549
+ error: ".autopilot/config.yml exists and is not a regular file",
550
+ };
551
+ }
552
+ configExists = true;
553
+ }
554
+ catch (err) {
555
+ const code = err?.code;
556
+ if (code !== "ENOENT") {
557
+ const msg = err instanceof Error ? err.message : String(err);
558
+ return { ok: false, error: `Cannot access config.yml: ${msg}` };
559
+ }
560
+ configExists = false;
561
+ }
562
+ if (mergePlatforms && !configExists) {
563
+ return {
564
+ ok: false,
565
+ error: "Cannot add a platform before init. Run init first, then --add-platform.",
566
+ };
567
+ }
568
+ if (configExists && !force) {
569
+ return {
570
+ ok: false,
571
+ error: "Project already initialized (.autopilot/config.yml exists). Re-run with --force to refresh (config.yml kept; hooks merge; plans untouched), or --add-platform <id> to enable another host.",
572
+ };
573
+ }
574
+ const preflight = preflightForceRefresh(projectRoot);
575
+ if (!preflight.ok) {
576
+ return { ok: false, error: preflight.error };
577
+ }
578
+ const { cliRoot, templatesRoot } = resolvePackageRoots();
579
+ // Fail closed on corrupt hooks before any mutate.
580
+ const hooksPre = readHooksFile(hooksPath);
581
+ if (!hooksPre.ok) {
582
+ return { ok: false, error: hooksPre.error };
583
+ }
584
+ const plansNorm = normalizePlansDir(opts.plansDir);
585
+ if (!plansNorm.ok) {
586
+ return { ok: false, error: plansNorm.error };
587
+ }
588
+ const plansDir = plansNorm.value;
589
+ const verifyEnabled = Boolean(opts.verifyEnabled);
590
+ const maxErrorsBeforePause = typeof opts.maxErrorsBeforePause === "number" &&
591
+ Number.isInteger(opts.maxErrorsBeforePause) &&
592
+ opts.maxErrorsBeforePause >= 0
593
+ ? opts.maxErrorsBeforePause
594
+ : 0;
595
+ const reviewScope = opts.reviewScope === "project" ? "project" : "executing_only";
596
+ const writeQs = opts.writeQuickstart !== false;
597
+ const locale = resolveInstallLocale(opts.locale, configExists, force, configPath);
598
+ let effectivePlatforms = requestedPlatforms;
599
+ /** When set, commit a platforms merge after hooks succeed (re-read at write). */
600
+ let pendingMergePlatforms = false;
601
+ let createdConfig = false;
602
+ try {
603
+ const written = [];
604
+ mkdirRealDirSync(autopilotDir, ".autopilot/", projectRoot);
605
+ // Fresh init: write config. --add-platform / mergePlatforms: validate merge
606
+ // now, but re-read + write only after hooks succeed (avoid half-updated
607
+ // config on later failure, and shrink TOCTOU vs concurrent editors).
608
+ // Plain --force: leave config.yml alone.
609
+ if (!configExists) {
610
+ try {
611
+ // Re-check immediately before wx: earlier `configExists` / mkdir leave a window
612
+ // where a symlink (or non-file) can appear and make wx fail with EEXIST —
613
+ // that must not be reported as "already initialized".
614
+ assertParentDirInProject(projectRoot, configPath, ".autopilot/");
615
+ assertNotSymlink(configPath, ".autopilot/config.yml");
616
+ const primary = primaryBinding(effectivePlatforms);
617
+ fs.writeFileSync(configPath, defaultConfigYaml({
618
+ platforms: effectivePlatforms,
619
+ platform: primary.id,
620
+ surface: primary.surface,
621
+ locale,
622
+ plansDir,
623
+ verifyEnabled,
624
+ maxErrorsBeforePause,
625
+ reviewScope,
626
+ }), { encoding: "utf8", flag: "wx" });
627
+ assertWrittenInsideProject(projectRoot, configPath, ".autopilot/config.yml");
628
+ }
629
+ catch (err) {
630
+ const code = err && typeof err === "object" && "code" in err
631
+ ? String(err.code)
632
+ : "";
633
+ if (code === "EEXIST") {
634
+ try {
635
+ const raced = fs.lstatSync(configPath);
636
+ if (raced.isSymbolicLink()) {
637
+ return {
638
+ ok: false,
639
+ error: ".autopilot/config.yml is a symlink; refusing to open",
640
+ };
641
+ }
642
+ if (!raced.isFile()) {
643
+ return {
644
+ ok: false,
645
+ error: ".autopilot/config.yml exists and is not a regular file; refusing to open",
646
+ };
647
+ }
648
+ }
649
+ catch (stErr) {
650
+ if (stErr instanceof Error && /symlink/i.test(stErr.message)) {
651
+ return { ok: false, error: stErr.message };
652
+ }
653
+ // Gone again — treat as contended init rather than success.
654
+ }
655
+ return {
656
+ ok: false,
657
+ error: "Project already initialized (.autopilot/config.yml exists). Re-run with --force to refresh (config.yml kept; hooks merge; plans untouched), or --add-platform <id> to enable another host.",
658
+ };
659
+ }
660
+ throw err;
661
+ }
662
+ createdConfig = true;
663
+ written.push(path.relative(projectRoot, configPath));
664
+ }
665
+ else if (mergePlatforms) {
666
+ let existingYaml;
667
+ try {
668
+ existingYaml = readUntrustedUtf8File(configPath, MAX_UNTRUSTED_TEXT_BYTES, ".autopilot/config.yml");
669
+ }
670
+ catch (err) {
671
+ const msg = err instanceof Error ? err.message : String(err);
672
+ return { ok: false, error: `Cannot read config.yml: ${msg}` };
673
+ }
674
+ let existingPlatforms;
675
+ try {
676
+ existingPlatforms = readConfigPlatformsOrThrow(existingYaml);
677
+ }
678
+ catch (err) {
679
+ const msg = err instanceof Error ? err.message : String(err);
680
+ return {
681
+ ok: false,
682
+ error: `Cannot update platforms in config.yml: ${msg}`,
683
+ };
684
+ }
685
+ effectivePlatforms = mergePlatformBindings(existingPlatforms, requestedPlatforms);
686
+ // Only the *requested* additions must be installable (checked above).
687
+ // Existing config may already declare future hosts; do not reject merge
688
+ // because of those entries — install still only wires installable ports.
689
+ if (effectivePlatforms.length === 0) {
690
+ return {
691
+ ok: false,
692
+ error: "platforms list is empty after merge; refusing to update config.yml",
693
+ };
694
+ }
695
+ if (!mergedIncludesAllRequested(effectivePlatforms, requestedPlatforms)) {
696
+ return {
697
+ ok: false,
698
+ error: "Cannot add platform(s): platforms list is at capacity. Remove an entry from config.yml and retry.",
699
+ };
700
+ }
701
+ try {
702
+ // Fail closed early if the current snapshot cannot be rewritten.
703
+ applyPlatformsToConfigYaml(existingYaml, effectivePlatforms);
704
+ pendingMergePlatforms = true;
705
+ }
706
+ catch (err) {
707
+ const msg = err instanceof Error ? err.message : String(err);
708
+ return { ok: false, error: `Cannot update platforms in config.yml: ${msg}` };
709
+ }
710
+ }
711
+ const primaryPlatform = primaryBinding(effectivePlatforms).id;
712
+ const rollbackFreshConfig = () => {
713
+ if (!createdConfig)
714
+ return;
715
+ try {
716
+ fs.unlinkSync(configPath);
717
+ createdConfig = false;
718
+ }
719
+ catch {
720
+ // Best-effort: leave crumbs rather than mask the root error.
721
+ }
722
+ };
723
+ const version = typeof opts.packageVersion === "string" && opts.packageVersion.trim()
724
+ ? opts.packageVersion.trim()
725
+ : PACKAGE_VERSION;
726
+ const pinPath = path.join(autopilotDir, "pin.json");
727
+ assertNotSymlink(pinPath, ".autopilot/pin.json");
728
+ writeFileAtomic(pinPath, JSON.stringify({ "autopilot-harness": version }, null, 2) + "\n", projectRoot, ".autopilot/");
729
+ written.push(path.relative(projectRoot, pinPath));
730
+ const binDir = path.join(autopilotDir, "bin");
731
+ mkdirRealDirSync(binDir, ".autopilot/bin/", projectRoot);
732
+ assertRealpathInside(projectRoot, binDir, ".autopilot/bin/");
733
+ copyHookAsset(cliRoot, binDir, projectRoot);
734
+ written.push(path.relative(projectRoot, path.join(binDir, "autopilot-harness-hook.mjs")));
735
+ written.push(...installSkills(templatesRoot, projectRoot, locale));
736
+ written.push(...installWorkflows(templatesRoot, projectRoot));
737
+ const ignoreRel = ensureAutopilotIgnore(projectRoot, templatesRoot);
738
+ if (ignoreRel && !written.includes(ignoreRel))
739
+ written.push(ignoreRel);
740
+ // Fresh init only: plans tree / plans gitignore / quickstart follow wizard.
741
+ // Force refresh must not create a second plans dir or rewrite docs.
742
+ if (!configExists) {
743
+ const plansReadme = ensurePlansReadme(projectRoot, plansDir);
744
+ if (plansReadme)
745
+ written.push(plansReadme);
746
+ }
747
+ const runtimeGi = applyAutopilotRuntimeGitignore(projectRoot);
748
+ if (runtimeGi && !written.includes(runtimeGi))
749
+ written.push(runtimeGi);
750
+ if (!configExists && opts.plansGit === "local-only") {
751
+ const gi = applyPlansGitignore(projectRoot, plansDir);
752
+ if (gi && !written.includes(gi))
753
+ written.push(gi);
754
+ }
755
+ if (!configExists && writeQs) {
756
+ const qsRel = writeQuickstart(projectRoot, locale, plansDir, primaryPlatform);
757
+ if (qsRel && !written.includes(qsRel))
758
+ written.push(qsRel);
759
+ }
760
+ // Re-read hooks immediately before write to shrink TOCTOU with other tools.
761
+ const hooksFresh = readHooksFile(hooksPath);
762
+ if (!hooksFresh.ok) {
763
+ rollbackFreshConfig();
764
+ return { ok: false, error: hooksFresh.error };
765
+ }
766
+ try {
767
+ assertNotSymlink(cursorDir, ".cursor/");
768
+ assertNotSymlink(hooksPath, ".cursor/hooks.json");
769
+ }
770
+ catch (err) {
771
+ rollbackFreshConfig();
772
+ const msg = err instanceof Error ? err.message : String(err);
773
+ return { ok: false, error: msg };
774
+ }
775
+ mkdirRealDirSync(path.dirname(hooksPath), ".cursor/", projectRoot);
776
+ assertRealpathInside(projectRoot, path.dirname(hooksPath), ".cursor/");
777
+ const merged = mergeHooksJson(hooksFresh.value);
778
+ writeFileAtomic(hooksPath, JSON.stringify(merged, null, 2) + "\n", projectRoot, ".cursor/");
779
+ written.push(path.relative(projectRoot, hooksPath));
780
+ // Commit platforms merge after hooks: re-read so concurrent edits between
781
+ // validation and commit are not silently clobbered by a stale snapshot.
782
+ if (pendingMergePlatforms) {
783
+ try {
784
+ const freshYaml = readUntrustedUtf8File(configPath, MAX_UNTRUSTED_TEXT_BYTES, ".autopilot/config.yml");
785
+ const freshPlatforms = readConfigPlatformsOrThrow(freshYaml);
786
+ const mergedPlatforms = mergePlatformBindings(freshPlatforms, requestedPlatforms);
787
+ if (mergedPlatforms.length === 0) {
788
+ return {
789
+ ok: false,
790
+ error: "platforms list is empty after merge; refusing to update config.yml",
791
+ };
792
+ }
793
+ if (!mergedIncludesAllRequested(mergedPlatforms, requestedPlatforms)) {
794
+ return {
795
+ ok: false,
796
+ error: "Hooks refreshed, but platforms merge failed: platforms list is at capacity. Remove an entry from config.yml and retry.",
797
+ };
798
+ }
799
+ const nextYaml = applyPlatformsToConfigYaml(freshYaml, mergedPlatforms);
800
+ assertParentDirInProject(projectRoot, configPath, ".autopilot/");
801
+ assertNotSymlink(configPath, ".autopilot/config.yml");
802
+ writeFileReplaceSync(configPath, nextYaml);
803
+ assertWrittenInsideProject(projectRoot, configPath, ".autopilot/config.yml");
804
+ written.push(path.relative(projectRoot, configPath));
805
+ }
806
+ catch (err) {
807
+ const msg = err instanceof Error ? err.message : String(err);
808
+ return {
809
+ ok: false,
810
+ error: `Hooks refreshed, but platforms merge failed: ${msg}`,
811
+ };
812
+ }
813
+ }
814
+ return { ok: true, written };
815
+ }
816
+ catch (err) {
817
+ if (createdConfig) {
818
+ try {
819
+ fs.unlinkSync(configPath);
820
+ }
821
+ catch {
822
+ // best-effort
823
+ }
824
+ }
825
+ const msg = err instanceof Error ? err.message : String(err);
826
+ return { ok: false, error: `init failed: ${msg}` };
827
+ }
828
+ }
829
+ //# sourceMappingURL=install.js.map