@vttforge/cli 0.0.0 → 0.5.1

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 (67) hide show
  1. package/CHANGELOG.md +364 -0
  2. package/LICENSE +21 -0
  3. package/README.md +33 -5
  4. package/dist/bin.d.mts +1 -0
  5. package/dist/bin.mjs +201 -0
  6. package/dist/bin.mjs.map +1 -0
  7. package/dist/index.d.mts +496 -0
  8. package/dist/index.mjs +2 -0
  9. package/dist/src-CWhUNM__.mjs +2443 -0
  10. package/dist/src-CWhUNM__.mjs.map +1 -0
  11. package/package.json +62 -9
  12. package/templates/module-js/.github/workflows/release.yml +92 -0
  13. package/templates/module-js/README.md +57 -0
  14. package/templates/module-js/_gitignore +12 -0
  15. package/templates/module-js/lang/en.json +11 -0
  16. package/templates/module-js/module.json +20 -0
  17. package/templates/module-js/package.json +24 -0
  18. package/templates/module-js/scripts/main.mjs +65 -0
  19. package/templates/module-js/styles/main.css +16 -0
  20. package/templates/module-js/vite.config.mjs +13 -0
  21. package/templates/module-ts/.github/workflows/release.yml +92 -0
  22. package/templates/module-ts/README.md +57 -0
  23. package/templates/module-ts/_gitignore +12 -0
  24. package/templates/module-ts/lang/en.json +11 -0
  25. package/templates/module-ts/module.json +20 -0
  26. package/templates/module-ts/package.json +26 -0
  27. package/templates/module-ts/scripts/foundry-globals.ts +23 -0
  28. package/templates/module-ts/scripts/main.ts +71 -0
  29. package/templates/module-ts/styles/main.css +16 -0
  30. package/templates/module-ts/tsconfig.json +18 -0
  31. package/templates/module-ts/vite.config.mjs +13 -0
  32. package/templates/system-js/.github/workflows/release.yml +92 -0
  33. package/templates/system-js/README.md +57 -0
  34. package/templates/system-js/_gitignore +12 -0
  35. package/templates/system-js/lang/en.json +55 -0
  36. package/templates/system-js/package.json +25 -0
  37. package/templates/system-js/scripts/data/character-data.mjs +79 -0
  38. package/templates/system-js/scripts/data/gear-data.mjs +37 -0
  39. package/templates/system-js/scripts/main.mjs +79 -0
  40. package/templates/system-js/scripts/migrations.mjs +43 -0
  41. package/templates/system-js/scripts/sheets/character-sheet.mjs +164 -0
  42. package/templates/system-js/scripts/sheets/gear-sheet.mjs +65 -0
  43. package/templates/system-js/styles/main.css +495 -0
  44. package/templates/system-js/system.json +44 -0
  45. package/templates/system-js/template.json +8 -0
  46. package/templates/system-js/templates/actor/character-sheet.hbs +168 -0
  47. package/templates/system-js/templates/item/gear-sheet.hbs +54 -0
  48. package/templates/system-js/vite.config.mjs +16 -0
  49. package/templates/system-ts/.github/workflows/release.yml +92 -0
  50. package/templates/system-ts/README.md +57 -0
  51. package/templates/system-ts/_gitignore +12 -0
  52. package/templates/system-ts/lang/en.json +55 -0
  53. package/templates/system-ts/package.json +27 -0
  54. package/templates/system-ts/scripts/data/character-data.ts +81 -0
  55. package/templates/system-ts/scripts/data/gear-data.ts +37 -0
  56. package/templates/system-ts/scripts/foundry-globals.ts +33 -0
  57. package/templates/system-ts/scripts/main.ts +82 -0
  58. package/templates/system-ts/scripts/migrations.ts +43 -0
  59. package/templates/system-ts/scripts/sheets/character-sheet.ts +202 -0
  60. package/templates/system-ts/scripts/sheets/gear-sheet.ts +68 -0
  61. package/templates/system-ts/styles/main.css +495 -0
  62. package/templates/system-ts/system.json +44 -0
  63. package/templates/system-ts/template.json +8 -0
  64. package/templates/system-ts/templates/actor/character-sheet.hbs +168 -0
  65. package/templates/system-ts/templates/item/gear-sheet.hbs +54 -0
  66. package/templates/system-ts/tsconfig.json +18 -0
  67. package/templates/system-ts/vite.config.mjs +16 -0
@@ -0,0 +1,2443 @@
1
+ import { createWriteStream, existsSync, readFileSync, readdirSync, statSync, watch } from "node:fs";
2
+ import { basename, dirname, extname, join, posix, relative, resolve, sep } from "node:path";
3
+ import { lstat, mkdir, readFile, readdir, readlink, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
4
+ import * as p from "@clack/prompts";
5
+ import { execFile, spawn } from "node:child_process";
6
+ import { ZipArchive } from "archiver";
7
+ import { fileURLToPath } from "node:url";
8
+ import { createHash } from "node:crypto";
9
+ import { createServer } from "node:http";
10
+ import { homedir } from "node:os";
11
+ import { promisify } from "node:util";
12
+ //#region src/audit/manifest-rules.ts
13
+ /**
14
+ * Manifest-scope audit rules.
15
+ *
16
+ * These rules operate on the parsed contents of `system.json` and/or
17
+ * `module.json` at the project root. They cover three v13 manifest
18
+ * footguns from the VTTForge audit catalog:
19
+ *
20
+ * VTTF-AUDIT-001 (HIGH) — flags.hotReload shape
21
+ * VTTF-AUDIT-002 (MEDIUM) — deprecated gridDistance/gridUnits
22
+ * VTTF-AUDIT-003 (LOW) — styles array of strings (v12 shape)
23
+ *
24
+ * Each rule emits zero or more `RuleResult`s. Line numbers are looked up
25
+ * cheaply by scanning the raw JSON for the offending key — accurate
26
+ * enough for navigation, no AST dependency.
27
+ */
28
+ const MANIFEST_FILES$1 = ["system.json", "module.json"];
29
+ /**
30
+ * Load the manifest(s) at the project root. Returns at most two entries
31
+ * (system + module), skipping anything that can't be parsed as a JSON
32
+ * object. We tolerate parse errors here because that's a separate failure
33
+ * mode the user will hit when they actually run `vite build`; the audit
34
+ * shouldn't double-report it.
35
+ */
36
+ async function loadManifests(cwd) {
37
+ const out = [];
38
+ for (const file of MANIFEST_FILES$1) {
39
+ const path = join(cwd, file);
40
+ if (!existsSync(path)) continue;
41
+ let raw;
42
+ try {
43
+ raw = await readFile(path, "utf8");
44
+ } catch {
45
+ continue;
46
+ }
47
+ let parsed;
48
+ try {
49
+ parsed = JSON.parse(raw);
50
+ } catch {
51
+ continue;
52
+ }
53
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) out.push({
54
+ path,
55
+ raw,
56
+ parsed
57
+ });
58
+ }
59
+ return out;
60
+ }
61
+ /**
62
+ * Find the 1-based line number of the first `"key":` occurrence in JSON
63
+ * source. Good enough for navigation — JSON keys are usually unique at
64
+ * the level we report on, and even when nested duplicates exist the
65
+ * first occurrence points at the right region of the file.
66
+ */
67
+ function findKeyLine(raw, key) {
68
+ const needle = `"${key}"`;
69
+ const idx = raw.indexOf(needle);
70
+ if (idx < 0) return void 0;
71
+ let line = 1;
72
+ for (let i = 0; i < idx; i += 1) if (raw[i] === "\n") line += 1;
73
+ return line;
74
+ }
75
+ /**
76
+ * VTTF-AUDIT-001 (HIGH) — flags.hotReload shape.
77
+ *
78
+ * v12 accepted `"hotReload": ["css", "hbs", ...]` (array). v13 expects
79
+ * `"hotReload": { "extensions": [...], "paths": [...] }` (object at the
80
+ * root of `flags`, NOT nested under a package id). With the array shape
81
+ * the server's chokidar watcher reads `.extensions` and gets undefined,
82
+ * silently disabling HMR for the package.
83
+ */
84
+ function ruleHotReload(manifest) {
85
+ const flags = manifest.parsed.flags;
86
+ if (!flags || typeof flags !== "object" || Array.isArray(flags)) return [];
87
+ const hr = flags.hotReload;
88
+ if (hr === void 0) return [];
89
+ const line = findKeyLine(manifest.raw, "hotReload");
90
+ if (Array.isArray(hr)) return [{
91
+ ruleId: "VTTF-AUDIT-001",
92
+ title: "flags.hotReload uses the deprecated v12 array shape",
93
+ severity: "HIGH",
94
+ filePath: manifest.path,
95
+ line,
96
+ message: "flags.hotReload is an array; v13 expects an object `{ \"extensions\": [...], \"paths\": [...] }`. With the array shape Foundry silently disables HMR for this package.",
97
+ remediation: "Replace the array with an object: `\"hotReload\": { \"extensions\": [\"css\", \"hbs\", \"json\"], \"paths\": [\"styles\", \"templates\", \"lang\"] }`."
98
+ }];
99
+ if (hr === null || typeof hr !== "object") return [{
100
+ ruleId: "VTTF-AUDIT-001",
101
+ title: "flags.hotReload has an invalid shape",
102
+ severity: "HIGH",
103
+ filePath: manifest.path,
104
+ line,
105
+ message: "flags.hotReload must be an object with `extensions` and `paths` arrays in v13.",
106
+ remediation: "Replace with `\"hotReload\": { \"extensions\": [...], \"paths\": [...] }`."
107
+ }];
108
+ const hrObj = hr;
109
+ if (!Array.isArray(hrObj.extensions)) return [{
110
+ ruleId: "VTTF-AUDIT-001",
111
+ title: "flags.hotReload is missing the required `extensions` key",
112
+ severity: "HIGH",
113
+ filePath: manifest.path,
114
+ line,
115
+ message: "flags.hotReload must declare an `extensions` array — Foundry has no default and the watcher silently does nothing without it.",
116
+ remediation: "Add `extensions`: `\"hotReload\": { \"extensions\": [\"css\", \"hbs\", \"json\"], \"paths\": [\"styles\", \"templates\", \"lang\"] }`. `paths` is optional (defaults to the package root) but recommended."
117
+ }];
118
+ return [];
119
+ }
120
+ /**
121
+ * VTTF-AUDIT-002 (MEDIUM) — deprecated top-level grid fields.
122
+ *
123
+ * v12 used flat `gridDistance` + `gridUnits`. v13 wants
124
+ * `grid: { type, distance, units, diagonals }`. The legacy keys still
125
+ * work today (Foundry auto-migrates and warns) but will be removed in
126
+ * v14 — flag them now so the project is forward-compatible.
127
+ */
128
+ function ruleGridShape(manifest) {
129
+ const hasLegacyDistance = "gridDistance" in manifest.parsed;
130
+ const hasLegacyUnits = "gridUnits" in manifest.parsed;
131
+ if (!hasLegacyDistance && !hasLegacyUnits) return [];
132
+ const offendingKey = hasLegacyDistance ? "gridDistance" : "gridUnits";
133
+ return [{
134
+ ruleId: "VTTF-AUDIT-002",
135
+ title: "Top-level gridDistance / gridUnits are deprecated v12 fields",
136
+ severity: "MEDIUM",
137
+ filePath: manifest.path,
138
+ line: findKeyLine(manifest.raw, offendingKey),
139
+ message: "gridDistance and gridUnits at the manifest root are the v12 shape. Foundry v13 expects a structured `grid` object and emits a deprecation warning on load; the fields will be removed in v14.",
140
+ remediation: "Replace with `\"grid\": { \"type\": 1, \"distance\": 5, \"units\": \"ft\", \"diagonals\": 0 }` (adjust values to your system)."
141
+ }];
142
+ }
143
+ /**
144
+ * VTTF-AUDIT-003 (LOW) — styles array of strings.
145
+ *
146
+ * v12 took `styles: ["styles/foo.css"]`. v13 expects `styles: [{ src,
147
+ * layer? }]` so cascade-layer ordering can be declared in the manifest.
148
+ * The string form still works (Foundry auto-migrates) but emits a
149
+ * deprecation warning and loses the ability to control layer placement.
150
+ */
151
+ function ruleStylesShape(manifest) {
152
+ const styles = manifest.parsed.styles;
153
+ if (!Array.isArray(styles) || styles.length === 0) return [];
154
+ if (!styles.some((entry) => typeof entry === "string")) return [];
155
+ return [{
156
+ ruleId: "VTTF-AUDIT-003",
157
+ title: "styles uses the deprecated v12 string-array shape",
158
+ severity: "LOW",
159
+ filePath: manifest.path,
160
+ line: findKeyLine(manifest.raw, "styles"),
161
+ message: "styles entries should be objects of the form `{ \"src\": \"path/to.css\", \"layer\": \"optional-layer\" }` in v13. String entries auto-migrate today but the conversion drops your control over cascade-layer placement.",
162
+ remediation: "Replace each string with `{ \"src\": \"<that-path>\" }`. Add a `\"layer\"` field per entry where you want explicit cascade ordering."
163
+ }];
164
+ }
165
+ /**
166
+ * Entry point: run every manifest rule against every manifest at the
167
+ * project root.
168
+ */
169
+ async function runManifestRules(cwd) {
170
+ const manifests = await loadManifests(cwd);
171
+ const results = [];
172
+ for (const manifest of manifests) {
173
+ results.push(...ruleHotReload(manifest));
174
+ results.push(...ruleGridShape(manifest));
175
+ results.push(...ruleStylesShape(manifest));
176
+ }
177
+ return results;
178
+ }
179
+ //#endregion
180
+ //#region src/audit/source-rules.ts
181
+ /**
182
+ * Source-tree audit rules.
183
+ *
184
+ * These walk `.ts` / `.tsx` / `.mjs` / `.cjs` / `.js` files under the
185
+ * project root (excluding common build/dependency dirs) and apply regex
186
+ * heuristics to spot four v13 footguns from the VTTForge audit catalog:
187
+ *
188
+ * VTTF-AUDIT-004 (MEDIUM) — HTMLField/FilePathField missing manifest declaration
189
+ * VTTF-AUDIT-005 (MEDIUM) — extends TypeDataModel without prepareBaseData stub
190
+ * VTTF-AUDIT-006 (LOW) — `_addDataFieldMigrations` override (wrong signature)
191
+ * VTTF-AUDIT-007 (MEDIUM) — manifest primary/secondaryTokenAttribute not matched
192
+ * by a `value`/`max` SchemaField in source
193
+ *
194
+ * Regex-based on purpose: avoids pulling in a TypeScript AST dependency
195
+ * for what amount to four pattern checks. The trade-off is occasional
196
+ * false negatives on heavily-formatted code; the alternative would be a
197
+ * 30MB+ runtime dep for marginal gains.
198
+ */
199
+ /** Directories we never descend into — they're not user source. */
200
+ const EXCLUDED_DIRS = /* @__PURE__ */ new Set([
201
+ "node_modules",
202
+ "dist",
203
+ "build",
204
+ ".git",
205
+ ".turbo",
206
+ ".vttforge",
207
+ ".next",
208
+ "coverage",
209
+ ".vitest-cache"
210
+ ]);
211
+ const SOURCE_EXTENSIONS = [
212
+ ".ts",
213
+ ".tsx",
214
+ ".mts",
215
+ ".cts",
216
+ ".mjs",
217
+ ".cjs",
218
+ ".js"
219
+ ];
220
+ function isSourceFile(name) {
221
+ if (name.endsWith(".d.ts") || name.endsWith(".d.mts") || name.endsWith(".d.cts")) return false;
222
+ return SOURCE_EXTENSIONS.some((ext) => name.endsWith(ext));
223
+ }
224
+ async function* walkSourceFiles(cwd) {
225
+ const entries = await readdir(cwd, { withFileTypes: true });
226
+ for (const entry of entries) {
227
+ if (entry.name.startsWith(".") && EXCLUDED_DIRS.has(entry.name)) continue;
228
+ if (entry.isDirectory()) {
229
+ if (EXCLUDED_DIRS.has(entry.name)) continue;
230
+ yield* walkSourceFiles(join(cwd, entry.name));
231
+ } else if (entry.isFile() && isSourceFile(entry.name)) yield join(cwd, entry.name);
232
+ }
233
+ }
234
+ /** Find the 1-based line number of the first occurrence of `needle`. */
235
+ function lineOf(content, needle) {
236
+ const idx = typeof needle === "string" ? content.indexOf(needle) : content.search(needle);
237
+ if (idx < 0) return void 0;
238
+ let line = 1;
239
+ for (let i = 0; i < idx; i += 1) if (content[i] === "\n") line += 1;
240
+ return line;
241
+ }
242
+ /** VTTF-AUDIT-005 — `extends TypeDataModel` without a `prepareBaseData(...)` method. */
243
+ function rule005(filePath, content) {
244
+ const out = [];
245
+ const directClasses = findDirectTypeDataModelClasses(content);
246
+ for (const cls of directClasses) {
247
+ const body = content.slice(cls.openIdx + 1, cls.endIdx);
248
+ if (/prepareBaseData\s*\(/.test(body)) continue;
249
+ out.push({
250
+ ruleId: "VTTF-AUDIT-005",
251
+ title: "TypeDataModel subclass missing prepareBaseData stub",
252
+ severity: "MEDIUM",
253
+ filePath,
254
+ line: cls.startLine,
255
+ message: `Class \`${cls.className}\` extends TypeDataModel directly but does not define prepareBaseData(). Active Effects apply between prepareBaseData and prepareDerivedData; without the hook, AE consumers see uninitialised fields.`,
256
+ remediation: "Add a no-op `prepareBaseData()` method (or extend `BaseTypeDataModel()` from `@vttforge/core`, which provides the stub)."
257
+ });
258
+ }
259
+ return out;
260
+ }
261
+ /**
262
+ * Subset of `findClassRanges` that only returns classes whose `extends`
263
+ * names `TypeDataModel` (and not via the `BaseTypeDataModel()` factory).
264
+ * The word boundary in `\bTypeDataModel\b` keeps the factory pattern
265
+ * out, same as the lower-level helper.
266
+ */
267
+ function findDirectTypeDataModelClasses(content) {
268
+ const re = /class\s+(\w+)\s+extends\s+(?:[\w.]+\.)?\bTypeDataModel\b[^{]*\{/g;
269
+ const out = [];
270
+ for (const match of content.matchAll(re)) {
271
+ if (match.index === void 0) continue;
272
+ const openIdx = match.index + match[0].length - 1;
273
+ let depth = 0;
274
+ let endIdx = -1;
275
+ for (let i = openIdx; i < content.length; i += 1) if (content[i] === "{") depth += 1;
276
+ else if (content[i] === "}") {
277
+ depth -= 1;
278
+ if (depth === 0) {
279
+ endIdx = i;
280
+ break;
281
+ }
282
+ }
283
+ if (endIdx < 0) continue;
284
+ let line = 1;
285
+ for (let i = 0; i < match.index; i += 1) if (content[i] === "\n") line += 1;
286
+ out.push({
287
+ className: match[1] ?? "(anonymous)",
288
+ startLine: line,
289
+ openIdx,
290
+ endIdx
291
+ });
292
+ }
293
+ return out;
294
+ }
295
+ /** VTTF-AUDIT-006 — `_addDataFieldMigrations(` override on a TypeDataModel subclass. */
296
+ function rule006(filePath, content) {
297
+ if (!/_addDataFieldMigrations\s*\(/.test(content)) return [];
298
+ if (!/extends\s+(?:[\w.]+\.)?(?:Base)?TypeDataModel\b/.test(content)) return [];
299
+ return [{
300
+ ruleId: "VTTF-AUDIT-006",
301
+ title: "Method name confused with Foundry migration API",
302
+ severity: "LOW",
303
+ filePath,
304
+ line: lineOf(content, /_addDataFieldMigrations\s*\(/),
305
+ message: "The plural `_addDataFieldMigrations` is not a real Foundry API. The canonical v13 migration hook is a static `migrateData(source)` override that calls singular `super._addDataFieldMigration(source, oldKey, newKey, apply?)` for each rename. Instance overrides named `_addDataFieldMigrations` never run.",
306
+ remediation: "Replace the override with `static migrateData(source) { super._addDataFieldMigration(source, \"oldKey\", \"newKey\"); return super.migrateData(source); }`."
307
+ }];
308
+ }
309
+ function findClassRanges(content) {
310
+ const out = [];
311
+ for (const match of content.matchAll(/class\s+(\w+)(?:\s+extends\s+[^{]+)?\{/g)) {
312
+ if (match.index === void 0) continue;
313
+ const openIdx = match.index + match[0].length - 1;
314
+ let depth = 0;
315
+ let endIdx = -1;
316
+ for (let i = openIdx; i < content.length; i += 1) if (content[i] === "{") depth += 1;
317
+ else if (content[i] === "}") {
318
+ depth -= 1;
319
+ if (depth === 0) {
320
+ endIdx = i;
321
+ break;
322
+ }
323
+ }
324
+ if (endIdx < 0) continue;
325
+ let line = 1;
326
+ for (let i = 0; i < match.index; i += 1) if (content[i] === "\n") line += 1;
327
+ out.push({
328
+ className: match[1] ?? "(anonymous)",
329
+ startLine: line,
330
+ openIdx,
331
+ endIdx
332
+ });
333
+ }
334
+ return out;
335
+ }
336
+ function findEnclosingClass(ranges, idx) {
337
+ for (let i = ranges.length - 1; i >= 0; i -= 1) {
338
+ const r = ranges[i];
339
+ if (!r) continue;
340
+ if (idx >= r.openIdx && idx <= r.endIdx) return r.className;
341
+ }
342
+ }
343
+ /**
344
+ * One `<subtype>: <Class>` entry inside a dataModels object literal.
345
+ *
346
+ * The key is bare for a system (`character`) and quoted for a module
347
+ * (`'my-module.vehicle'`), because the module form contains a dot.
348
+ */
349
+ const SUBTYPE_ENTRY_RE = /(?:['"]([^'"]+)['"]|(\w+))\s*:\s*(\w+)/g;
350
+ /** Map a `registerSystem({ ...DataModels })` key to the document name. */
351
+ const REGISTER_SYSTEM_KEYS = [
352
+ {
353
+ key: "actorDataModels",
354
+ doc: "Actor"
355
+ },
356
+ {
357
+ key: "itemDataModels",
358
+ doc: "Item"
359
+ },
360
+ {
361
+ key: "activeEffectDataModels",
362
+ doc: "ActiveEffect"
363
+ },
364
+ {
365
+ key: "journalEntryPageDataModels",
366
+ doc: "JournalEntryPage"
367
+ },
368
+ {
369
+ key: "regionBehaviorDataModels",
370
+ doc: "RegionBehavior"
371
+ }
372
+ ];
373
+ function findConfigMappings(content) {
374
+ const out = [];
375
+ for (const m of content.matchAll(/CONFIG\.(\w+)\.dataModels\.(\w+)\s*=\s*(\w+)/g)) out.push({
376
+ doc: m[1] ?? "",
377
+ subtype: m[2] ?? "",
378
+ className: m[3] ?? ""
379
+ });
380
+ for (const m of content.matchAll(/CONFIG\.(\w+)\.dataModels\[\s*['"]([^'"]+)['"]\s*\]\s*=\s*(\w+)/g)) out.push({
381
+ doc: m[1] ?? "",
382
+ subtype: m[2] ?? "",
383
+ className: m[3] ?? ""
384
+ });
385
+ for (const m of content.matchAll(/CONFIG\.(\w+)\.dataModels\s*=\s*\{([\s\S]*?)\}/g)) {
386
+ const doc = m[1] ?? "";
387
+ const body = m[2] ?? "";
388
+ for (const e of body.matchAll(SUBTYPE_ENTRY_RE)) out.push({
389
+ doc,
390
+ subtype: e[1] ?? e[2] ?? "",
391
+ className: e[3] ?? ""
392
+ });
393
+ }
394
+ for (const { key, doc } of REGISTER_SYSTEM_KEYS) {
395
+ const re = new RegExp(String.raw`\b${key}\s*:\s*\{`, "g");
396
+ for (const m of content.matchAll(re)) {
397
+ if (m.index === void 0) continue;
398
+ const openIdx = m.index + m[0].length - 1;
399
+ let depth = 0;
400
+ let endIdx = -1;
401
+ for (let i = openIdx; i < content.length; i += 1) if (content[i] === "{") depth += 1;
402
+ else if (content[i] === "}") {
403
+ depth -= 1;
404
+ if (depth === 0) {
405
+ endIdx = i;
406
+ break;
407
+ }
408
+ }
409
+ if (endIdx < 0) continue;
410
+ const body = content.slice(openIdx + 1, endIdx);
411
+ for (const pair of body.matchAll(SUBTYPE_ENTRY_RE)) out.push({
412
+ doc,
413
+ subtype: pair[1] ?? pair[2] ?? "",
414
+ className: pair[3] ?? ""
415
+ });
416
+ }
417
+ }
418
+ return out;
419
+ }
420
+ /**
421
+ * Match `<name>: new (f|fields|foundry.data.fields).HTMLField(` or
422
+ * `.FilePathField(`. We capture the field name, the field type, the
423
+ * enclosing class, AND the dot-path through any enclosing SchemaField
424
+ * wrappers — so rule 004 compares against the FULL manifest declaration
425
+ * path (`profile.bio`), not just the leaf segment (`bio`).
426
+ */
427
+ const RICH_FIELD_RE = /(\w+)\s*:\s*new\s+(?:[\w.]+\.)?(HTMLField|FilePathField)\s*\(/g;
428
+ function findRichFields(content, classes) {
429
+ const out = [];
430
+ for (const m of content.matchAll(RICH_FIELD_RE)) {
431
+ if (m.index === void 0) continue;
432
+ let line = 1;
433
+ for (let i = 0; i < m.index; i += 1) if (content[i] === "\n") line += 1;
434
+ const fieldName = m[1] ?? "(anonymous)";
435
+ out.push({
436
+ fieldName,
437
+ type: m[2] ?? "HTMLField",
438
+ line,
439
+ enclosingClass: findEnclosingClass(classes, m.index),
440
+ schemaPath: buildSchemaPath(content, m.index, fieldName)
441
+ });
442
+ }
443
+ return out;
444
+ }
445
+ /**
446
+ * Walk backward from a rich-field match looking for enclosing SchemaField
447
+ * wrappers, building the dot-path up to the outermost schema.
448
+ *
449
+ * For `profile: new SchemaField({ bio: new HTMLField() })`, the rich-field
450
+ * match is `bio: new HTMLField(`; walking back, we find the enclosing `{`
451
+ * that's preceded by `profile: new ...SchemaField(`, prepend `profile`, and
452
+ * recurse. Top-level fields (immediate children of `defineSchema()`'s
453
+ * returned object) recurse until the enclosing `{` has no SchemaField
454
+ * ancestor, returning just the field name.
455
+ */
456
+ function buildSchemaPath(content, idx, fieldName) {
457
+ let depth = 0;
458
+ let openIdx = -1;
459
+ for (let i = idx - 1; i >= 0; i -= 1) {
460
+ const ch = content[i];
461
+ if (ch === "}") depth += 1;
462
+ else if (ch === "{") {
463
+ if (depth === 0) {
464
+ openIdx = i;
465
+ break;
466
+ }
467
+ depth -= 1;
468
+ }
469
+ }
470
+ if (openIdx < 0) return fieldName;
471
+ const lookbackStart = Math.max(0, openIdx - 200);
472
+ const m = content.slice(lookbackStart, openIdx).match(/(\w+)\s*:\s*new\s+(?:[\w.]+\.)?SchemaField\s*\(\s*$/);
473
+ if (!m) return fieldName;
474
+ const parentName = m[1] ?? "";
475
+ if (!parentName) return fieldName;
476
+ return `${buildSchemaPath(content, lookbackStart + (m.index ?? 0), parentName)}.${fieldName}`;
477
+ }
478
+ /**
479
+ * Foundry's manifest convention puts TypeDataModel-driven field paths
480
+ * under the `system.` root (`system.biography`). Source-side rich-field
481
+ * paths from `buildSchemaPath` don't include that prefix because they
482
+ * start from `defineSchema()`'s returned object. Strip it for matching.
483
+ */
484
+ function normalizeDeclaredPath(path) {
485
+ return path.startsWith("system.") ? path.slice(7) : path;
486
+ }
487
+ /**
488
+ * Find one subtype's declarations, accounting for the module prefix.
489
+ *
490
+ * A system declares `character` in the manifest and registers `character`
491
+ * in source — the two match directly. A module declares `vehicle` but
492
+ * Foundry registers it as `my-module.vehicle`, and that prefixed form is
493
+ * what appears in source. Comparing the two verbatim reports every
494
+ * correctly declared module field as missing, so fall back to the bare key
495
+ * once the package's own prefix is stripped.
496
+ *
497
+ * Only this package's prefix is stripped. A subtype contributed by some
498
+ * other module is genuinely not ours to have declared.
499
+ */
500
+ function lookupSubtype(declared, doc, subtype) {
501
+ const direct = declared.perSubtype.get(`${doc}.${subtype}`);
502
+ if (direct) return direct;
503
+ const prefix = declared.packageId ? `${declared.packageId}.` : null;
504
+ if (!prefix || !subtype.startsWith(prefix)) return void 0;
505
+ return declared.perSubtype.get(`${doc}.${subtype.slice(prefix.length)}`);
506
+ }
507
+ /**
508
+ * VTTF-AUDIT-004 — cross-check source HTMLField/FilePathField usages
509
+ * against the manifest's documentTypes declarations.
510
+ *
511
+ * Subtype-aware: if the enclosing class is registered on
512
+ * `CONFIG.<Doc>.dataModels.<subtype>`, the field must be declared in
513
+ * THAT subtype's htmlFields / filePathFields. Otherwise — class
514
+ * unregistered, registration spread across files, dynamic registration —
515
+ * we fall back to the global union check (catches the common "forgot to
516
+ * declare anything" case without false positives on advanced setups).
517
+ */
518
+ function rule004(filePath, content, declared, classToSubtypes) {
519
+ const usages = findRichFields(content, findClassRanges(content));
520
+ const out = [];
521
+ for (const usage of usages) {
522
+ const declaredBucketName = usage.type === "HTMLField" ? "htmlFields" : "filePathFields";
523
+ const mappings = usage.enclosingClass ? classToSubtypes.get(usage.enclosingClass) ?? [] : [];
524
+ const sourcePath = usage.schemaPath;
525
+ if (mappings.length > 0) {
526
+ const missing = mappings.filter((mapping) => {
527
+ const decl = lookupSubtype(declared, mapping.doc, mapping.subtype);
528
+ return !(usage.type === "HTMLField" ? decl?.html : decl?.filePath)?.has(sourcePath);
529
+ });
530
+ if (missing.length > 0) {
531
+ const locations = missing.map((mapping) => `${mapping.doc}.${mapping.subtype}`).join(", ");
532
+ out.push({
533
+ ruleId: "VTTF-AUDIT-004",
534
+ title: `${usage.type} not declared in manifest documentTypes`,
535
+ severity: "MEDIUM",
536
+ filePath,
537
+ line: usage.line,
538
+ message: `\`${usage.enclosingClass}\` declares \`${sourcePath}\` as ${usage.type}, but documentTypes.${locations}.${declaredBucketName} does not list this path. The Foundry server only sanitises declared paths.`,
539
+ remediation: `Add \`${sourcePath}\` (or \`system.${sourcePath}\`) to documentTypes.${missing[0]?.doc}.${missing[0]?.subtype}.${declaredBucketName} in your manifest.`
540
+ });
541
+ }
542
+ continue;
543
+ }
544
+ if ((usage.type === "HTMLField" ? declared.globalHtml : declared.globalFilePath).has(sourcePath)) continue;
545
+ out.push({
546
+ ruleId: "VTTF-AUDIT-004",
547
+ title: `${usage.type} not declared in manifest documentTypes`,
548
+ severity: "MEDIUM",
549
+ filePath,
550
+ line: usage.line,
551
+ message: `Schema declares \`${sourcePath}\` as ${usage.type}, but no documentTypes.<Doc>.<subtype>.${declaredBucketName} entry lists this path. The Foundry server only sanitises declared paths.`,
552
+ remediation: `Add \`${sourcePath}\` (or \`system.${sourcePath}\`) to documentTypes.<Doc>.<subtype>.${declaredBucketName} in your manifest.`
553
+ });
554
+ }
555
+ return out;
556
+ }
557
+ /**
558
+ * VTTF-AUDIT-007 — manifest's `primaryTokenAttribute` /
559
+ * `secondaryTokenAttribute` must point at a SchemaField with `value` +
560
+ * `max` keys. If not, Foundry's `getBarAttribute` silently degrades to
561
+ * value-only rendering (no bar at all).
562
+ *
563
+ * Cross-check walks the source for every SchemaField declaration paired
564
+ * with its full schema path (`buildSchemaPath`). The manifest path is
565
+ * matched EXACTLY — `primaryTokenAttribute: 'health'` does not accept a
566
+ * nested `attributes.health` SchemaField, and a nested path like
567
+ * `attributes.hp` is resolved by finding the declaration at that exact
568
+ * dot-path. No more "verify manually" branch; either the path resolves
569
+ * or it doesn't.
570
+ */
571
+ async function rule007(manifestPath, manifestRaw, manifestParsed, sourceFiles, classToSubtypes) {
572
+ if (!manifestPath || !manifestRaw || !manifestParsed) return [];
573
+ const actorClasses = new Set([...classToSubtypes.entries()].filter(([, mappings]) => mappings.some((m) => m.doc === "Actor")).map(([className]) => className));
574
+ const out = [];
575
+ for (const key of ["primaryTokenAttribute", "secondaryTokenAttribute"]) {
576
+ const value = manifestParsed[key];
577
+ if (typeof value !== "string" || value.length === 0) continue;
578
+ if (!await sourceHasValueMaxSchemaAtPath(sourceFiles, value, actorClasses)) out.push({
579
+ ruleId: "VTTF-AUDIT-007",
580
+ title: `${key} does not resolve to a {value, max} SchemaField`,
581
+ severity: "MEDIUM",
582
+ filePath: manifestPath,
583
+ line: lineOfInRaw(manifestRaw, key),
584
+ message: `Manifest ${key} is \`${value}\`, but no TypeDataModel schema in the source declares a SchemaField at this path with both \`value\` and \`max\` keys. Foundry's getBarAttribute degrades silently when the structure doesn't match.`,
585
+ remediation: `Define \`${value}\` as a SchemaField with \`value\` and \`max\` NumberField children in your TypeDataModel schema (matching the manifest path exactly).`
586
+ });
587
+ }
588
+ return out;
589
+ }
590
+ function lineOfInRaw(raw, key) {
591
+ const idx = raw.indexOf(`"${key}"`);
592
+ if (idx < 0) return void 0;
593
+ let line = 1;
594
+ for (let i = 0; i < idx; i += 1) if (raw[i] === "\n") line += 1;
595
+ return line;
596
+ }
597
+ /**
598
+ * Path-aware lookup for the rule-007 cross-check. For every SchemaField
599
+ * declaration in every source file, compute its FULL schema path via
600
+ * `buildSchemaPath`. Match the manifest's `primaryTokenAttribute` (which
601
+ * may be a single key like `health` or a dotted path like `attributes.hp`)
602
+ * against the declaration's path exactly. Only declarations whose schema
603
+ * path equals the manifest value contribute to the value+max check.
604
+ *
605
+ * Why exact path: an unqualified `health: new SchemaField(...)` inside
606
+ * `attributes: new SchemaField({...})` has schema path `attributes.health`.
607
+ * Manifest `primaryTokenAttribute: 'health'` should NOT resolve to that
608
+ * nested declaration — Foundry walks the document path structurally and
609
+ * would look for `health.value` at the top of `system`, not under
610
+ * `attributes`.
611
+ */
612
+ async function sourceHasValueMaxSchemaAtPath(sourceFiles, targetPath, actorClasses) {
613
+ for (const file of sourceFiles) {
614
+ let content;
615
+ try {
616
+ content = await readFile(file, "utf8");
617
+ } catch {
618
+ continue;
619
+ }
620
+ const classes = findClassRanges(content);
621
+ for (const decl of findAllSchemaFields(content)) {
622
+ if (decl.path !== targetPath) continue;
623
+ if (actorClasses.size > 0) {
624
+ const owner = classes.find((c) => decl.index > c.openIdx && decl.index < c.endIdx);
625
+ if (!owner || !actorClasses.has(owner.className)) continue;
626
+ }
627
+ const topKeys = extractTopLevelKeys(decl.body);
628
+ if (topKeys.has("value") && topKeys.has("max")) return true;
629
+ }
630
+ }
631
+ return false;
632
+ }
633
+ function findAllSchemaFields(content) {
634
+ const out = [];
635
+ for (const m of content.matchAll(/(\w+)\s*:\s*new\s+(?:[\w.]+\.)?SchemaField\s*\(\s*\{/g)) {
636
+ if (m.index === void 0) continue;
637
+ const fieldName = m[1] ?? "";
638
+ const openIdx = m.index + m[0].length - 1;
639
+ let depth = 0;
640
+ let endIdx = -1;
641
+ for (let i = openIdx; i < content.length; i += 1) if (content[i] === "{") depth += 1;
642
+ else if (content[i] === "}") {
643
+ depth -= 1;
644
+ if (depth === 0) {
645
+ endIdx = i;
646
+ break;
647
+ }
648
+ }
649
+ if (endIdx < 0) continue;
650
+ out.push({
651
+ path: buildSchemaPath(content, m.index, fieldName),
652
+ body: content.slice(openIdx + 1, endIdx),
653
+ index: m.index
654
+ });
655
+ }
656
+ return out;
657
+ }
658
+ /**
659
+ * Extract identifier keys declared at depth 0 of an object-literal body.
660
+ *
661
+ * The naive `value:`/`max:` regex check matches nested constructor options
662
+ * too — `value: new NumberField({ max: 100 })` looked to a flat regex like
663
+ * both keys were SchemaField siblings. Walking the body byte-by-byte with
664
+ * depth tracking + string-boundary handling correctly distinguishes
665
+ * top-level keys from nested ones.
666
+ */
667
+ function extractTopLevelKeys(body) {
668
+ const keys = /* @__PURE__ */ new Set();
669
+ let depth = 0;
670
+ let inString = null;
671
+ let escaped = false;
672
+ let pos = 0;
673
+ const idStart = /[a-zA-Z_$]/;
674
+ const idCont = /[\w$]/;
675
+ const ws = /\s/;
676
+ while (pos < body.length) {
677
+ const ch = body[pos];
678
+ if (ch === void 0) break;
679
+ if (escaped) {
680
+ escaped = false;
681
+ pos += 1;
682
+ continue;
683
+ }
684
+ if (inString) {
685
+ if (ch === "\\") escaped = true;
686
+ else if (ch === inString) inString = null;
687
+ pos += 1;
688
+ continue;
689
+ }
690
+ if (ch === "\"" || ch === "'" || ch === "`") {
691
+ inString = ch;
692
+ pos += 1;
693
+ continue;
694
+ }
695
+ if (ch === "{" || ch === "(" || ch === "[") {
696
+ depth += 1;
697
+ pos += 1;
698
+ continue;
699
+ }
700
+ if (ch === "}" || ch === ")" || ch === "]") {
701
+ depth -= 1;
702
+ pos += 1;
703
+ continue;
704
+ }
705
+ if (depth === 0 && idStart.test(ch)) {
706
+ let end = pos;
707
+ while (end < body.length && idCont.test(body[end] ?? "")) end += 1;
708
+ const name = body.slice(pos, end);
709
+ let after = end;
710
+ while (after < body.length && ws.test(body[after] ?? "")) after += 1;
711
+ if (body[after] === ":") {
712
+ keys.add(name);
713
+ pos = after + 1;
714
+ continue;
715
+ }
716
+ pos = end;
717
+ continue;
718
+ }
719
+ pos += 1;
720
+ }
721
+ return keys;
722
+ }
723
+ /**
724
+ * Load every manifest at the project root, then return:
725
+ * - declared.perSubtype: per `Doc.subtype` htmlFields / filePathFields sets
726
+ * - declared.globalHtml / globalFilePath: union across all subtypes
727
+ * - manifest path/raw/parsed for the manifest we found first
728
+ *
729
+ * Used by rules 004 and 007. The per-subtype map lets rule 004 do a strict
730
+ * cross-check when it knows which subtype a source class is registered to;
731
+ * the global union is the fallback when no class→subtype mapping is found.
732
+ */
733
+ async function collectDeclaredRichFields(cwd) {
734
+ const perSubtype = /* @__PURE__ */ new Map();
735
+ const globalHtml = /* @__PURE__ */ new Set();
736
+ const globalFilePath = /* @__PURE__ */ new Set();
737
+ let manifestPath = null;
738
+ let manifestRaw = null;
739
+ let manifestParsed = null;
740
+ let packageId = null;
741
+ for (const file of ["system.json", "module.json"]) {
742
+ const p = join(cwd, file);
743
+ if (!existsSync(p)) continue;
744
+ let raw;
745
+ try {
746
+ raw = await readFile(p, "utf8");
747
+ } catch {
748
+ continue;
749
+ }
750
+ let parsed;
751
+ try {
752
+ parsed = JSON.parse(raw);
753
+ } catch {
754
+ continue;
755
+ }
756
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
757
+ if (!manifestPath) {
758
+ manifestPath = p;
759
+ manifestRaw = raw;
760
+ manifestParsed = parsed;
761
+ const id = parsed.id;
762
+ packageId = typeof id === "string" && id.length > 0 ? id : null;
763
+ }
764
+ const docTypes = parsed.documentTypes;
765
+ if (!docTypes || typeof docTypes !== "object" || Array.isArray(docTypes)) continue;
766
+ for (const [docName, docType] of Object.entries(docTypes)) {
767
+ if (!docType || typeof docType !== "object" || Array.isArray(docType)) continue;
768
+ for (const [subName, subtype] of Object.entries(docType)) {
769
+ if (!subtype || typeof subtype !== "object" || Array.isArray(subtype)) continue;
770
+ const sub = subtype;
771
+ const key = `${docName}.${subName}`;
772
+ const entry = perSubtype.get(key) ?? {
773
+ html: /* @__PURE__ */ new Set(),
774
+ filePath: /* @__PURE__ */ new Set()
775
+ };
776
+ if (Array.isArray(sub.htmlFields)) {
777
+ for (const e of sub.htmlFields) if (typeof e === "string") {
778
+ const normalized = normalizeDeclaredPath(e);
779
+ entry.html.add(normalized);
780
+ globalHtml.add(normalized);
781
+ }
782
+ }
783
+ for (const declaredPath of declaredFilePathKeys(sub.filePathFields)) {
784
+ const normalized = normalizeDeclaredPath(declaredPath);
785
+ entry.filePath.add(normalized);
786
+ globalFilePath.add(normalized);
787
+ }
788
+ perSubtype.set(key, entry);
789
+ }
790
+ }
791
+ }
792
+ return {
793
+ declared: {
794
+ perSubtype,
795
+ packageId,
796
+ globalHtml,
797
+ globalFilePath
798
+ },
799
+ manifestPath,
800
+ manifestRaw,
801
+ manifestParsed
802
+ };
803
+ }
804
+ /**
805
+ * Read the declared file-path keys off one subtype's `filePathFields`.
806
+ *
807
+ * The manifest shape is an object keyed by field path, whose values name
808
+ * the permitted file categories. Older hand-written manifests sometimes
809
+ * carry a bare array instead; that shape does not validate, but reading it
810
+ * costs nothing and avoids reporting a field as undeclared when the author
811
+ * plainly declared it.
812
+ */
813
+ function declaredFilePathKeys(value) {
814
+ if (Array.isArray(value)) return value.filter((e) => typeof e === "string");
815
+ if (value && typeof value === "object") return Object.keys(value);
816
+ return [];
817
+ }
818
+ /**
819
+ * Aggregate every `CONFIG.<Doc>.dataModels.<subtype> = <ClassName>`
820
+ * mapping discovered across all source files, keyed by class name.
821
+ * Rule 004 reads this to determine which subtype each rich-field's
822
+ * enclosing class is registered under.
823
+ */
824
+ function buildClassToSubtypes(fileContents) {
825
+ const map = /* @__PURE__ */ new Map();
826
+ for (const content of fileContents) for (const mapping of findConfigMappings(content)) {
827
+ if (!mapping.className) continue;
828
+ const existing = map.get(mapping.className) ?? [];
829
+ existing.push(mapping);
830
+ map.set(mapping.className, existing);
831
+ }
832
+ return map;
833
+ }
834
+ /** Entry point: gather everything once, then dispatch to the per-file rules. */
835
+ async function runSourceRules(cwd) {
836
+ if (!existsSync(cwd)) return [];
837
+ if (!(await stat(cwd)).isDirectory()) return [];
838
+ const { declared, manifestPath, manifestRaw, manifestParsed } = await collectDeclaredRichFields(cwd);
839
+ const sourceFiles = [];
840
+ for await (const file of walkSourceFiles(cwd)) sourceFiles.push(file);
841
+ const contents = /* @__PURE__ */ new Map();
842
+ for (const file of sourceFiles) try {
843
+ contents.set(file, await readFile(file, "utf8"));
844
+ } catch {}
845
+ const classToSubtypes = buildClassToSubtypes(contents.values());
846
+ const results = [];
847
+ for (const [file, content] of contents) {
848
+ results.push(...rule005(file, content));
849
+ results.push(...rule006(file, content));
850
+ results.push(...rule004(file, content, declared, classToSubtypes));
851
+ }
852
+ results.push(...await rule007(manifestPath, manifestRaw, manifestParsed, sourceFiles, classToSubtypes));
853
+ return results;
854
+ }
855
+ //#endregion
856
+ //#region src/audit/types.ts
857
+ /** Severity rank for sorting (HIGH first). */
858
+ const SEVERITY_RANK = {
859
+ HIGH: 0,
860
+ MEDIUM: 1,
861
+ LOW: 2
862
+ };
863
+ //#endregion
864
+ //#region src/audit/index.ts
865
+ /**
866
+ * Audit orchestrator — runs every rule against a project root and
867
+ * aggregates the findings into an `AuditReport`.
868
+ *
869
+ * Rule organisation:
870
+ * - manifest-rules.ts → VTTF-AUDIT-001, 002, 003 (manifest-only)
871
+ * - source-rules.ts → VTTF-AUDIT-004, 005, 006, 007 (source walker
872
+ * + cross-check with manifest where needed)
873
+ *
874
+ * The orchestrator stays minimal — it knows which rule sets exist, but
875
+ * the rules themselves are responsible for their own file IO. This keeps
876
+ * the individual rule files testable in isolation.
877
+ */
878
+ var AuditTargetError = class extends Error {
879
+ constructor(message) {
880
+ super(message);
881
+ this.name = "AuditTargetError";
882
+ }
883
+ };
884
+ async function runAudit(options) {
885
+ const cwd = resolve(options.cwd);
886
+ if (!existsSync(cwd)) throw new AuditTargetError(`Audit target does not exist: ${cwd}`);
887
+ if (!statSync(cwd).isDirectory()) throw new AuditTargetError(`Audit target is not a directory: ${cwd}`);
888
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
889
+ const [manifestFindings, sourceFindings] = await Promise.all([runManifestRules(cwd), runSourceRules(cwd)]);
890
+ const findings = [...manifestFindings, ...sourceFindings].sort(compareFindings);
891
+ return {
892
+ cwd,
893
+ startedAt,
894
+ findings,
895
+ counts: countBySeverity(findings)
896
+ };
897
+ }
898
+ /** Sort: HIGH first, then by ruleId for deterministic output. */
899
+ function compareFindings(a, b) {
900
+ const sev = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
901
+ if (sev !== 0) return sev;
902
+ if (a.ruleId !== b.ruleId) return a.ruleId.localeCompare(b.ruleId);
903
+ return a.filePath.localeCompare(b.filePath);
904
+ }
905
+ function countBySeverity(findings) {
906
+ const counts = {
907
+ HIGH: 0,
908
+ MEDIUM: 0,
909
+ LOW: 0
910
+ };
911
+ for (const f of findings) counts[f.severity] += 1;
912
+ return counts;
913
+ }
914
+ //#endregion
915
+ //#region src/audit/reporter.ts
916
+ /**
917
+ * Render an `AuditReport` as JSON (machine-readable, stable schema) or
918
+ * markdown (terminal-friendly, sectioned by severity).
919
+ *
920
+ * JSON output is the contract for downstream CI tooling: stable shape,
921
+ * stable severity strings, stable ruleId namespace `VTTF-AUDIT-NNN`. The
922
+ * markdown variant exists so a human running `vttforge audit` in a
923
+ * scrollback gets immediate signal without piping through `jq`.
924
+ */
925
+ function formatReport(report, format) {
926
+ return format === "json" ? formatJson(report) : formatMarkdown(report);
927
+ }
928
+ function formatJson(report) {
929
+ return `${JSON.stringify(report, null, 2)}\n`;
930
+ }
931
+ function formatMarkdown(report) {
932
+ const { findings, counts, cwd } = report;
933
+ const lines = [];
934
+ lines.push("# vttforge audit report");
935
+ lines.push("");
936
+ lines.push(`- cwd: \`${cwd}\``);
937
+ lines.push(`- started: ${report.startedAt}`);
938
+ lines.push(`- findings: ${findings.length} (${counts.HIGH} HIGH · ${counts.MEDIUM} MEDIUM · ${counts.LOW} LOW)`);
939
+ lines.push("");
940
+ if (findings.length === 0) {
941
+ lines.push("No issues found. The system / module looks healthy against the v13 catalog.");
942
+ lines.push("");
943
+ return lines.join("\n");
944
+ }
945
+ for (const severity of [
946
+ "HIGH",
947
+ "MEDIUM",
948
+ "LOW"
949
+ ]) {
950
+ const bucket = findings.filter((f) => f.severity === severity);
951
+ if (bucket.length === 0) continue;
952
+ lines.push(`## ${severity} (${bucket.length})`);
953
+ lines.push("");
954
+ for (const finding of bucket) {
955
+ lines.push(formatFinding(finding, cwd));
956
+ lines.push("");
957
+ }
958
+ }
959
+ return lines.join("\n");
960
+ }
961
+ function formatFinding(finding, cwd) {
962
+ const location = formatLocation(finding, cwd);
963
+ const lines = [];
964
+ lines.push(`### \`${finding.ruleId}\` — ${finding.title}`);
965
+ lines.push("");
966
+ lines.push(`- file: ${location}`);
967
+ lines.push(`- message: ${finding.message}`);
968
+ if (finding.remediation) lines.push(`- fix: ${finding.remediation}`);
969
+ return lines.join("\n");
970
+ }
971
+ function formatLocation(finding, cwd) {
972
+ const rel = relativizeIfBelow(finding.filePath, cwd);
973
+ return finding.line !== void 0 ? `\`${rel}:${finding.line}\`` : `\`${rel}\``;
974
+ }
975
+ /**
976
+ * Show paths relative to `cwd` when they live underneath it (cleaner reports),
977
+ * fall back to the absolute path otherwise so we don't lie about where the
978
+ * file actually is.
979
+ */
980
+ function relativizeIfBelow(filePath, cwd) {
981
+ const rel = relative(cwd, filePath);
982
+ if (rel === "" || rel.startsWith("..") || rel.startsWith("/")) return filePath;
983
+ return rel;
984
+ }
985
+ //#endregion
986
+ //#region src/commands/audit.ts
987
+ /**
988
+ * `vttforge audit` — scan a system/module project against the VTTForge
989
+ * audit catalog (seven v13 manifest + code footguns).
990
+ *
991
+ * The seven rules (VTTF-AUDIT-001 through 007) live in
992
+ * `audit/manifest-rules.ts` or `audit/source-rules.ts`; this file is the
993
+ * CLI surface that orchestrates them and prints the report.
994
+ *
995
+ * Exit codes:
996
+ * 0 — clean run, or only MEDIUM/LOW findings (informational)
997
+ * 1 — at least one HIGH finding (or any finding in `--strict` mode)
998
+ */
999
+ async function runAuditCommand(options = {}) {
1000
+ const cwd = resolve(options.cwd ?? process.cwd());
1001
+ const format = options.format ?? "markdown";
1002
+ const strict = options.strict === true;
1003
+ const write = options.write ?? ((chunk) => {
1004
+ process.stdout.write(chunk);
1005
+ });
1006
+ const report = await runAudit({ cwd });
1007
+ write(formatReport(report, format));
1008
+ return {
1009
+ report,
1010
+ exitCode: report.counts.HIGH > 0 || strict && (report.counts.MEDIUM > 0 || report.counts.LOW > 0) ? 1 : 0
1011
+ };
1012
+ }
1013
+ //#endregion
1014
+ //#region src/manifest.ts
1015
+ /**
1016
+ * Read a built manifest (`dist/system.json` or `dist/module.json`).
1017
+ *
1018
+ * The vite plugin emits the manifest into `dist/` during build, so the CLI
1019
+ * commands that come after a build (dev's initial symlink, build's release
1020
+ * zip) read from there rather than walking source. This file is intentionally
1021
+ * tiny — we extract just the fields the CLI cares about and surface the
1022
+ * rest as `raw` for any consumer that wants more.
1023
+ */
1024
+ const MANIFEST_FILES = [{
1025
+ file: "system.json",
1026
+ type: "system"
1027
+ }, {
1028
+ file: "module.json",
1029
+ type: "module"
1030
+ }];
1031
+ const MANIFEST_ID_RE = /^[a-z][a-z0-9._-]*$/;
1032
+ const MANIFEST_VERSION_RE = /^[0-9A-Za-z._+-]+$/;
1033
+ /**
1034
+ * Locate and parse the Foundry manifest inside the given dist directory.
1035
+ * Prefers `system.json`, falls back to `module.json` (a single dist can't
1036
+ * be both — the vite plugin emits exactly one based on the project type).
1037
+ *
1038
+ * Throws if neither file exists, required fields are missing/non-string,
1039
+ * or the id/version contain characters that would let downstream `join()`
1040
+ * calls escape their intended directory.
1041
+ */
1042
+ async function readManifest(distDir) {
1043
+ for (const { file, type } of MANIFEST_FILES) {
1044
+ const path = join(distDir, file);
1045
+ if (!existsSync(path)) continue;
1046
+ let parsed;
1047
+ try {
1048
+ parsed = JSON.parse(await readFile(path, "utf8"));
1049
+ } catch (err) {
1050
+ throw new Error(`Failed to parse ${path}: ${err instanceof Error ? err.message : String(err)}`);
1051
+ }
1052
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`Manifest at ${path} is not a JSON object.`);
1053
+ const raw = parsed;
1054
+ const id = raw.id;
1055
+ const version = raw.version;
1056
+ if (typeof id !== "string" || id.length === 0) throw new Error(`Manifest at ${path} is missing a non-empty "id" field.`);
1057
+ if (!MANIFEST_ID_RE.test(id)) throw new Error(`Manifest at ${path} has an invalid "id" (${JSON.stringify(id)}). Foundry package ids must match ${MANIFEST_ID_RE} — lowercase letters, digits, dashes, dots, underscores; must start with a letter.`);
1058
+ if (typeof version !== "string" || version.length === 0) throw new Error(`Manifest at ${path} is missing a non-empty "version" field.`);
1059
+ if (!MANIFEST_VERSION_RE.test(version)) throw new Error(`Manifest at ${path} has an invalid "version" (${JSON.stringify(version)}). Use a semver-ish string with letters, digits, dots, dashes, plus, or underscore.`);
1060
+ return {
1061
+ id,
1062
+ version,
1063
+ type,
1064
+ raw
1065
+ };
1066
+ }
1067
+ throw new Error(`No Foundry manifest found in ${distDir}. Expected system.json or module.json — did you run \`vite build\` first?`);
1068
+ }
1069
+ //#endregion
1070
+ //#region src/package-manager.ts
1071
+ /**
1072
+ * Detect which package manager invoked the CLI so we can run the right
1073
+ * `install` command after scaffolding.
1074
+ *
1075
+ * Strategy: read `npm_config_user_agent`, which pnpm/npm/bun/yarn all set when
1076
+ * they spawn a child process (including `pnpm dlx`, `pnpm create`, etc.).
1077
+ * Falls back to `pnpm` because that's the VTTForge house default and the
1078
+ * most common Foundry-developer choice — but any concrete signal in the
1079
+ * environment wins over the default.
1080
+ */
1081
+ function detectPackageManager(env = process.env) {
1082
+ const ua = env.npm_config_user_agent;
1083
+ if (typeof ua === "string" && ua.length > 0) {
1084
+ if (ua.startsWith("pnpm")) return "pnpm";
1085
+ if (ua.startsWith("bun")) return "bun";
1086
+ if (ua.startsWith("yarn")) return "yarn";
1087
+ if (ua.startsWith("npm")) return "npm";
1088
+ }
1089
+ return "pnpm";
1090
+ }
1091
+ function installCommand(pm) {
1092
+ return `${pm} install`;
1093
+ }
1094
+ /**
1095
+ * Detect a project's package manager by looking at which lockfile it
1096
+ * ships. `dev` and `build` need this — by the time the user runs
1097
+ * `vttforge dev`, npm_config_user_agent reflects whatever shell launched
1098
+ * the binary (often nothing in a global install), not how the project
1099
+ * was bootstrapped. The lockfile is the project's own contract.
1100
+ *
1101
+ * Falls back to {@link detectPackageManager} for fresh checkouts that
1102
+ * haven't been installed yet.
1103
+ */
1104
+ function detectProjectPackageManager(cwd, env = process.env) {
1105
+ if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
1106
+ if (existsSync(join(cwd, "yarn.lock"))) return "yarn";
1107
+ if (existsSync(join(cwd, "bun.lock")) || existsSync(join(cwd, "bun.lockb"))) return "bun";
1108
+ if (existsSync(join(cwd, "package-lock.json")) || existsSync(join(cwd, "npm-shrinkwrap.json"))) return "npm";
1109
+ return detectPackageManager(env);
1110
+ }
1111
+ /**
1112
+ * Argument vector to invoke `vite` (or any other locally-installed CLI)
1113
+ * via the project's package manager. We always shell out through `<pm>
1114
+ * exec` so the call works regardless of the linker's choices — Yarn 4
1115
+ * Plug'n'Play, pnpm's symlinked layout, npm's flattened node_modules,
1116
+ * Bun's symlinked node_modules. Each manager exposes a uniform "run a
1117
+ * locally-resolved binary in the project's dependency graph" command.
1118
+ */
1119
+ function execInvocation(pm, bin) {
1120
+ switch (pm) {
1121
+ case "pnpm": return ["pnpm", ["exec", bin]];
1122
+ case "yarn": return ["yarn", [
1123
+ "exec",
1124
+ "--",
1125
+ bin
1126
+ ]];
1127
+ case "bun": return ["bun", ["x", bin]];
1128
+ case "npm": return ["npx", ["--no-install", bin]];
1129
+ }
1130
+ }
1131
+ //#endregion
1132
+ //#region src/vite-runner.ts
1133
+ /**
1134
+ * Spawn the user's project-local `vite` through their package manager.
1135
+ *
1136
+ * We deliberately go through `<pm> exec vite` instead of probing a
1137
+ * `node_modules/vite/bin/vite.js` path: Yarn 4 Plug'n'Play stores
1138
+ * dependencies in `.yarn/cache/` rather than `node_modules/`, and even
1139
+ * within npm/pnpm projects PNP-style linkers may not materialize the
1140
+ * binary on disk. The package-manager command always resolves the
1141
+ * locally-installed vite correctly regardless of linker.
1142
+ */
1143
+ var ViteNotInstalledError = class extends Error {
1144
+ constructor(message) {
1145
+ super(message);
1146
+ this.name = "ViteNotInstalledError";
1147
+ }
1148
+ };
1149
+ /** Spawn args for invoking the user's vite (e.g. `["pnpm", ["exec", "vite"]]`). */
1150
+ function resolveViteInvocation(cwd) {
1151
+ if (!existsSync(join(cwd, "package.json"))) throw new ViteNotInstalledError(`No package.json found at ${cwd}. Run \`vttforge dev\` / \`vttforge build\` from inside a scaffolded project.`);
1152
+ return execInvocation(detectProjectPackageManager(cwd), "vite");
1153
+ }
1154
+ /** Run `vite build` once and wait for it to exit. Throws on non-zero exit. */
1155
+ async function runViteBuildOnce(cwd) {
1156
+ const [bin, baseArgs] = resolveViteInvocation(cwd);
1157
+ await new Promise((resolveBuild, rejectBuild) => {
1158
+ const child = spawn(bin, [...baseArgs, "build"], {
1159
+ cwd,
1160
+ stdio: "inherit",
1161
+ env: process.env,
1162
+ shell: false
1163
+ });
1164
+ child.on("error", (err) => {
1165
+ if (err.code === "ENOENT") rejectBuild(/* @__PURE__ */ new Error(`Could not invoke "${bin}". Is the project's package manager installed and on PATH?`));
1166
+ else rejectBuild(err);
1167
+ });
1168
+ child.on("exit", (code) => {
1169
+ if (code === 0) resolveBuild();
1170
+ else rejectBuild(/* @__PURE__ */ new Error(`vite build exited with code ${code ?? "unknown"}`));
1171
+ });
1172
+ });
1173
+ }
1174
+ /**
1175
+ * Spawn `vite build --watch` and return the child process. The caller is
1176
+ * responsible for waiting on it and tearing it down (the dev command kills
1177
+ * it on SIGINT).
1178
+ */
1179
+ function spawnViteWatch(cwd) {
1180
+ const [bin, baseArgs] = resolveViteInvocation(cwd);
1181
+ return spawn(bin, [
1182
+ ...baseArgs,
1183
+ "build",
1184
+ "--watch"
1185
+ ], {
1186
+ cwd,
1187
+ stdio: "inherit",
1188
+ env: process.env,
1189
+ shell: false
1190
+ });
1191
+ }
1192
+ //#endregion
1193
+ //#region src/zip.ts
1194
+ /**
1195
+ * Emit a foundryvtt.com-compatible release zip.
1196
+ *
1197
+ * foundryvtt.com expects the package contents at the zip root (no wrapper
1198
+ * folder), so `unzip release.zip` produces `system.json`, `scripts/`, etc.
1199
+ * directly. The vite build output already matches that layout, so we feed
1200
+ * `dist/` straight into archiver with `false` as the second arg to disable
1201
+ * the wrap-with-source-dir-name default.
1202
+ *
1203
+ * Optional extras (LICENSE / README / CHANGELOG) ride along when present at
1204
+ * the project root and not already inside `dist/`. We never duplicate.
1205
+ */
1206
+ async function emitZip(opts) {
1207
+ const { sourceDir, outFile, extras = [], extrasFrom } = opts;
1208
+ if (!existsSync(sourceDir)) throw new Error(`emitZip: sourceDir does not exist: ${sourceDir}`);
1209
+ if (extras.length > 0 && !extrasFrom) throw new Error("emitZip: extrasFrom is required when extras are provided.");
1210
+ await new Promise((resolveZip, rejectZip) => {
1211
+ const output = createWriteStream(outFile);
1212
+ const archive = new ZipArchive({ zlib: { level: 9 } });
1213
+ output.on("close", () => resolveZip());
1214
+ output.on("error", rejectZip);
1215
+ archive.on("error", rejectZip);
1216
+ archive.on("warning", (err) => {
1217
+ if (err.code !== "ENOENT") rejectZip(err);
1218
+ });
1219
+ archive.pipe(output);
1220
+ archive.directory(sourceDir, false);
1221
+ if (extrasFrom) for (const name of extras) {
1222
+ const srcPath = join(extrasFrom, name);
1223
+ if (!existsSync(srcPath)) continue;
1224
+ const distPath = join(sourceDir, name);
1225
+ if (existsSync(distPath)) continue;
1226
+ archive.file(srcPath, { name });
1227
+ }
1228
+ archive.finalize().catch(rejectZip);
1229
+ });
1230
+ return {
1231
+ outFile,
1232
+ byteSize: (await stat(outFile)).size
1233
+ };
1234
+ }
1235
+ //#endregion
1236
+ //#region src/commands/build.ts
1237
+ /**
1238
+ * `vttforge build` — emit a foundryvtt.com-ready release zip.
1239
+ *
1240
+ * Flow:
1241
+ * 1. Clean dist/
1242
+ * 2. Run `vite build` (production mode)
1243
+ * 3. Read the resulting manifest → id + version
1244
+ * 4. Zip dist/ + LICENSE/README/CHANGELOG (if present) into
1245
+ * `<cwd>/<id>-<version>.zip` with contents at the zip root
1246
+ *
1247
+ * The release workflows shipped by the templates upload this zip to a
1248
+ * GitHub release; the manifest URL points at `releases/latest/download/`
1249
+ * so Foundry's auto-update catches new versions.
1250
+ */
1251
+ /** Files we copy into the zip at root when they exist at project root. */
1252
+ const RELEASE_ZIP_EXTRAS = [
1253
+ "LICENSE",
1254
+ "README.md",
1255
+ "CHANGELOG.md"
1256
+ ];
1257
+ /** Format a byte count as `12 B`, `4.3 KB`, or `1.2 MB`. */
1258
+ function formatBytes(n) {
1259
+ if (!Number.isFinite(n) || n < 0) return `${n} B`;
1260
+ const units = [
1261
+ "B",
1262
+ "KB",
1263
+ "MB"
1264
+ ];
1265
+ let v = n;
1266
+ let unitIndex = 0;
1267
+ while (v >= 1024 && unitIndex < units.length - 1) {
1268
+ v /= 1024;
1269
+ unitIndex += 1;
1270
+ }
1271
+ return `${v.toFixed(v >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
1272
+ }
1273
+ /**
1274
+ * Internal building block exposed for tests: assumes dist/ is already
1275
+ * populated (vite has run, manifest is present) and just emits the zip.
1276
+ */
1277
+ async function emitReleaseZip(opts) {
1278
+ const cwd = resolve(opts.cwd);
1279
+ const distDir = join(cwd, "dist");
1280
+ const manifest = await readManifest(distDir);
1281
+ const zipName = `${manifest.id}-${manifest.version}.zip`;
1282
+ const zipFile = join(cwd, zipName);
1283
+ const { byteSize } = await emitZip({
1284
+ sourceDir: distDir,
1285
+ outFile: zipFile,
1286
+ extras: RELEASE_ZIP_EXTRAS,
1287
+ extrasFrom: cwd
1288
+ });
1289
+ return {
1290
+ zipFile,
1291
+ byteSize,
1292
+ manifest
1293
+ };
1294
+ }
1295
+ async function runBuild(options = {}) {
1296
+ const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
1297
+ p.intro("🜲 vttforge build — release zip");
1298
+ const distDir = join(cwd, "dist");
1299
+ await rm(distDir, {
1300
+ recursive: true,
1301
+ force: true
1302
+ });
1303
+ const buildSpinner = p.spinner();
1304
+ buildSpinner.start("vite build (production)");
1305
+ try {
1306
+ await runViteBuildOnce(cwd);
1307
+ buildSpinner.stop("Build complete");
1308
+ } catch (err) {
1309
+ buildSpinner.stop("Build failed");
1310
+ throw err;
1311
+ }
1312
+ const { zipFile, byteSize } = await emitReleaseZip({ cwd });
1313
+ p.note(`${zipFile}\n${formatBytes(byteSize)}`, "Release artifact");
1314
+ p.outro(`Upload this zip to your release. The release workflow shipped with the template handles tag→GitHub Release → foundryvtt.com manifest update.`);
1315
+ }
1316
+ //#endregion
1317
+ //#region src/symlink.ts
1318
+ /**
1319
+ * Cross-platform symlink helpers for `vttforge dev`.
1320
+ *
1321
+ * The dev loop drops a symlink from Foundry's `Data/<systems|modules>/<id>/`
1322
+ * back to the project's `dist/` so Foundry serves freshly-built files as
1323
+ * vite writes them. The win32 path matters: with `type: 'junction'` the
1324
+ * operation works on standard Windows shells without Developer Mode, while
1325
+ * `type: 'dir'` requires admin. We never silently fall back to copying —
1326
+ * Foundry's HMR relies on live file mutation, and a copy snapshot defeats
1327
+ * the loop. If the symlink fails, we surface the platform-specific fix.
1328
+ */
1329
+ /**
1330
+ * Read a symlink target. Returns the absolute path the link points at, or
1331
+ * `null` if `path` doesn't exist or isn't a symlink. Relative symlink
1332
+ * targets are resolved against the link's own directory (matches the
1333
+ * semantics callers expect when comparing to a known absolute source).
1334
+ */
1335
+ async function readLinkTarget(path) {
1336
+ try {
1337
+ if (!(await lstat(path)).isSymbolicLink()) return null;
1338
+ const target = await readlink(path);
1339
+ return resolve(dirname(path), target);
1340
+ } catch {
1341
+ return null;
1342
+ }
1343
+ }
1344
+ /**
1345
+ * Create a symlink at `target` pointing to `source`. Win32 uses `'junction'`
1346
+ * so the call succeeds without elevation; everywhere else uses `'dir'`.
1347
+ *
1348
+ * Idempotency:
1349
+ * - target already points to source → no-op
1350
+ * - target points elsewhere → throws unless `options.overwrite`
1351
+ * - target is a real file/dir → throws unconditionally
1352
+ */
1353
+ async function createLink(target, source, options = {}) {
1354
+ const absSource = resolve(source);
1355
+ const absTarget = resolve(target);
1356
+ let info;
1357
+ try {
1358
+ info = await lstat(absTarget);
1359
+ } catch (err) {
1360
+ if (err.code === "ENOENT") info = null;
1361
+ else throw err;
1362
+ }
1363
+ if (info?.isSymbolicLink()) {
1364
+ const existing = await readLinkTarget(absTarget);
1365
+ if (existing === absSource) return;
1366
+ if (!options.overwrite) throw new Error(`Symlink already exists at ${absTarget} pointing to ${existing}. Pass { overwrite: true } or remove it manually.`);
1367
+ await unlink(absTarget);
1368
+ } else if (info) {
1369
+ const kind = info.isDirectory() ? "directory" : "file";
1370
+ throw new Error(`Refusing to overwrite ${absTarget}: path exists as a ${kind}, not a symlink.`);
1371
+ }
1372
+ await mkdir(dirname(absTarget), { recursive: true });
1373
+ const linkType = process.platform === "win32" ? "junction" : "dir";
1374
+ try {
1375
+ await symlink(absSource, absTarget, linkType);
1376
+ } catch (err) {
1377
+ if (err.code === "EPERM" && process.platform === "win32") throw new Error(`Could not create symlink at ${absTarget}: permission denied. On Windows, enable Developer Mode (Settings → Update & Security → For Developers) or relaunch the terminal as administrator.`);
1378
+ throw err;
1379
+ }
1380
+ }
1381
+ /**
1382
+ * Remove a symlink at `path`. No-ops when the path doesn't exist. Throws
1383
+ * when the path exists but isn't a symlink — we never unlink real files
1384
+ * or directories.
1385
+ */
1386
+ async function removeLink(path) {
1387
+ let info;
1388
+ try {
1389
+ info = await lstat(path);
1390
+ } catch (err) {
1391
+ if (err.code === "ENOENT") return;
1392
+ throw err;
1393
+ }
1394
+ if (!info.isSymbolicLink()) {
1395
+ const kind = info.isDirectory() ? "directory" : "file";
1396
+ throw new Error(`Refusing to remove ${path}: it is a ${kind}, not a symlink.`);
1397
+ }
1398
+ await unlink(path);
1399
+ }
1400
+ //#endregion
1401
+ //#region src/dev-module-install.ts
1402
+ /**
1403
+ * Put `@vttforge/dev-module` where Foundry will find it.
1404
+ *
1405
+ * Foundry loads a module from a directory under `Data/modules/<id>/` holding
1406
+ * `module.json` and the files it names. The published package already has
1407
+ * exactly that shape, so the install is a link to the package root — no copy
1408
+ * to keep in step, and a `pnpm update` is picked up on the next reload.
1409
+ *
1410
+ * A container is the other case: it cannot follow a host symlink, so the
1411
+ * compose file mounts the same directory instead. `vttforge dev` prints the
1412
+ * mount rather than editing anyone's compose file for them.
1413
+ */
1414
+ const DEV_MODULE_ID = "vttforge-dev";
1415
+ /**
1416
+ * Find the installed package directory.
1417
+ *
1418
+ * Resolved from the consumer's project so their `node_modules` answers,
1419
+ * falling back to this CLI's own resolution — which is what makes it work
1420
+ * inside this repository, where the package is a workspace link.
1421
+ */
1422
+ function resolveDevModuleDir(cwd) {
1423
+ const candidates = [join(cwd, "node_modules", "@vttforge", "dev-module"), join(dirname(fileURLToPath(import.meta.url)), "..", "..", "dev-module")];
1424
+ for (const dir of candidates) if (existsSync(join(dir, "module.json"))) return dir;
1425
+ return null;
1426
+ }
1427
+ async function installDevModule(modulesDir, packageDir) {
1428
+ const target = join(modulesDir, DEV_MODULE_ID);
1429
+ if (await readLinkTarget(target) === packageDir) return {
1430
+ target,
1431
+ source: packageDir,
1432
+ changed: false
1433
+ };
1434
+ await createLink(target, packageDir, { overwrite: true });
1435
+ return {
1436
+ target,
1437
+ source: packageDir,
1438
+ changed: true
1439
+ };
1440
+ }
1441
+ /** The compose line a containerised Foundry needs, ready to paste. */
1442
+ function composeMountLine(packageDir) {
1443
+ return `- ${packageDir}:/data/Data/modules/${DEV_MODULE_ID}:ro`;
1444
+ }
1445
+ //#endregion
1446
+ //#region src/dev-server.ts
1447
+ /**
1448
+ * A one-way WebSocket server: `vttforge dev` pushes, the dev module listens.
1449
+ *
1450
+ * Hand-written rather than pulled from a package. The CLI ships to every
1451
+ * consumer, so each dependency is one they install too, and what is needed
1452
+ * here is a narrow slice of RFC 6455: accept the upgrade, send unmasked text
1453
+ * frames, notice when a client goes away. No client payloads are read, no
1454
+ * compression, no extensions.
1455
+ */
1456
+ /** Fixed GUID from RFC 6455 §1.3, concatenated with the client key. */
1457
+ const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
1458
+ /** Frame lengths above these switch to a wider length field. */
1459
+ const LEN_16_BIT = 126;
1460
+ const LEN_64_BIT = 65536;
1461
+ /** Opcode 0x8 — the client is closing. */
1462
+ const OPCODE_CLOSE = 8;
1463
+ /**
1464
+ * Compute the handshake response header.
1465
+ *
1466
+ * Exported because it is the one part of the handshake with a published test
1467
+ * vector, and a wrong value fails as a silent non-connection.
1468
+ */
1469
+ function acceptKey(clientKey) {
1470
+ return createHash("sha1").update(clientKey + WS_GUID).digest("base64");
1471
+ }
1472
+ /**
1473
+ * Encode one text frame, server to client.
1474
+ *
1475
+ * Server frames are never masked. The payload length picks one of three
1476
+ * widths, and getting the boundary wrong corrupts the stream rather than
1477
+ * failing loudly — hence the explicit constants and the tests around them.
1478
+ */
1479
+ function encodeTextFrame(message) {
1480
+ const payload = Buffer.from(message, "utf8");
1481
+ const len = payload.length;
1482
+ let header;
1483
+ if (len < LEN_16_BIT) header = Buffer.from([129, len]);
1484
+ else if (len < LEN_64_BIT) {
1485
+ header = Buffer.alloc(4);
1486
+ header[0] = 129;
1487
+ header[1] = LEN_16_BIT;
1488
+ header.writeUInt16BE(len, 2);
1489
+ } else {
1490
+ header = Buffer.alloc(10);
1491
+ header[0] = 129;
1492
+ header[1] = 127;
1493
+ header.writeBigUInt64BE(BigInt(len), 2);
1494
+ }
1495
+ return Buffer.concat([header, payload]);
1496
+ }
1497
+ /** Is this inbound frame a close? That is the only opcode worth reading. */
1498
+ function isCloseFrame(chunk) {
1499
+ const first = chunk.at(0);
1500
+ return first !== void 0 && (first & 15) === OPCODE_CLOSE;
1501
+ }
1502
+ async function startDevServer(options) {
1503
+ const sockets = /* @__PURE__ */ new Set();
1504
+ const server = createServer((_req, res) => {
1505
+ res.writeHead(426, { "Content-Type": "text/plain" });
1506
+ res.end("This port speaks WebSocket only — it is the vttforge dev bridge.\n");
1507
+ });
1508
+ server.on("upgrade", (req, socket) => {
1509
+ const key = req.headers["sec-websocket-key"];
1510
+ if (typeof key !== "string") {
1511
+ socket.destroy();
1512
+ return;
1513
+ }
1514
+ socket.write([
1515
+ "HTTP/1.1 101 Switching Protocols",
1516
+ "Upgrade: websocket",
1517
+ "Connection: Upgrade",
1518
+ `Sec-WebSocket-Accept: ${acceptKey(key)}`,
1519
+ "\r\n"
1520
+ ].join("\r\n"));
1521
+ if ("setNoDelay" in socket && typeof socket.setNoDelay === "function") socket.setNoDelay(true);
1522
+ sockets.add(socket);
1523
+ const drop = () => {
1524
+ sockets.delete(socket);
1525
+ socket.destroy();
1526
+ };
1527
+ socket.on("data", (chunk) => {
1528
+ if (isCloseFrame(chunk)) drop();
1529
+ });
1530
+ socket.on("close", () => sockets.delete(socket));
1531
+ socket.on("error", drop);
1532
+ });
1533
+ await new Promise((resolveStart, rejectStart) => {
1534
+ server.once("error", rejectStart);
1535
+ server.listen(options.port, options.host ?? "127.0.0.1", () => {
1536
+ server.removeListener("error", rejectStart);
1537
+ resolveStart();
1538
+ });
1539
+ });
1540
+ const address = server.address();
1541
+ return {
1542
+ broadcast: (message) => {
1543
+ const frame = encodeTextFrame(message);
1544
+ for (const socket of sockets) try {
1545
+ socket.write(frame);
1546
+ } catch {
1547
+ sockets.delete(socket);
1548
+ }
1549
+ },
1550
+ clientCount: () => sockets.size,
1551
+ close: async () => {
1552
+ for (const socket of sockets) socket.destroy();
1553
+ sockets.clear();
1554
+ await new Promise((resolveClose) => server.close(() => resolveClose()));
1555
+ },
1556
+ port: typeof address === "object" && address ? address.port : options.port
1557
+ };
1558
+ }
1559
+ //#endregion
1560
+ //#region src/dev-watcher.ts
1561
+ /**
1562
+ * Watch the build output and turn each change into a reload payload.
1563
+ *
1564
+ * Watching `dist/` rather than hooking into Vite is deliberate: Vite rebuilds
1565
+ * into that directory whatever its internals look like, so this stays correct
1566
+ * across bundler versions and gives the served path directly — `dist/` is what
1567
+ * Foundry mounts, so a file's position inside it is its position under
1568
+ * `/systems/<id>/`.
1569
+ */
1570
+ /** What the dev module knows how to apply without a page reload. */
1571
+ const RELOADABLE = /* @__PURE__ */ new Set([
1572
+ "css",
1573
+ "hbs",
1574
+ "html",
1575
+ "json"
1576
+ ]);
1577
+ /**
1578
+ * Editors save in bursts — write, rename, truncate — and a burst should be
1579
+ * one reload, not four.
1580
+ */
1581
+ const DEBOUNCE_MS = 40;
1582
+ /**
1583
+ * The path Foundry serves this file at.
1584
+ *
1585
+ * Always POSIX separators — it becomes a URL, and a Windows backslash would
1586
+ * not match the `<link>` the browser rendered.
1587
+ */
1588
+ function servedPath(relative, packageId, packageType) {
1589
+ const normalized = relative.split(sep).join(posix.sep);
1590
+ return posix.join(`${packageType}s`, packageId, normalized);
1591
+ }
1592
+ /** Extension without the dot, lowercased. Empty when there is none. */
1593
+ function reloadableExtension(relative) {
1594
+ const ext = extname(relative).replace(".", "").toLowerCase();
1595
+ return RELOADABLE.has(ext) ? ext : null;
1596
+ }
1597
+ const digest = (content) => createHash("sha1").update(content).digest("hex");
1598
+ /**
1599
+ * Record what every reloadable file already contains.
1600
+ *
1601
+ * Vite rewrites its whole output on each build, so without a baseline the
1602
+ * first rebuild after startup looks like every file changed at once.
1603
+ *
1604
+ * Read synchronously and before the watcher exists, on purpose. Doing it
1605
+ * asynchronously races the very first change: the walk can pick up a file
1606
+ * written a moment ago and then the change event finds a matching hash and
1607
+ * drops a real edit. The output tree is small; correctness is worth the wait.
1608
+ */
1609
+ function seedDigests(distDir) {
1610
+ const seen = /* @__PURE__ */ new Map();
1611
+ const walk = (dir, prefix) => {
1612
+ let items;
1613
+ try {
1614
+ items = readdirSync(dir, { withFileTypes: true });
1615
+ } catch {
1616
+ return;
1617
+ }
1618
+ for (const item of items) {
1619
+ const rel = prefix ? `${prefix}/${item.name}` : item.name;
1620
+ if (item.isDirectory()) {
1621
+ walk(join(dir, item.name), rel);
1622
+ continue;
1623
+ }
1624
+ if (!reloadableExtension(rel)) continue;
1625
+ try {
1626
+ seen.set(rel, digest(readFileSync(join(distDir, rel), "utf8")));
1627
+ } catch {}
1628
+ }
1629
+ };
1630
+ walk(distDir, "");
1631
+ return seen;
1632
+ }
1633
+ function watchDist(options) {
1634
+ const debounceMs = options.debounceMs ?? DEBOUNCE_MS;
1635
+ const pending = /* @__PURE__ */ new Map();
1636
+ /**
1637
+ * Last content seen per file.
1638
+ *
1639
+ * A rebuild rewrites every output file, changed or not, so the filesystem
1640
+ * event alone says "the bundler ran", not "the developer edited this".
1641
+ * Comparing content is what makes a reload mean the second thing — and it
1642
+ * matters: a language file rewritten untouched would redraw every open
1643
+ * window, undoing the scoped re-render on the file that did change.
1644
+ */
1645
+ const digests = seedDigests(options.distDir);
1646
+ const send = async (relative) => {
1647
+ const extension = reloadableExtension(relative);
1648
+ if (!extension) return;
1649
+ let content;
1650
+ try {
1651
+ content = await readFile(join(options.distDir, relative), "utf8");
1652
+ } catch {
1653
+ return;
1654
+ }
1655
+ const hash = digest(content);
1656
+ if (digests.get(relative) === hash) return;
1657
+ digests.set(relative, hash);
1658
+ options.onPayload(JSON.stringify({
1659
+ packageType: options.packageType,
1660
+ packageId: options.packageId,
1661
+ content,
1662
+ path: servedPath(relative, options.packageId, options.packageType),
1663
+ extension
1664
+ }));
1665
+ };
1666
+ if (!existsSync(options.distDir)) {
1667
+ options.onError?.(`Cannot watch ${options.distDir} — the directory does not exist.`);
1668
+ return { close: () => void 0 };
1669
+ }
1670
+ let watcher;
1671
+ try {
1672
+ watcher = watch(options.distDir, { recursive: true }, (_event, filename) => {
1673
+ if (!filename) return;
1674
+ const relative = filename.toString();
1675
+ clearTimeout(pending.get(relative));
1676
+ pending.set(relative, setTimeout(() => {
1677
+ pending.delete(relative);
1678
+ send(relative);
1679
+ }, debounceMs));
1680
+ });
1681
+ } catch (err) {
1682
+ options.onError?.(err instanceof Error ? err.message : String(err));
1683
+ return { close: () => void 0 };
1684
+ }
1685
+ watcher.on("error", (err) => {
1686
+ options.onError?.(err instanceof Error ? err.message : String(err));
1687
+ });
1688
+ return { close: () => {
1689
+ for (const timer of pending.values()) clearTimeout(timer);
1690
+ pending.clear();
1691
+ watcher.close();
1692
+ } };
1693
+ }
1694
+ //#endregion
1695
+ //#region src/foundry-data-dir.ts
1696
+ /**
1697
+ * Foundry user-data directory discovery + persistence.
1698
+ *
1699
+ * Foundry stores worlds, systems, and modules under a single user-data
1700
+ * directory (`Data/` lives at its root). `vttforge dev` needs to know where
1701
+ * that is so it can symlink the built `dist/` into `Data/systems/<id>/` or
1702
+ * `Data/modules/<id>/` and let Foundry pick it up.
1703
+ *
1704
+ * Precedence (top wins):
1705
+ * 1. Explicit `--data-dir` flag passed to the command
1706
+ * 2. `FOUNDRY_DATA_DIR` env var (useful in Docker/CI)
1707
+ * 3. `<project>/.vttforge/config.json :: foundryDataDir`
1708
+ * 4. OS default + interactive prompt that saves to (3) for next time
1709
+ *
1710
+ * The OS-default detection treats `XDG_DATA_HOME` (Linux) and `%LOCALAPPDATA%`
1711
+ * (Windows) as part of the OS convention, not as Foundry-specific overrides.
1712
+ *
1713
+ * Everything here is pure — `platform`, `env`, and `home` flow through
1714
+ * options so tests can drive every branch without monkey-patching globals.
1715
+ */
1716
+ /**
1717
+ * Return the OS-default Foundry user-data directory, or `null` on unknown
1718
+ * platforms / missing platform-specific env vars. Does NOT check existence —
1719
+ * callers prompt the user when the suggested path doesn't resolve.
1720
+ */
1721
+ function autoDetectFoundryDataDir(platform = process.platform, env = process.env, home = homedir()) {
1722
+ switch (platform) {
1723
+ case "darwin": return join(home, "Library", "Application Support", "FoundryVTT");
1724
+ case "linux": {
1725
+ const xdg = env.XDG_DATA_HOME;
1726
+ if (typeof xdg === "string" && xdg.length > 0) return join(xdg, "FoundryVTT");
1727
+ return join(home, ".local", "share", "FoundryVTT");
1728
+ }
1729
+ case "win32": {
1730
+ const localAppData = env.LOCALAPPDATA;
1731
+ if (typeof localAppData === "string" && localAppData.length > 0) return join(localAppData, "FoundryVTT");
1732
+ return null;
1733
+ }
1734
+ default: return null;
1735
+ }
1736
+ }
1737
+ /**
1738
+ * Heuristic: a path looks like a Foundry user-data root if it contains a
1739
+ * `Data/` subdirectory (canonical Foundry layout) OR if it already contains
1740
+ * `systems/` or `modules/` (the path is already the Data folder itself).
1741
+ */
1742
+ function looksLikeFoundryDataDir(path) {
1743
+ if (!existsSync(path)) return false;
1744
+ return existsSync(join(path, "Data")) || existsSync(join(path, "systems")) || existsSync(join(path, "modules"));
1745
+ }
1746
+ const CONFIG_DIR = ".vttforge";
1747
+ const CONFIG_FILE = "config.json";
1748
+ /** Absolute path to the project-local config file (whether or not it exists). */
1749
+ function configPath(cwd) {
1750
+ return join(resolve(cwd), CONFIG_DIR, CONFIG_FILE);
1751
+ }
1752
+ /** Load `<cwd>/.vttforge/config.json` if it exists and is valid JSON. */
1753
+ async function loadConfig(cwd) {
1754
+ const path = configPath(cwd);
1755
+ if (!existsSync(path)) return null;
1756
+ try {
1757
+ const raw = await readFile(path, "utf8");
1758
+ const parsed = JSON.parse(raw);
1759
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
1760
+ return null;
1761
+ } catch {
1762
+ return null;
1763
+ }
1764
+ }
1765
+ /** Persist `<cwd>/.vttforge/config.json`. Creates the `.vttforge/` dir as needed. */
1766
+ async function saveConfig(cwd, config) {
1767
+ const path = configPath(cwd);
1768
+ await mkdir(dirname(path), { recursive: true });
1769
+ await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, "utf8");
1770
+ }
1771
+ /**
1772
+ * Heuristic: is `path` already Foundry's `Data/` folder (vs. its parent)?
1773
+ * We treat the path as Data when:
1774
+ * - basename is "Data" (case-insensitive — macOS/Windows file systems
1775
+ * are case-insensitive by default), OR
1776
+ * - any of `systems/`, `modules/`, `worlds/` exists inside (proof that
1777
+ * Foundry has previously written package folders here).
1778
+ *
1779
+ * Used by `foundryPackagesDir` so users can supply EITHER the user-data
1780
+ * root OR the `Data/` folder directly without us double-nesting `Data/Data`.
1781
+ */
1782
+ function isFoundryDataFolder(path) {
1783
+ if (basename(path).toLowerCase() === "data") return true;
1784
+ return existsSync(join(path, "systems")) || existsSync(join(path, "modules")) || existsSync(join(path, "worlds"));
1785
+ }
1786
+ /**
1787
+ * Resolve the packages directory (`Data/systems/` or `Data/modules/`) under
1788
+ * the given Foundry user-data root. Accepts the path Foundry itself runs
1789
+ * with (the user-data folder, which contains `Data/`) OR the `Data/` folder
1790
+ * directly — both are common in muscle memory.
1791
+ *
1792
+ * The returned path is not guaranteed to exist yet; callers `mkdir -p` it
1793
+ * before creating the symlink.
1794
+ */
1795
+ function foundryPackagesDir(dataRoot, type) {
1796
+ const subFolder = type === "system" ? "systems" : "modules";
1797
+ if (isFoundryDataFolder(dataRoot)) return join(dataRoot, subFolder);
1798
+ return join(dataRoot, "Data", subFolder);
1799
+ }
1800
+ /**
1801
+ * Expand `~` / `~/` prefixes to the user's home directory. Shells do this
1802
+ * automatically; Node's `path.resolve` does NOT, so a user who types the
1803
+ * prompt's own example `~/Library/Application Support/FoundryVTT` would
1804
+ * end up with a symlink under `<cwd>/~/Library/...` if we skipped this.
1805
+ */
1806
+ function expandTilde(p, home) {
1807
+ if (p === "~") return home;
1808
+ if (p.startsWith("~/") || p.startsWith("~\\")) return join(home, p.slice(2));
1809
+ return p;
1810
+ }
1811
+ /**
1812
+ * Walk the precedence chain and return the chosen Foundry user-data path.
1813
+ * Saves the user's choice to the project config on first interactive run so
1814
+ * subsequent invocations skip the prompt.
1815
+ */
1816
+ async function resolveFoundryDataDir(opts) {
1817
+ const { cwd, override, prompt } = opts;
1818
+ const env = opts.env ?? process.env;
1819
+ const platform = opts.platform ?? process.platform;
1820
+ const home = opts.home ?? homedir();
1821
+ if (typeof override === "string" && override.length > 0) return resolve(expandTilde(override, home));
1822
+ const envValue = env.FOUNDRY_DATA_DIR;
1823
+ if (typeof envValue === "string" && envValue.length > 0) return resolve(expandTilde(envValue, home));
1824
+ const config = await loadConfig(cwd);
1825
+ if (config?.foundryDataDir && existsSync(config.foundryDataDir)) return config.foundryDataDir;
1826
+ const detected = autoDetectFoundryDataDir(platform, env, home);
1827
+ if (prompt) {
1828
+ const choice = await prompt(detected);
1829
+ if (!choice) throw new Error("No Foundry data directory selected — aborting.");
1830
+ const chosen = resolve(expandTilde(choice, home));
1831
+ await saveConfig(cwd, {
1832
+ ...config,
1833
+ foundryDataDir: chosen
1834
+ });
1835
+ return chosen;
1836
+ }
1837
+ if (detected && existsSync(detected)) return detected;
1838
+ throw new Error("Foundry data directory not configured. Set FOUNDRY_DATA_DIR, pass --data-dir, or run `vttforge dev` interactively to save the path to .vttforge/config.json.");
1839
+ }
1840
+ /**
1841
+ * Stand up the hot reload bridge: install the companion module, open the
1842
+ * socket, watch the build output.
1843
+ *
1844
+ * Every failure here returns null rather than throwing. A busy port or a
1845
+ * missing companion package should cost the developer hot reload, not the
1846
+ * dev loop — the build and the symlink are the load-bearing parts, and they
1847
+ * already succeeded by the time this runs.
1848
+ */
1849
+ async function startHotReloadBridge(opts) {
1850
+ const packageDir = resolveDevModuleDir(opts.cwd);
1851
+ if (!packageDir) {
1852
+ p.note("Could not find @vttforge/dev-module. Install it to reload saves in place:\n pnpm add -D @vttforge/dev-module", "Hot reload unavailable");
1853
+ return null;
1854
+ }
1855
+ let server;
1856
+ try {
1857
+ server = await startDevServer({ port: opts.port });
1858
+ } catch {
1859
+ p.note(`Port ${opts.port} is in use — another \`vttforge dev\` is probably running.\nPass --hmr-port to use a different one.`, "Hot reload unavailable");
1860
+ return null;
1861
+ }
1862
+ const modulesDir = foundryPackagesDir(opts.dataRoot, "module");
1863
+ try {
1864
+ const install = await installDevModule(modulesDir, packageDir);
1865
+ p.note([
1866
+ `Companion module linked → ${install.target}`,
1867
+ "Enable \"VTTForge Dev\" in the world, once.",
1868
+ "",
1869
+ "Foundry in a container cannot follow that link. Mount it instead:",
1870
+ ` ${composeMountLine(packageDir)}`
1871
+ ].join("\n"), "Hot reload");
1872
+ } catch (err) {
1873
+ await server.close();
1874
+ p.note(`Could not install the companion module: ${err instanceof Error ? err.message : String(err)}`, "Hot reload unavailable");
1875
+ return null;
1876
+ }
1877
+ const watcher = watchDist({
1878
+ distDir: opts.distDir,
1879
+ packageId: opts.manifest.id,
1880
+ packageType: opts.manifest.type,
1881
+ onPayload: (frame) => server.broadcast(frame),
1882
+ onError: (message) => p.note(message, "Watch error")
1883
+ });
1884
+ return { close: async () => {
1885
+ watcher.close();
1886
+ await server.close();
1887
+ } };
1888
+ }
1889
+ /**
1890
+ * Compose a clack prompt that resolves to a Foundry user-data path or
1891
+ * `null` if the user cancels. Suitable for passing to
1892
+ * `resolveFoundryDataDir`.
1893
+ */
1894
+ function createDataDirPrompt() {
1895
+ return async (autoDetected) => {
1896
+ p.note(autoDetected ? `Detected Foundry user-data directory:\n ${autoDetected}` : "Could not auto-detect a Foundry user-data directory on this OS — please type the path.", "First-run setup");
1897
+ if (autoDetected) {
1898
+ const useDetected = await p.confirm({
1899
+ message: "Use detected directory?",
1900
+ initialValue: true
1901
+ });
1902
+ if (p.isCancel(useDetected)) return null;
1903
+ if (useDetected === true) return autoDetected;
1904
+ }
1905
+ const custom = await p.text({
1906
+ message: "Foundry user-data directory (the folder that contains Data/, e.g. ~/Library/Application Support/FoundryVTT)",
1907
+ placeholder: autoDetected ?? "/path/to/FoundryVTT",
1908
+ validate: (value) => {
1909
+ if (!value || value.trim().length === 0) return "A path is required";
1910
+ }
1911
+ });
1912
+ if (p.isCancel(custom)) return null;
1913
+ return String(custom).trim();
1914
+ };
1915
+ }
1916
+ /**
1917
+ * Install signal handlers that fire once. We use `process.once` so a second
1918
+ * Ctrl-C while cleanup is in flight propagates as a hard exit instead of
1919
+ * being swallowed (the user gave up on graceful shutdown — let them).
1920
+ */
1921
+ function installSignalHandlers(cleanup) {
1922
+ let invoked = false;
1923
+ const handler = () => {
1924
+ if (invoked) return;
1925
+ invoked = true;
1926
+ cleanup();
1927
+ };
1928
+ process.once("SIGINT", handler);
1929
+ process.once("SIGTERM", handler);
1930
+ return () => {
1931
+ process.removeListener("SIGINT", handler);
1932
+ process.removeListener("SIGTERM", handler);
1933
+ };
1934
+ }
1935
+ async function runDev(options = {}) {
1936
+ const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
1937
+ p.intro("🜲 vttforge dev — symlink + watch");
1938
+ if (!existsSync(join(cwd, "package.json"))) {
1939
+ p.cancel(`No package.json at ${cwd}. Run \`vttforge dev\` from inside a scaffolded project.`);
1940
+ throw new Error("Missing package.json");
1941
+ }
1942
+ const buildSpinner = p.spinner();
1943
+ buildSpinner.start("vite build (initial)");
1944
+ try {
1945
+ await runViteBuildOnce(cwd);
1946
+ buildSpinner.stop("Initial build complete");
1947
+ } catch (err) {
1948
+ buildSpinner.stop("Initial build failed");
1949
+ throw err;
1950
+ }
1951
+ const distDir = join(cwd, "dist");
1952
+ const manifest = await readManifest(distDir);
1953
+ const interactive = Boolean(process.stdout.isTTY);
1954
+ const dataRoot = await resolveFoundryDataDir({
1955
+ cwd,
1956
+ override: options.dataDir,
1957
+ prompt: interactive ? createDataDirPrompt() : void 0
1958
+ });
1959
+ const packagesDir = foundryPackagesDir(dataRoot, manifest.type);
1960
+ const target = join(packagesDir, manifest.id);
1961
+ const existingTarget = await readLinkTarget(target);
1962
+ if (existingTarget && existingTarget !== distDir) p.note(`Replacing stale symlink:\n ${target}\n was: ${existingTarget}\n now: ${distDir}`, "Symlink");
1963
+ await createLink(target, distDir, { overwrite: true });
1964
+ p.note(`Linked dist/ → ${target}\nFoundry serves the package under /${manifest.type}s/${manifest.id}/.`, "Symlinked");
1965
+ const bridge = await startHotReloadBridge({
1966
+ cwd,
1967
+ distDir,
1968
+ dataRoot,
1969
+ manifest,
1970
+ port: options.hmrPort ?? 31313
1971
+ });
1972
+ p.note(`${bridge ? "Saves apply in place — no page refresh." : "Hot reload is off; saves need a page refresh."}\nCtrl-C to stop.`, "Watching");
1973
+ const watcher = spawnViteWatch(cwd);
1974
+ await new Promise((resolveDev) => {
1975
+ const cleanup = async () => {
1976
+ try {
1977
+ await cleanupDevSymlink({
1978
+ target,
1979
+ expectedSource: distDir
1980
+ });
1981
+ } catch {}
1982
+ await bridge?.close();
1983
+ if (!watcher.killed) watcher.kill("SIGINT");
1984
+ uninstall();
1985
+ resolveDev();
1986
+ };
1987
+ const uninstall = installSignalHandlers(cleanup);
1988
+ watcher.on("exit", (code) => {
1989
+ if (code !== 0 && code !== null) console.error(`\nvite watcher exited with code ${code}.`);
1990
+ });
1991
+ });
1992
+ p.outro("Stopped. Symlink cleaned up.");
1993
+ }
1994
+ /**
1995
+ * Internal building block exposed for tests: given an already-built `dist/`
1996
+ * (vite has run, manifest is present) and a resolved Foundry data dir,
1997
+ * create the symlink and return the target path + manifest. Skips the
1998
+ * vite spawn and the signal-loop, so tests can drive the orchestration
1999
+ * deterministically.
2000
+ */
2001
+ async function setupDevSymlink(opts) {
2002
+ const cwd = resolve(opts.cwd);
2003
+ const distDir = join(cwd, "dist");
2004
+ const manifest = await readManifest(distDir);
2005
+ const packagesDir = foundryPackagesDir(opts.dataRoot, manifest.type);
2006
+ const target = join(packagesDir, manifest.id);
2007
+ await createLink(target, distDir, { overwrite: true });
2008
+ return {
2009
+ target,
2010
+ manifest
2011
+ };
2012
+ }
2013
+ /**
2014
+ * Tear down a dev symlink iff it still points at the expected source.
2015
+ * Refuses to remove a symlink that another `vttforge dev` session (or a
2016
+ * manual `ln -s`) has since redirected — that link doesn't belong to us
2017
+ * and removing it would silently disconnect their setup.
2018
+ */
2019
+ async function cleanupDevSymlink(opts) {
2020
+ if (await readLinkTarget(opts.target) === opts.expectedSource) await removeLink(opts.target);
2021
+ }
2022
+ //#endregion
2023
+ //#region src/git.ts
2024
+ /**
2025
+ * Git helpers for the init scaffolder. Optional: every step degrades to a
2026
+ * warning, never throws. The scaffold completes even if git is missing or
2027
+ * the user has no global git config — the user can still `git init`
2028
+ * themselves later.
2029
+ */
2030
+ const execFileAsync = promisify(execFile);
2031
+ /**
2032
+ * Read `git config user.name` to pre-fill the author prompt. Returns
2033
+ * `undefined` if git is missing or the value is unset.
2034
+ */
2035
+ async function readGitAuthorName() {
2036
+ try {
2037
+ const { stdout } = await execFileAsync("git", [
2038
+ "config",
2039
+ "--get",
2040
+ "user.name"
2041
+ ]);
2042
+ const value = stdout.trim();
2043
+ return value.length > 0 ? value : void 0;
2044
+ } catch {
2045
+ return;
2046
+ }
2047
+ }
2048
+ /**
2049
+ * Initialize a fresh git repository in `cwd`, run an initial commit. Errors
2050
+ * are swallowed and converted to a return value so the caller can decide
2051
+ * whether to warn or move on.
2052
+ */
2053
+ async function initGitRepo(cwd) {
2054
+ try {
2055
+ await execFileAsync("git", ["init", "--initial-branch=main"], { cwd });
2056
+ await execFileAsync("git", ["add", "."], { cwd });
2057
+ await execFileAsync("git", [
2058
+ "commit",
2059
+ "-m",
2060
+ "chore: initial scaffold from @vttforge/cli"
2061
+ ], { cwd });
2062
+ return { ok: true };
2063
+ } catch (err) {
2064
+ return {
2065
+ ok: false,
2066
+ reason: err instanceof Error ? err.message : String(err)
2067
+ };
2068
+ }
2069
+ }
2070
+ //#endregion
2071
+ //#region src/scaffold.ts
2072
+ /**
2073
+ * Template copy with `{{var}}` placeholder substitution.
2074
+ *
2075
+ * Templates live under `packages/cli/templates/<variant>/` and are shipped
2076
+ * inside the published tarball (see the `files` array in package.json).
2077
+ * Each file is read, substituted, and written to the destination directory
2078
+ * preserving the relative path. Directories are created lazily on first
2079
+ * write.
2080
+ *
2081
+ * We intentionally avoid Handlebars or any other template engine — the
2082
+ * substitution surface is small (a flat string-to-string map), and bundling
2083
+ * a dependency for ten lines of regex would be wasteful in a tool whose
2084
+ * value is being fast to invoke.
2085
+ */
2086
+ const PLACEHOLDER_RE = /\{\{(\w+)\}\}/g;
2087
+ /**
2088
+ * Replace `{{NAME}}` placeholders in `content` using the `vars` map.
2089
+ * Unknown placeholders are passed through unchanged so partially-templated
2090
+ * files (e.g. a snippet that contains `{{handlebarsLikeSyntax}}` as literal
2091
+ * content) still scaffold without surprises.
2092
+ */
2093
+ function substitute(content, vars) {
2094
+ const lookup = vars;
2095
+ return content.replace(PLACEHOLDER_RE, (match, key) => Object.hasOwn(lookup, key) ? lookup[key] ?? match : match);
2096
+ }
2097
+ /**
2098
+ * Walk `dir` recursively and yield every file path relative to `dir`.
2099
+ */
2100
+ async function* walkRelative(dir, base = dir) {
2101
+ const entries = await readdir(dir, { withFileTypes: true });
2102
+ for (const entry of entries) {
2103
+ const full = join(dir, entry.name);
2104
+ if (entry.isDirectory()) yield* walkRelative(full, base);
2105
+ else if (entry.isFile()) yield relative(base, full);
2106
+ }
2107
+ }
2108
+ /**
2109
+ * Files that should be substituted as text. Binary assets (.png, .ico, …)
2110
+ * bypass substitution and are copied byte-for-byte. The list of binary
2111
+ * extensions is intentionally small — extend if we add real binary assets
2112
+ * to templates.
2113
+ */
2114
+ const BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
2115
+ ".png",
2116
+ ".jpg",
2117
+ ".jpeg",
2118
+ ".gif",
2119
+ ".webp",
2120
+ ".ico",
2121
+ ".woff",
2122
+ ".woff2",
2123
+ ".ttf",
2124
+ ".otf"
2125
+ ]);
2126
+ function isBinaryPath(path) {
2127
+ const dotIndex = path.lastIndexOf(".");
2128
+ if (dotIndex < 0) return false;
2129
+ return BINARY_EXTENSIONS.has(path.slice(dotIndex).toLowerCase());
2130
+ }
2131
+ /**
2132
+ * Files whose source name in the template is rewritten at scaffold time so
2133
+ * npm packaging doesn't strip or rename them. npm transforms `.gitignore`
2134
+ * into `.npmignore` on publish, so templates ship `_gitignore` and the
2135
+ * scaffolder writes `.gitignore` into the generated project.
2136
+ */
2137
+ const SCAFFOLD_PATH_RENAMES = /* @__PURE__ */ new Map([["_gitignore", ".gitignore"]]);
2138
+ function rewriteDestRelPath(relPath) {
2139
+ const base = relPath.split("/").pop() ?? relPath;
2140
+ const replacement = SCAFFOLD_PATH_RENAMES.get(base);
2141
+ if (replacement === void 0) return relPath;
2142
+ return relPath.slice(0, relPath.length - base.length) + replacement;
2143
+ }
2144
+ async function scaffold({ templateDir, destDir, vars }) {
2145
+ if (!existsSync(templateDir)) throw new Error(`[vttforge] template directory does not exist: ${templateDir}`);
2146
+ if (!(await stat(templateDir)).isDirectory()) throw new Error(`[vttforge] template path is not a directory: ${templateDir}`);
2147
+ await mkdir(destDir, { recursive: true });
2148
+ for await (const relPath of walkRelative(templateDir)) {
2149
+ const srcPath = join(templateDir, relPath);
2150
+ const substituted = substitute(relPath, vars);
2151
+ const destPath = join(destDir, rewriteDestRelPath(substituted));
2152
+ await mkdir(dirname(destPath), { recursive: true });
2153
+ if (isBinaryPath(srcPath)) {
2154
+ const bytes = await readFile(srcPath);
2155
+ await writeFile(destPath, bytes);
2156
+ continue;
2157
+ }
2158
+ const content = await readFile(srcPath, "utf8");
2159
+ await writeFile(destPath, substitute(content, vars), "utf8");
2160
+ }
2161
+ }
2162
+ /**
2163
+ * Resolve the templates directory relative to this module's file URL. tsdown
2164
+ * emits `dist/scaffold.mjs`, so `import.meta.url` points there at runtime;
2165
+ * we walk up two segments to land at the package root, then descend into
2166
+ * `templates/`.
2167
+ */
2168
+ function templatesRoot() {
2169
+ const here = fileURLToPath(import.meta.url);
2170
+ return resolve(here, "..", "..", "templates");
2171
+ }
2172
+ //#endregion
2173
+ //#region src/commands/init.ts
2174
+ /**
2175
+ * `vttforge init` — interactive scaffolder.
2176
+ *
2177
+ * Honors CLI flags first, prompts for everything missing, then copies the
2178
+ * matching template into the destination directory. Optionally `git init`s
2179
+ * and runs the detected package manager's install command at the end.
2180
+ */
2181
+ const PACKAGE_ID_RE = /^[a-z][a-z0-9-]*$/;
2182
+ const UNSAFE_METADATA_CHARS_RE = /["\\\r\n\t]/;
2183
+ function validateMetadata(value) {
2184
+ if (!value) return void 0;
2185
+ if (UNSAFE_METADATA_CHARS_RE.test(value)) return "Avoid backslashes, double quotes, and line breaks — they break the generated manifest.";
2186
+ if (value.includes("*/")) return "Avoid `*/` — it closes block comments in the generated source headers.";
2187
+ }
2188
+ function validateRequiredMetadata(value) {
2189
+ if (!value || value.trim().length === 0) return "Required — Foundry rejects packages with a blank title.";
2190
+ return validateMetadata(value);
2191
+ }
2192
+ function validatePackageId(value) {
2193
+ if (!PACKAGE_ID_RE.test(value ?? "")) return "Package id must start with a letter and contain only lowercase letters, digits, dashes";
2194
+ }
2195
+ /**
2196
+ * Thrown when the scaffolder cannot continue — bad input, an existing
2197
+ * destination, a cancelled prompt, etc. `runInit` propagates these instead
2198
+ * of calling `process.exit`, so library consumers (tests, other CLIs) can
2199
+ * catch and recover. The `vttforge` bin wraps `runInit` in a top-level
2200
+ * handler that prints the message and exits with code 1.
2201
+ */
2202
+ var ScaffoldError = class extends Error {
2203
+ constructor(message) {
2204
+ super(message);
2205
+ this.name = "ScaffoldError";
2206
+ }
2207
+ };
2208
+ function isCancelled(value) {
2209
+ return p.isCancel(value);
2210
+ }
2211
+ function bail(message) {
2212
+ p.cancel(message);
2213
+ throw new ScaffoldError(message);
2214
+ }
2215
+ function slugify(input) {
2216
+ return input.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
2217
+ }
2218
+ function localePrefix(id) {
2219
+ return id.toUpperCase().replace(/-/g, "_");
2220
+ }
2221
+ function titleCase(input) {
2222
+ return input.split(/[-_\s]+/).filter((part) => part.length > 0).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
2223
+ }
2224
+ /**
2225
+ * Whether we may ask the user anything at all.
2226
+ *
2227
+ * Clack reads from stdin. With no terminal attached there is nothing to read,
2228
+ * and the prompt simply hangs — so a run that cannot ask has to be told every
2229
+ * answer up front, or be given `--yes` and take the defaults.
2230
+ */
2231
+ function canPrompt(yes) {
2232
+ return yes !== true && Boolean(process.stdin.isTTY);
2233
+ }
2234
+ function templateVariantFor(type, lang) {
2235
+ return `${type}-${lang}`;
2236
+ }
2237
+ async function runInit(options = {}) {
2238
+ const cwd = options.cwd ?? process.cwd();
2239
+ const interactive = canPrompt(options.yes);
2240
+ p.intro("🜲 vttforge init — scaffold a Foundry v13+ system or module");
2241
+ let name = options.name?.trim();
2242
+ if (!name && !interactive) bail("A directory name is required when running without prompts. Pass it as the first argument: `vttforge init my-module --yes`.");
2243
+ if (!name) {
2244
+ const answer = await p.text({
2245
+ message: "Directory name (also the default manifest id)",
2246
+ placeholder: "my-system",
2247
+ validate: (value) => {
2248
+ const trimmed = value?.trim() ?? "";
2249
+ if (trimmed.length === 0) return "Name is required";
2250
+ if (!PACKAGE_ID_RE.test(trimmed)) return "Use only lowercase letters, digits, and dashes (no spaces, slashes, dots).";
2251
+ }
2252
+ });
2253
+ if (isCancelled(answer)) bail("Scaffold cancelled.");
2254
+ name = String(answer).trim();
2255
+ } else if (!PACKAGE_ID_RE.test(name)) bail(`Invalid name "${name}": use only lowercase letters, digits, and dashes (no spaces, slashes, dots).`);
2256
+ const dest = resolve(cwd, name);
2257
+ if (existsSync(dest)) bail(`Directory already exists: ${dest}`);
2258
+ let type = options.type;
2259
+ if (type !== "system" && type !== "module" && !interactive) type = "system";
2260
+ if (type !== "system" && type !== "module") {
2261
+ const answer = await p.select({
2262
+ message: "What are you building?",
2263
+ options: [{
2264
+ value: "system",
2265
+ label: "system — defines the game rules (Actor/Item types, sheets)"
2266
+ }, {
2267
+ value: "module",
2268
+ label: "module — extends an existing system or adds cross-system features"
2269
+ }],
2270
+ initialValue: "system"
2271
+ });
2272
+ if (isCancelled(answer)) bail("Scaffold cancelled.");
2273
+ type = answer;
2274
+ }
2275
+ let lang = options.lang;
2276
+ if (lang !== "ts" && lang !== "js" && !interactive) lang = "ts";
2277
+ if (lang !== "ts" && lang !== "js") {
2278
+ const answer = await p.select({
2279
+ message: "Language",
2280
+ options: [{
2281
+ value: "ts",
2282
+ label: "TypeScript"
2283
+ }, {
2284
+ value: "js",
2285
+ label: "JavaScript (.mjs)"
2286
+ }],
2287
+ initialValue: "ts"
2288
+ });
2289
+ if (isCancelled(answer)) bail("Scaffold cancelled.");
2290
+ lang = answer;
2291
+ }
2292
+ /**
2293
+ * Resolve one metadata field: an explicit flag wins, otherwise ask, and
2294
+ * when asking is impossible take the default.
2295
+ */
2296
+ const resolveField = async (provided, fallback, ask, validate) => {
2297
+ if (provided !== void 0) {
2298
+ const trimmed = provided.trim();
2299
+ const failure = validate(trimmed);
2300
+ if (failure !== void 0) bail(`${failure} (got ${JSON.stringify(provided)})`);
2301
+ return trimmed;
2302
+ }
2303
+ if (!interactive) return fallback;
2304
+ const answer = await ask();
2305
+ if (isCancelled(answer)) bail("Scaffold cancelled.");
2306
+ const value = String(answer).trim();
2307
+ return value.length > 0 ? value : fallback;
2308
+ };
2309
+ const defaultId = slugify(name);
2310
+ const id = await resolveField(options.id, defaultId, () => p.text({
2311
+ message: "Package id (used as the folder Foundry serves under /<systems|modules>/<id>/)",
2312
+ initialValue: defaultId,
2313
+ validate: validatePackageId
2314
+ }), validatePackageId);
2315
+ const title = await resolveField(options.title, titleCase(id), () => p.text({
2316
+ message: "Title (human-readable, shown in Foundry setup screens)",
2317
+ initialValue: titleCase(id),
2318
+ validate: validateRequiredMetadata
2319
+ }), validateRequiredMetadata);
2320
+ const defaultDescription = `A Foundry v13+ ${type} built with VTTForge`;
2321
+ const description = await resolveField(options.description, defaultDescription, () => p.text({
2322
+ message: "One-line description",
2323
+ placeholder: defaultDescription,
2324
+ initialValue: defaultDescription,
2325
+ validate: validateMetadata
2326
+ }), validateMetadata);
2327
+ const rawDetectedAuthor = await readGitAuthorName();
2328
+ const detectedAuthor = rawDetectedAuthor && validateMetadata(rawDetectedAuthor) === void 0 ? rawDetectedAuthor : void 0;
2329
+ const author = await resolveField(options.author, detectedAuthor ?? "Anonymous", () => p.text({
2330
+ message: "Author name (used in the manifest)",
2331
+ placeholder: detectedAuthor ?? "Your Name",
2332
+ initialValue: detectedAuthor ?? "",
2333
+ validate: validateMetadata
2334
+ }), validateMetadata);
2335
+ const license = await resolveField(options.license, "MIT", () => p.select({
2336
+ message: "License",
2337
+ options: [
2338
+ {
2339
+ value: "MIT",
2340
+ label: "MIT (recommended for Foundry community packages)"
2341
+ },
2342
+ {
2343
+ value: "Apache-2.0",
2344
+ label: "Apache-2.0"
2345
+ },
2346
+ {
2347
+ value: "GPL-3.0-or-later",
2348
+ label: "GPL-3.0-or-later"
2349
+ },
2350
+ {
2351
+ value: "UNLICENSED",
2352
+ label: "UNLICENSED (private use only)"
2353
+ }
2354
+ ],
2355
+ initialValue: "MIT"
2356
+ }), validateRequiredMetadata);
2357
+ const foundryMinVersion = "13";
2358
+ const foundryVerifiedVersion = "13.341";
2359
+ const templateVariant = templateVariantFor(type, lang);
2360
+ const templateDir = resolve(templatesRoot(), templateVariant);
2361
+ if (!existsSync(templateDir)) bail(`Template "${templateVariant}" not found at ${templateDir}`);
2362
+ const vars = {
2363
+ ID: id,
2364
+ TITLE: title,
2365
+ DESCRIPTION: description,
2366
+ AUTHOR: author,
2367
+ LICENSE: license,
2368
+ FOUNDRY_MIN_VERSION: foundryMinVersion,
2369
+ FOUNDRY_VERIFIED_VERSION: foundryVerifiedVersion,
2370
+ LOCALE_PREFIX: localePrefix(id),
2371
+ YEAR: String((/* @__PURE__ */ new Date()).getFullYear())
2372
+ };
2373
+ const scaffoldSpinner = p.spinner();
2374
+ scaffoldSpinner.start(`Scaffolding ${templateVariant} into ${name}/`);
2375
+ try {
2376
+ await scaffold({
2377
+ templateDir,
2378
+ destDir: dest,
2379
+ vars
2380
+ });
2381
+ scaffoldSpinner.stop(`Scaffold written to ${dest}`);
2382
+ } catch (err) {
2383
+ scaffoldSpinner.stop("Scaffold failed.");
2384
+ throw err;
2385
+ }
2386
+ const pm = detectPackageManager();
2387
+ let shouldInstall = !options.noInstall;
2388
+ if (shouldInstall && interactive) {
2389
+ const confirm = await p.confirm({
2390
+ message: `Install dependencies now with ${pm}?`,
2391
+ initialValue: true
2392
+ });
2393
+ if (isCancelled(confirm)) bail("Scaffold cancelled at install prompt (your scaffold is intact).");
2394
+ shouldInstall = confirm === true;
2395
+ }
2396
+ if (shouldInstall) {
2397
+ const installSpinner = p.spinner();
2398
+ installSpinner.start(`Running ${installCommand(pm)}`);
2399
+ try {
2400
+ await runInstall(pm, dest);
2401
+ installSpinner.stop(`Dependencies installed (${pm}).`);
2402
+ } catch (err) {
2403
+ installSpinner.stop(`Install failed: ${err instanceof Error ? err.message : String(err)}`);
2404
+ p.note(`Run \`${installCommand(pm)}\` inside ${name}/ to retry.`, "Continuing without install.");
2405
+ }
2406
+ }
2407
+ if (!options.noGit) {
2408
+ const gitSpinner = p.spinner();
2409
+ gitSpinner.start("Initializing git repository");
2410
+ const gitResult = await initGitRepo(dest);
2411
+ if (gitResult.ok) gitSpinner.stop("Git initialized (branch: main, initial commit landed).");
2412
+ else gitSpinner.stop(`Skipped git init: ${gitResult.reason ?? "unknown error"}`);
2413
+ }
2414
+ const nextSteps = [
2415
+ `cd ${name}`,
2416
+ !shouldInstall ? installCommand(pm) : null,
2417
+ `${pm} run build`,
2418
+ "Symlink dist/ into your Foundry Data/<systems|modules>/<id>/ (or use the dev compose mount)"
2419
+ ].filter((line) => line !== null).map((line, idx) => ` ${idx + 1}. ${line}`).join("\n");
2420
+ p.note(nextSteps, "Next steps");
2421
+ p.outro("Have fun building.");
2422
+ }
2423
+ function runInstall(pm, cwd) {
2424
+ return new Promise((resolveInstall, rejectInstall) => {
2425
+ const child = spawn(pm, ["install"], {
2426
+ cwd,
2427
+ stdio: "inherit",
2428
+ env: process.env
2429
+ });
2430
+ child.on("error", rejectInstall);
2431
+ child.on("exit", (code) => {
2432
+ if (code === 0) resolveInstall();
2433
+ else rejectInstall(/* @__PURE__ */ new Error(`${pm} install exited with code ${code}`));
2434
+ });
2435
+ });
2436
+ }
2437
+ //#endregion
2438
+ //#region src/index.ts
2439
+ const VTTFORGE_CLI_VERSION = "0.1.0";
2440
+ //#endregion
2441
+ export { installCommand as A, ViteNotInstalledError as C, detectPackageManager as D, spawnViteWatch as E, SEVERITY_RANK as F, runSourceRules as I, runManifestRules as L, runAuditCommand as M, formatReport as N, detectProjectPackageManager as O, runAudit as P, emitZip as S, runViteBuildOnce as T, createLink as _, substitute as a, emitReleaseZip as b, runDev as c, configPath as d, foundryPackagesDir as f, saveConfig as g, resolveFoundryDataDir as h, scaffold as i, readManifest as j, execInvocation as k, setupDevSymlink as l, looksLikeFoundryDataDir as m, ScaffoldError as n, templatesRoot as o, loadConfig as p, runInit as r, cleanupDevSymlink as s, VTTFORGE_CLI_VERSION as t, autoDetectFoundryDataDir as u, readLinkTarget as v, resolveViteInvocation as w, runBuild as x, removeLink as y };
2442
+
2443
+ //# sourceMappingURL=src-CWhUNM__.mjs.map