@reddoorla/maintenance 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/bin.js CHANGED
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli/bin.ts
4
- import { readFileSync } from "fs";
5
- import { dirname, join as join14 } from "path";
4
+ import { dirname } from "path";
6
5
  import { fileURLToPath } from "url";
7
6
  import { cac } from "cac";
8
7
 
@@ -12,15 +11,18 @@ import { resolve as resolve2 } from "path";
12
11
  // src/audits/util/spawn.ts
13
12
  import { spawn } from "child_process";
14
13
  var defaultSpawn = (cmd, args, opts = {}) => new Promise((resolve6, reject) => {
14
+ const streaming = opts.streaming === true;
15
15
  const child = spawn(cmd, [...args], {
16
16
  cwd: opts.cwd,
17
17
  env: opts.env ?? process.env,
18
- stdio: ["ignore", "pipe", "pipe"]
18
+ stdio: streaming ? ["ignore", "inherit", "inherit"] : ["ignore", "pipe", "pipe"]
19
19
  });
20
20
  let stdout = "";
21
21
  let stderr = "";
22
- child.stdout.on("data", (chunk) => stdout += String(chunk));
23
- child.stderr.on("data", (chunk) => stderr += String(chunk));
22
+ if (!streaming) {
23
+ child.stdout?.on("data", (chunk) => stdout += String(chunk));
24
+ child.stderr?.on("data", (chunk) => stderr += String(chunk));
25
+ }
24
26
  const timer = opts.timeoutMs ? setTimeout(() => {
25
27
  child.kill("SIGTERM");
26
28
  reject(new Error(`spawn timeout after ${opts.timeoutMs}ms: ${cmd}`));
@@ -115,10 +117,10 @@ async function depsAudit(ctx) {
115
117
  details: { error: String(err) }
116
118
  };
117
119
  }
118
- const pkg2 = JSON.parse(pkgRaw);
120
+ const pkg = JSON.parse(pkgRaw);
119
121
  const installed = {
120
- ...pkg2.dependencies ?? {},
121
- ...pkg2.devDependencies ?? {}
122
+ ...pkg.dependencies ?? {},
123
+ ...pkg.devDependencies ?? {}
122
124
  };
123
125
  const details = [];
124
126
  for (const [name, baseline] of Object.entries(baselineVersions)) {
@@ -148,7 +150,7 @@ async function depsAudit(ctx) {
148
150
  // src/audits/lint.ts
149
151
  import { existsSync } from "fs";
150
152
  import { readFile as readFile2 } from "fs/promises";
151
- import { join as join2, relative } from "path";
153
+ import { join as join2 } from "path";
152
154
  import { ESLint } from "eslint";
153
155
  import { check as prettierCheck, resolveConfig as prettierResolveConfig } from "prettier";
154
156
  import { glob } from "tinyglobby";
@@ -177,19 +179,19 @@ async function lintAudit(ctx) {
177
179
  errorOnUnmatchedPattern: false
178
180
  });
179
181
  const relFiles = await listFiles(site.path);
180
- const filesToLint = relFiles.map((f) => join2(site.path, f));
181
- const eslintResults = await eslint2.lintFiles(filesToLint);
182
+ const eslintResults = await eslint2.lintFiles(relFiles);
182
183
  const eslintErrors = eslintResults.reduce((n, r) => n + r.errorCount, 0);
183
184
  const eslintWarnings = eslintResults.reduce((n, r) => n + r.warningCount, 0);
184
185
  const prettierUnformatted = [];
185
- for (const file of filesToLint) {
186
- const source = await readFile2(file, "utf-8");
187
- const options = await prettierResolveConfig(file) ?? {};
188
- const ok = await prettierCheck(source, { ...options, filepath: file });
189
- if (!ok) prettierUnformatted.push(relative(site.path, file));
186
+ for (const rel of relFiles) {
187
+ const absForResolve = join2(site.path, rel);
188
+ const source = await readFile2(absForResolve, "utf-8");
189
+ const options = await prettierResolveConfig(absForResolve) ?? {};
190
+ const ok = await prettierCheck(source, { ...options, filepath: absForResolve });
191
+ if (!ok) prettierUnformatted.push(rel);
190
192
  }
191
193
  const status = eslintErrors > 0 || prettierUnformatted.length > 0 ? "fail" : eslintWarnings > 0 ? "warn" : "pass";
192
- const summary = status === "pass" ? `lint clean across ${filesToLint.length} files` : `${eslintErrors} eslint errors, ${eslintWarnings} warnings, ${prettierUnformatted.length} unformatted`;
194
+ const summary = status === "pass" ? `lint clean across ${relFiles.length} files` : `${eslintErrors} eslint errors, ${eslintWarnings} warnings, ${prettierUnformatted.length} unformatted`;
193
195
  return {
194
196
  audit: "lint",
195
197
  site: siteLabel2(site),
@@ -199,7 +201,7 @@ async function lintAudit(ctx) {
199
201
  eslintErrors,
200
202
  eslintWarnings,
201
203
  prettierUnformatted,
202
- files: filesToLint.length
204
+ files: relFiles.length
203
205
  }
204
206
  };
205
207
  }
@@ -213,6 +215,61 @@ function classify(v) {
213
215
  if (v.moderate > 0 || v.low > 0) return "warn";
214
216
  return "pass";
215
217
  }
218
+ function normalizeSeverity(s) {
219
+ if (s === "low" || s === "moderate" || s === "high" || s === "critical") return s;
220
+ return "low";
221
+ }
222
+ function extractAdvisoriesFromPnpm(parsed) {
223
+ const out = [];
224
+ for (const a of Object.values(parsed.advisories ?? {})) {
225
+ if (!a) continue;
226
+ out.push({
227
+ module: a.module_name ?? "unknown",
228
+ severity: normalizeSeverity(a.severity),
229
+ title: a.title ?? "(no title)",
230
+ ...a.cves ? { cves: a.cves } : {},
231
+ ...a.url ? { url: a.url } : {}
232
+ });
233
+ }
234
+ return out;
235
+ }
236
+ function resolveNpmAdvisoryRoot(startName, vulnerabilities) {
237
+ const seen = /* @__PURE__ */ new Set();
238
+ let current = startName;
239
+ while (!seen.has(current)) {
240
+ seen.add(current);
241
+ const entry = vulnerabilities[current];
242
+ if (!entry || !Array.isArray(entry.via)) return { rootName: current };
243
+ const detailed = entry.via.find(
244
+ (e) => typeof e === "object" && e !== null
245
+ );
246
+ if (detailed) return { rootName: current, detail: detailed };
247
+ const next = entry.via.find((e) => typeof e === "string");
248
+ if (!next || next === current) return { rootName: current };
249
+ current = next;
250
+ }
251
+ return { rootName: current };
252
+ }
253
+ function extractAdvisoriesFromNpm(parsed) {
254
+ const vulnerabilities = parsed.vulnerabilities ?? {};
255
+ const roots = /* @__PURE__ */ new Map();
256
+ for (const [name, v] of Object.entries(vulnerabilities)) {
257
+ if (!v) continue;
258
+ const { rootName, detail } = resolveNpmAdvisoryRoot(name, vulnerabilities);
259
+ if (roots.has(rootName)) continue;
260
+ const rootEntry = vulnerabilities[rootName];
261
+ const severity = normalizeSeverity(rootEntry?.severity ?? v.severity);
262
+ const title = detail?.title ?? rootName;
263
+ const url = detail?.url;
264
+ roots.set(rootName, {
265
+ module: rootEntry?.name ?? rootName,
266
+ severity,
267
+ title,
268
+ ...url ? { url } : {}
269
+ });
270
+ }
271
+ return [...roots.values()];
272
+ }
216
273
  async function tryRun(spawn2, cmd, args, cwd) {
217
274
  try {
218
275
  return await spawn2(cmd, args, { cwd });
@@ -266,26 +323,27 @@ async function securityAudit(ctx) {
266
323
  details: { error: String(err), stdout: raw.stdout.slice(0, 500) }
267
324
  };
268
325
  }
269
- const vuln = {
326
+ const counts = {
270
327
  low: parsed.metadata?.vulnerabilities?.low ?? 0,
271
328
  moderate: parsed.metadata?.vulnerabilities?.moderate ?? 0,
272
329
  high: parsed.metadata?.vulnerabilities?.high ?? 0,
273
330
  critical: parsed.metadata?.vulnerabilities?.critical ?? 0
274
331
  };
275
- const status = classify(vuln);
276
- const total = vuln.low + vuln.moderate + vuln.high + vuln.critical;
277
- const summary = status === "pass" ? `${used}: 0 vulnerabilities` : `${used}: ${total} vulnerabilities (${vuln.critical}C/${vuln.high}H/${vuln.moderate}M/${vuln.low}L)`;
332
+ const advisories = used === "pnpm audit" ? extractAdvisoriesFromPnpm(parsed) : extractAdvisoriesFromNpm(parsed);
333
+ const status = classify(counts);
334
+ const total = counts.low + counts.moderate + counts.high + counts.critical;
335
+ const summary = status === "pass" ? `${used}: 0 vulnerabilities` : `${used}: ${total} vulnerabilities (${counts.critical}C/${counts.high}H/${counts.moderate}M/${counts.low}L)`;
278
336
  return {
279
337
  audit: "security",
280
338
  site: label,
281
339
  status,
282
340
  summary,
283
- details: vuln
341
+ details: { counts, advisories }
284
342
  };
285
343
  }
286
344
 
287
345
  // src/audits/lighthouse.ts
288
- import { writeFile, mkdtemp, rm } from "fs/promises";
346
+ import { readFile as readFile3, writeFile, mkdtemp, rm } from "fs/promises";
289
347
  import { tmpdir } from "os";
290
348
  import { join as join3 } from "path";
291
349
 
@@ -321,29 +379,56 @@ var lighthouseConfig = {
321
379
  function siteLabel4(site) {
322
380
  return site.name ?? site.path;
323
381
  }
324
- function isFakeShape(stdout) {
382
+ async function readJsonMaybe(path) {
325
383
  try {
326
- const parsed = JSON.parse(stdout);
327
- if (typeof parsed.assertionsFailed === "number" && parsed.summary) return parsed;
384
+ const raw = await readFile3(path, "utf-8");
385
+ return JSON.parse(raw);
328
386
  } catch {
329
387
  return null;
330
388
  }
331
- return null;
389
+ }
390
+ function averageSummaries(entries) {
391
+ if (entries.length === 0) return {};
392
+ const sums = {};
393
+ const counts = {};
394
+ for (const e of entries) {
395
+ for (const [k, v] of Object.entries(e.summary ?? {})) {
396
+ if (typeof v !== "number") continue;
397
+ sums[k] = (sums[k] ?? 0) + v;
398
+ counts[k] = (counts[k] ?? 0) + 1;
399
+ }
400
+ }
401
+ const out = {};
402
+ for (const k of Object.keys(sums)) {
403
+ const total = sums[k] ?? 0;
404
+ const count = counts[k] ?? 1;
405
+ out[k] = total / count;
406
+ }
407
+ return out;
408
+ }
409
+ function categoryFromAssertion(a) {
410
+ const colonIdx = a.name.indexOf(":");
411
+ return colonIdx >= 0 ? a.name.slice(colonIdx + 1) : a.name;
412
+ }
413
+ function messageForAssertion(a) {
414
+ return `${a.name} ${a.operator} ${a.expected} (actual: ${a.actual.toFixed(2)})`;
332
415
  }
333
416
  async function lighthouseAudit(ctx) {
334
417
  const spawn2 = ctx.spawn ?? defaultSpawn;
335
418
  const site = ctx.site;
336
419
  const label = siteLabel4(site);
337
- const dir = await mkdtemp(join3(tmpdir(), "reddoor-lhci-"));
338
- const configPath = join3(dir, "lighthouserc.json");
420
+ const configDir = await mkdtemp(join3(tmpdir(), "reddoor-lhci-"));
421
+ const configPath = join3(configDir, "lighthouserc.json");
339
422
  await writeFile(configPath, JSON.stringify(lighthouseConfig), "utf-8");
423
+ const resultsDir = join3(site.path, ".lighthouseci");
424
+ await rm(resultsDir, { recursive: true, force: true });
340
425
  let raw;
341
426
  try {
342
427
  raw = await spawn2("npx", ["--yes", "@lhci/cli", "autorun", `--config=${configPath}`], {
343
428
  cwd: site.path
344
429
  });
345
430
  } catch (err) {
346
- await rm(dir, { recursive: true, force: true });
431
+ await rm(configDir, { recursive: true, force: true });
347
432
  const e = err;
348
433
  if (e.code === "ENOENT" || /ENOENT/.test(String(err))) {
349
434
  return {
@@ -355,17 +440,32 @@ async function lighthouseAudit(ctx) {
355
440
  }
356
441
  throw err;
357
442
  }
358
- await rm(dir, { recursive: true, force: true });
359
- const fake = isFakeShape(raw.stdout);
360
- const normalized = fake ?? {
361
- summary: {},
362
- assertionsFailed: raw.code === 0 ? 0 : 1,
363
- assertions: raw.code === 0 ? [] : [{ category: "unknown", level: "error", message: raw.stderr.slice(0, 200) }]
364
- };
365
- const anyError = (normalized.assertions ?? []).some((a) => a.level === "error");
366
- const anyWarn = (normalized.assertions ?? []).some((a) => a.level === "warn");
443
+ await rm(configDir, { recursive: true, force: true });
444
+ const manifest = await readJsonMaybe(join3(resultsDir, "manifest.json"));
445
+ if (!manifest || manifest.length === 0) {
446
+ return {
447
+ audit: "lighthouse",
448
+ site: label,
449
+ status: "fail",
450
+ summary: `lighthouse: no manifest written (exit ${raw.code})${raw.stderr ? ` \u2014 ${raw.stderr.slice(0, 200)}` : ""}`
451
+ };
452
+ }
453
+ const assertionResults = await readJsonMaybe(join3(resultsDir, "assertion-results.json")) ?? [];
454
+ const failed = assertionResults.filter((a) => !a.passed);
455
+ const assertions = failed.map((a) => ({
456
+ category: categoryFromAssertion(a),
457
+ level: a.level,
458
+ message: messageForAssertion(a)
459
+ }));
460
+ const anyError = assertions.some((a) => a.level === "error");
461
+ const anyWarn = assertions.some((a) => a.level === "warn");
367
462
  const status = anyError ? "fail" : anyWarn ? "warn" : "pass";
368
- const summary = status === "pass" ? "lighthouse: all categories passing" : `lighthouse: ${normalized.assertionsFailed} assertion(s) failed`;
463
+ const normalized = {
464
+ summary: averageSummaries(manifest),
465
+ assertionsFailed: failed.length,
466
+ assertions
467
+ };
468
+ const summary = status === "pass" ? "lighthouse: all categories passing" : `lighthouse: ${failed.length} assertion(s) failed`;
369
469
  return {
370
470
  audit: "lighthouse",
371
471
  site: label,
@@ -376,7 +476,7 @@ async function lighthouseAudit(ctx) {
376
476
  }
377
477
 
378
478
  // src/audits/a11y.ts
379
- import { writeFile as writeFile2, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
479
+ import { readFile as readFile4, writeFile as writeFile2, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
380
480
  import { tmpdir as tmpdir2 } from "os";
381
481
  import { join as join4 } from "path";
382
482
 
@@ -412,48 +512,81 @@ var playwrightA11yConfig = defineConfig({
412
512
  });
413
513
 
414
514
  // src/audits/a11y.ts
515
+ var RESULTS_REL = ".reddoor-a11y/results.json";
415
516
  function siteLabel5(site) {
416
517
  return site.name ?? site.path;
417
518
  }
418
- function isFakeShape2(stdout) {
519
+ async function readJsonMaybe2(path) {
419
520
  try {
420
- const parsed = JSON.parse(stdout);
421
- if (typeof parsed.totalViolations === "number" && parsed.byImpact) return parsed;
521
+ const raw = await readFile4(path, "utf-8");
522
+ return JSON.parse(raw);
422
523
  } catch {
423
524
  return null;
424
525
  }
425
- return null;
426
526
  }
427
527
  function buildSpec() {
428
- return `
429
- import { test, expect } from "@playwright/test";
528
+ return `import { test, expect } from "@playwright/test";
430
529
  import AxeBuilder from "@axe-core/playwright";
530
+ import { mkdir, writeFile } from "node:fs/promises";
531
+ import { dirname } from "node:path";
532
+
431
533
  const pages = ${JSON.stringify(a11yRoutes)};
432
- for (const { path, name } of pages) {
433
- test(\`\${name} has no axe violations\`, async ({ page }) => {
534
+ const OUTPUT = process.env.REDDOOR_A11Y_OUTPUT;
535
+
536
+ // Playwright's default per-test timeout is 30s. We loop through every
537
+ // configured route in a single test, so the budget needs to scale.
538
+ test.setTimeout(5 * 60_000);
539
+
540
+ test("a11y across configured routes", async ({ page }) => {
541
+ const violations = [];
542
+ for (const { path, name } of pages) {
434
543
  await page.goto(path);
435
544
  const results = await new AxeBuilder({ page })
436
545
  .withTags(["wcag2a","wcag2aa","wcag21a","wcag21aa","wcag22aa"])
437
546
  .analyze();
438
- expect(results.violations).toEqual([]);
439
- });
440
- }
547
+ for (const v of results.violations) {
548
+ violations.push({
549
+ id: v.id,
550
+ impact: v.impact ?? "moderate",
551
+ route: name,
552
+ help: v.help,
553
+ helpUrl: v.helpUrl,
554
+ nodes: v.nodes.map((n) => ({ html: n.html, target: n.target })),
555
+ });
556
+ }
557
+ }
558
+ const byImpact = {};
559
+ for (const v of violations) {
560
+ byImpact[v.impact] = (byImpact[v.impact] ?? 0) + 1;
561
+ }
562
+ if (OUTPUT) {
563
+ await mkdir(dirname(OUTPUT), { recursive: true });
564
+ await writeFile(
565
+ OUTPUT,
566
+ JSON.stringify({ totalViolations: violations.length, byImpact, violations }, null, 2),
567
+ );
568
+ }
569
+ expect(violations).toEqual([]);
570
+ });
441
571
  `;
442
572
  }
443
573
  async function a11yAudit(ctx) {
444
574
  const spawn2 = ctx.spawn ?? defaultSpawn;
445
575
  const site = ctx.site;
446
576
  const label = siteLabel5(site);
447
- const dir = await mkdtemp2(join4(tmpdir2(), "reddoor-a11y-"));
448
- const specPath = join4(dir, "a11y.spec.ts");
577
+ const specDir = await mkdtemp2(join4(tmpdir2(), "reddoor-a11y-spec-"));
578
+ const specPath = join4(specDir, "a11y.spec.ts");
449
579
  await writeFile2(specPath, buildSpec(), "utf-8");
580
+ const resultsPath = join4(site.path, RESULTS_REL);
581
+ await rm2(join4(site.path, ".reddoor-a11y"), { recursive: true, force: true });
450
582
  let raw;
451
583
  try {
452
- raw = await spawn2("npx", ["--yes", "playwright", "test", "--reporter=json", specPath], {
453
- cwd: site.path
584
+ raw = await spawn2("npx", ["--yes", "playwright", "test", "--reporter=line", specPath], {
585
+ cwd: site.path,
586
+ env: { ...process.env, REDDOOR_A11Y_OUTPUT: resultsPath }
454
587
  });
455
588
  } catch (err) {
456
- await rm2(dir, { recursive: true, force: true });
589
+ await rm2(specDir, { recursive: true, force: true });
457
590
  const e = err;
458
591
  if (e.code === "ENOENT" || /ENOENT/.test(String(err))) {
459
592
  return {
@@ -465,22 +598,26 @@ async function a11yAudit(ctx) {
465
598
  }
466
599
  throw err;
467
600
  }
468
- await rm2(dir, { recursive: true, force: true });
469
- const fake = isFakeShape2(raw.stdout);
470
- const normalized = fake ?? {
471
- totalViolations: raw.code === 0 ? 0 : 1,
472
- byImpact: raw.code === 0 ? {} : { moderate: 1 }
473
- };
474
- const hasSerious = (normalized.byImpact.serious ?? 0) > 0 || (normalized.byImpact.critical ?? 0) > 0;
475
- const hasAny = normalized.totalViolations > 0;
601
+ await rm2(specDir, { recursive: true, force: true });
602
+ const artifact = await readJsonMaybe2(resultsPath);
603
+ if (!artifact) {
604
+ return {
605
+ audit: "a11y",
606
+ site: label,
607
+ status: "fail",
608
+ summary: `a11y: no results written (exit ${raw.code})${raw.stderr ? ` \u2014 ${raw.stderr.slice(0, 200)}` : ""}`
609
+ };
610
+ }
611
+ const hasSerious = (artifact.byImpact.serious ?? 0) > 0 || (artifact.byImpact.critical ?? 0) > 0;
612
+ const hasAny = artifact.totalViolations > 0;
476
613
  const status = hasSerious ? "fail" : hasAny ? "warn" : "pass";
477
- const summary = status === "pass" ? `a11y: 0 violations across ${a11yRoutes.length} routes` : `a11y: ${normalized.totalViolations} violations`;
614
+ const summary = status === "pass" ? `a11y: 0 violations across ${a11yRoutes.length} routes` : `a11y: ${artifact.totalViolations} violations`;
478
615
  return {
479
616
  audit: "a11y",
480
617
  site: label,
481
618
  status,
482
619
  summary,
483
- details: normalized
620
+ details: artifact
484
621
  };
485
622
  }
486
623
 
@@ -530,7 +667,8 @@ function localPath(path, opts = {}) {
530
667
  }
531
668
 
532
669
  // src/inventory/json.ts
533
- import { readFile as readFile3 } from "fs/promises";
670
+ import { readFile as readFile5 } from "fs/promises";
671
+ import { isAbsolute } from "path";
534
672
  function validate(raw) {
535
673
  if (!Array.isArray(raw)) {
536
674
  throw new Error("inventory JSON must be an array of sites");
@@ -543,6 +681,11 @@ function validate(raw) {
543
681
  if (typeof e.path !== "string" || e.path.length === 0) {
544
682
  throw new Error(`inventory entry ${i} is missing required field: path`);
545
683
  }
684
+ if (!isAbsolute(e.path)) {
685
+ throw new Error(
686
+ `inventory entry ${i}: path must be absolute (got "${e.path}"). Relative paths are rejected so cwd at invocation can't change which site is targeted.`
687
+ );
688
+ }
546
689
  const site = { path: e.path };
547
690
  if (typeof e.name === "string") site.name = e.name;
548
691
  if (typeof e.repoUrl === "string") site.repoUrl = e.repoUrl;
@@ -554,7 +697,7 @@ function validate(raw) {
554
697
  }
555
698
  function fromJsonFile(path) {
556
699
  return async () => {
557
- const raw = JSON.parse(await readFile3(path, "utf-8"));
700
+ const raw = JSON.parse(await readFile5(path, "utf-8"));
558
701
  return validate(raw);
559
702
  };
560
703
  }
@@ -593,11 +736,22 @@ async function resolveSites(input) {
593
736
 
594
737
  // src/cli/fleet/clone-if-needed.ts
595
738
  import { stat, readdir, mkdir } from "fs/promises";
596
- import { join as join5 } from "path";
739
+ import { isAbsolute as isAbsolute2, join as join5 } from "path";
597
740
  function deriveNameFromRepoUrl(repoUrl) {
598
741
  const slash = repoUrl.split("/").pop() ?? repoUrl;
599
742
  return slash.replace(/\.git$/, "");
600
743
  }
744
+ function assertSafeName(name) {
745
+ if (isAbsolute2(name)) {
746
+ throw new Error(`unsafe site name (absolute path not allowed): ${name}`);
747
+ }
748
+ if (name.includes("/") || name.includes("\\")) {
749
+ throw new Error(`unsafe site name (path separator not allowed): ${name}`);
750
+ }
751
+ if (name.split(/[\\/]/).some((seg) => seg === "..")) {
752
+ throw new Error(`unsafe site name (traversal segment not allowed): ${name}`);
753
+ }
754
+ }
601
755
  async function isNonEmptyDir(path) {
602
756
  try {
603
757
  const s = await stat(path);
@@ -614,6 +768,7 @@ async function cloneIfNeeded(site, opts) {
614
768
  throw new Error(`site path does not exist (${site.path}) and no repoUrl is set \u2014 cannot clone`);
615
769
  }
616
770
  const name = site.name ?? deriveNameFromRepoUrl(site.repoUrl);
771
+ assertSafeName(name);
617
772
  const target = join5(opts.workdir, name);
618
773
  await mkdir(opts.workdir, { recursive: true });
619
774
  if (await isNonEmptyDir(target)) {
@@ -670,11 +825,11 @@ async function runAuditCommand(site, opts) {
670
825
  }
671
826
 
672
827
  // src/cli/commands/sync-configs.ts
673
- import { readFile as readFile5 } from "fs/promises";
828
+ import { readFile as readFile7 } from "fs/promises";
674
829
  import { join as join7, resolve as resolve3 } from "path";
675
830
 
676
831
  // src/recipes/sync-configs.ts
677
- import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
832
+ import { readFile as readFile6, writeFile as writeFile3 } from "fs/promises";
678
833
  import { join as join6 } from "path";
679
834
 
680
835
  // src/recipes/sync-configs/templates.ts
@@ -759,7 +914,7 @@ function siteLabel6(site) {
759
914
  }
760
915
  async function readMaybe(path) {
761
916
  try {
762
- return await readFile4(path, "utf-8");
917
+ return await readFile6(path, "utf-8");
763
918
  } catch {
764
919
  return null;
765
920
  }
@@ -818,7 +973,7 @@ async function dryPlan(cwd, which) {
818
973
  for (const t of targets) {
819
974
  let existing = "";
820
975
  try {
821
- existing = await readFile5(join7(cwd, t.path), "utf-8");
976
+ existing = await readFile7(join7(cwd, t.path), "utf-8");
822
977
  } catch {
823
978
  }
824
979
  if (existing !== t.contents) lines.push(`would update ${t.path} (config: ${t.config})`);
@@ -877,6 +1032,7 @@ async function bumpDeps(site, opts = {}) {
877
1032
  const label = siteLabel7(site);
878
1033
  const group = opts.group ?? "minor";
879
1034
  const spawn2 = opts.spawn ?? defaultSpawn;
1035
+ await spawn2("pnpm", ["install"], { cwd: site.path, streaming: true });
880
1036
  const outdated = await spawn2("pnpm", ["outdated", "--json", ...outdatedFlagsForGroup(group)], {
881
1037
  cwd: site.path
882
1038
  });
@@ -901,7 +1057,10 @@ async function bumpDeps(site, opts = {}) {
901
1057
  }
902
1058
  const branch = branchName("bump-deps");
903
1059
  await createBranch(site.path, branch);
904
- await spawn2("pnpm", ["up", ...upFlagsForGroup(group)], { cwd: site.path });
1060
+ await spawn2("pnpm", ["up", ...upFlagsForGroup(group)], {
1061
+ cwd: site.path,
1062
+ streaming: true
1063
+ });
905
1064
  const sha = await commit(site.path, `chore(deps): bump dependencies (${group})`);
906
1065
  const shas = sha ? [sha] : [];
907
1066
  return {
@@ -952,36 +1111,38 @@ import { resolve as resolve5 } from "path";
952
1111
  import { join as join13 } from "path";
953
1112
 
954
1113
  // src/util/pkg.ts
955
- import { readFile as readFile6, writeFile as writeFile4 } from "fs/promises";
1114
+ import { readFile as readFile8, writeFile as writeFile4 } from "fs/promises";
956
1115
  async function readPackageJson(path) {
957
- const raw = await readFile6(path, "utf-8");
1116
+ const raw = await readFile8(path, "utf-8");
958
1117
  return JSON.parse(raw);
959
1118
  }
960
- async function writePackageJson(path, pkg2) {
961
- const content = JSON.stringify(pkg2, null, 2) + "\n";
1119
+ async function writePackageJson(path, pkg) {
1120
+ const content = JSON.stringify(pkg, null, 2) + "\n";
962
1121
  await writeFile4(path, content, "utf-8");
963
1122
  }
964
- function bumpDep(pkg2, name, version) {
1123
+ function bumpDep(pkg, name, version2, opts = {}) {
1124
+ const mode = opts.mode ?? "ensure";
965
1125
  const next = {
966
- ...pkg2
1126
+ ...pkg
967
1127
  };
968
- if (pkg2.dependencies) {
969
- next.dependencies = { ...pkg2.dependencies };
1128
+ if (pkg.dependencies) {
1129
+ next.dependencies = { ...pkg.dependencies };
970
1130
  }
971
- if (pkg2.devDependencies) {
972
- next.devDependencies = { ...pkg2.devDependencies };
1131
+ if (pkg.devDependencies) {
1132
+ next.devDependencies = { ...pkg.devDependencies };
973
1133
  }
974
1134
  if (next.dependencies && name in next.dependencies) {
975
- if (next.dependencies[name] === version) return pkg2;
976
- next.dependencies[name] = version;
1135
+ if (next.dependencies[name] === version2) return pkg;
1136
+ next.dependencies[name] = version2;
977
1137
  return next;
978
1138
  }
979
1139
  if (next.devDependencies && name in next.devDependencies) {
980
- if (next.devDependencies[name] === version) return pkg2;
981
- next.devDependencies[name] = version;
1140
+ if (next.devDependencies[name] === version2) return pkg;
1141
+ next.devDependencies[name] = version2;
982
1142
  return next;
983
1143
  }
984
- next.devDependencies = { ...next.devDependencies ?? {}, [name]: version };
1144
+ if (mode === "bump-only") return pkg;
1145
+ next.devDependencies = { ...next.devDependencies ?? {}, [name]: version2 };
985
1146
  return next;
986
1147
  }
987
1148
 
@@ -1000,33 +1161,69 @@ var SVELTE_5_VERSIONS = {
1000
1161
  };
1001
1162
  async function bumpToSvelte5Versions(cwd) {
1002
1163
  const pkgPath = join8(cwd, "package.json");
1003
- const pkg2 = await readPackageJson(pkgPath);
1004
- let next = pkg2;
1005
- for (const [name, version] of Object.entries(SVELTE_5_VERSIONS)) {
1006
- next = bumpDep(next, name, version);
1164
+ const pkg = await readPackageJson(pkgPath);
1165
+ let next = pkg;
1166
+ for (const [name, version2] of Object.entries(SVELTE_5_VERSIONS)) {
1167
+ next = bumpDep(next, name, version2, { mode: "bump-only" });
1007
1168
  }
1008
- if (next === pkg2) return false;
1169
+ if (next === pkg) return false;
1009
1170
  await writePackageJson(pkgPath, next);
1010
1171
  return true;
1011
1172
  }
1012
1173
 
1013
1174
  // src/recipes/svelte-5/step-svelte-config.ts
1014
- import { readFile as readFile7, writeFile as writeFile5 } from "fs/promises";
1175
+ import { readFile as readFile9, writeFile as writeFile5 } from "fs/promises";
1015
1176
  import { join as join9 } from "path";
1177
+ var VITE_PLUGIN_PKG = "@sveltejs/vite-plugin-svelte";
1178
+ var IMPORT_FROM_VITE_PLUGIN = new RegExp(
1179
+ String.raw`^import\s+\{\s*([^}]+?)\s*\}\s+from\s+["']` + VITE_PLUGIN_PKG.replace(/[/]/g, "\\/") + String.raw`["'];?[ \t]*\n`,
1180
+ "m"
1181
+ );
1182
+ function dropVitePreprocessImport(source) {
1183
+ return source.replace(IMPORT_FROM_VITE_PLUGIN, (full, names) => {
1184
+ const remaining = names.split(",").map((n) => n.trim()).filter((n) => n.length > 0 && n !== "vitePreprocess");
1185
+ if (remaining.length === 0) return "";
1186
+ return `import { ${remaining.join(", ")} } from "${VITE_PLUGIN_PKG}";
1187
+ `;
1188
+ });
1189
+ }
1190
+ function findMatchingParen(source, openIdx) {
1191
+ if (source[openIdx] !== "(") return -1;
1192
+ let depth = 0;
1193
+ for (let i = openIdx; i < source.length; i++) {
1194
+ const ch = source[i];
1195
+ if (ch === "(") depth++;
1196
+ else if (ch === ")") {
1197
+ depth--;
1198
+ if (depth === 0) return i;
1199
+ }
1200
+ }
1201
+ return -1;
1202
+ }
1203
+ function dropPreprocessKey(source) {
1204
+ const startRe = /^(\s*)preprocess:\s*vitePreprocess\(/m;
1205
+ const m = startRe.exec(source);
1206
+ if (!m) return source;
1207
+ const indent = m[1] ?? "";
1208
+ const parenOpenAbs = m.index + m[0].length - 1;
1209
+ const parenCloseAbs = findMatchingParen(source, parenOpenAbs);
1210
+ if (parenCloseAbs < 0) return source;
1211
+ let tailIdx = parenCloseAbs + 1;
1212
+ while (tailIdx < source.length && /[ \t,]/.test(source[tailIdx] ?? "")) tailIdx++;
1213
+ if (source[tailIdx] === "\n") tailIdx++;
1214
+ return source.slice(0, m.index) + source.slice(tailIdx).replace(new RegExp(`^${indent}\\n`), "");
1215
+ }
1016
1216
  async function migrateSvelteConfig(cwd) {
1017
1217
  const path = join9(cwd, "svelte.config.js");
1018
1218
  let src;
1019
1219
  try {
1020
- src = await readFile7(path, "utf-8");
1220
+ src = await readFile9(path, "utf-8");
1021
1221
  } catch {
1022
1222
  return false;
1023
1223
  }
1024
1224
  let next = src;
1025
- next = next.replace(
1026
- /^import\s+\{\s*vitePreprocess\s*\}\s+from\s+["']@sveltejs\/vite-plugin-svelte["'];\n/m,
1027
- ""
1028
- );
1029
- next = next.replace(/^\s*preprocess:\s*vitePreprocess\(\)\s*,?\s*\n/m, "");
1225
+ next = dropPreprocessKey(next);
1226
+ next = dropVitePreprocessImport(next);
1030
1227
  if (next === src) return false;
1031
1228
  await writeFile5(path, next, "utf-8");
1032
1229
  return true;
@@ -1056,8 +1253,8 @@ async function runSvelteMigrate(cwd, spawn2 = defaultSpawn) {
1056
1253
  // src/recipes/svelte-5/step-tailwind-upgrade.ts
1057
1254
  import { join as join10 } from "path";
1058
1255
  async function upgradeTailwind(cwd, spawn2 = defaultSpawn) {
1059
- const pkg2 = await readPackageJson(join10(cwd, "package.json"));
1060
- const tailwindVersion = pkg2.devDependencies?.tailwindcss ?? pkg2.dependencies?.tailwindcss;
1256
+ const pkg = await readPackageJson(join10(cwd, "package.json"));
1257
+ const tailwindVersion = pkg.devDependencies?.tailwindcss ?? pkg.dependencies?.tailwindcss;
1061
1258
  if (!tailwindVersion) return { ran: false, reason: "tailwindcss not installed" };
1062
1259
  if (/^\^?4\./.test(tailwindVersion)) return { ran: false, reason: "already on tailwind 4.x" };
1063
1260
  try {
@@ -1077,7 +1274,7 @@ async function upgradeTailwind(cwd, spawn2 = defaultSpawn) {
1077
1274
  }
1078
1275
 
1079
1276
  // src/recipes/svelte-5/step-gotchas.ts
1080
- import { readFile as readFile8, writeFile as writeFile6 } from "fs/promises";
1277
+ import { readFile as readFile10, writeFile as writeFile6 } from "fs/promises";
1081
1278
  import { join as join11 } from "path";
1082
1279
  import { glob as glob2 } from "tinyglobby";
1083
1280
 
@@ -1136,10 +1333,32 @@ function exportLetToProps(source) {
1136
1333
  }
1137
1334
 
1138
1335
  // src/recipes/svelte-5/codemods/dollar-restprops.ts
1336
+ function removeInterfaceBlock(source) {
1337
+ const re = /^\s*interface\s+\$\$Props\s*\{/m;
1338
+ let out = source;
1339
+ while (true) {
1340
+ const match = re.exec(out);
1341
+ if (!match) return out;
1342
+ const openBraceIdx = match.index + match[0].length - 1;
1343
+ let depth = 1;
1344
+ let i = openBraceIdx + 1;
1345
+ while (i < out.length && depth > 0) {
1346
+ const ch = out[i];
1347
+ if (ch === "{") depth++;
1348
+ else if (ch === "}") depth--;
1349
+ i++;
1350
+ }
1351
+ if (depth !== 0) return out;
1352
+ let endIdx = i;
1353
+ while (endIdx < out.length && /[ \t]/.test(out[endIdx] ?? "")) endIdx++;
1354
+ if (out[endIdx] === "\n") endIdx++;
1355
+ out = out.slice(0, match.index) + out.slice(endIdx);
1356
+ }
1357
+ }
1139
1358
  function removeDollarRestProps(source) {
1140
1359
  let next = source;
1141
1360
  next = next.replace(/\$\$restProps/g, "rest");
1142
- next = next.replace(/^\s*interface\s+\$\$Props\s*\{[^}]*\}\s*\n/gm, "");
1361
+ next = removeInterfaceBlock(next);
1143
1362
  return next;
1144
1363
  }
1145
1364
 
@@ -1152,7 +1371,7 @@ async function applyGotchaCodemods(cwd) {
1152
1371
  const relPaths = await glob2(SVELTE_GLOBS, { cwd, ignore: IGNORE2, absolute: false });
1153
1372
  for (const rel of relPaths) {
1154
1373
  const path = join11(cwd, rel);
1155
- const before = await readFile8(path, "utf-8");
1374
+ const before = await readFile10(path, "utf-8");
1156
1375
  const after = CODEMODS.reduce((s, fn) => fn(s), before);
1157
1376
  if (after !== before) {
1158
1377
  await writeFile6(path, after, "utf-8");
@@ -1209,8 +1428,8 @@ function siteLabel8(site) {
1209
1428
  }
1210
1429
  async function alreadyOnSvelte5(cwd) {
1211
1430
  try {
1212
- const pkg2 = await readPackageJson(join13(cwd, "package.json"));
1213
- const v = pkg2.devDependencies?.svelte ?? pkg2.dependencies?.svelte;
1431
+ const pkg = await readPackageJson(join13(cwd, "package.json"));
1432
+ const v = pkg.devDependencies?.svelte ?? pkg.dependencies?.svelte;
1214
1433
  return !!v && /^\^?5\./.test(v);
1215
1434
  } catch {
1216
1435
  return false;
@@ -1322,9 +1541,22 @@ async function runUpgradeCommand(upgradeName, site, opts = {}) {
1322
1541
  return { output, code };
1323
1542
  }
1324
1543
 
1544
+ // src/cli/version.ts
1545
+ import { readFileSync } from "fs";
1546
+ import { join as join14 } from "path";
1547
+ function resolvePackageVersion(fromDir) {
1548
+ try {
1549
+ const raw = readFileSync(join14(fromDir, "..", "..", "package.json"), "utf-8");
1550
+ const pkg = JSON.parse(raw);
1551
+ return pkg.version ?? "unknown";
1552
+ } catch {
1553
+ return "unknown";
1554
+ }
1555
+ }
1556
+
1325
1557
  // src/cli/bin.ts
1326
1558
  var here = dirname(fileURLToPath(import.meta.url));
1327
- var pkg = JSON.parse(readFileSync(join14(here, "../../package.json"), "utf-8"));
1559
+ var version = resolvePackageVersion(here);
1328
1560
  var AUDIT_DESCRIPTIONS = {
1329
1561
  deps: "Diff site package.json against the bundled baseline version map.",
1330
1562
  lighthouse: "Run @lhci/cli autorun using the canonical lighthouserc.",
@@ -1403,6 +1635,6 @@ cli.command("upgrade <upgrade> [site]", "Run a named upgrade recipe (svelte-4-to
1403
1635
  }
1404
1636
  );
1405
1637
  cli.help();
1406
- cli.version(pkg.version);
1638
+ cli.version(version);
1407
1639
  cli.parse();
1408
1640
  //# sourceMappingURL=bin.js.map