saasaloy 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +8 -7
  2. package/dist/index.js +639 -522
  3. package/dist/index.js.map +1 -1
  4. package/package.json +3 -1
  5. package/schemas/registry-item.schema.json +31 -1
  6. package/templates/base/AGENTS.md +180 -53
  7. package/templates/base/README.md +20 -8
  8. package/templates/base/_agents/skills/saasaloy-design/SKILL.md +26 -15
  9. package/templates/base/_agents/skills/saasaloy-env/SKILL.md +130 -0
  10. package/templates/base/_agents/skills/saasaloy-landing-copy/SKILL.md +63 -46
  11. package/templates/base/_agents/skills/saasaloy-setup/SKILL.md +1 -1
  12. package/templates/base/_gitignore +0 -3
  13. package/templates/base/_saasaloy-base.json +8 -1
  14. package/templates/base/{DESIGN.md → apps/web/DESIGN.md} +36 -12
  15. package/templates/base/apps/web/astro.config.mjs +54 -3
  16. package/templates/base/apps/web/package.json +13 -11
  17. package/templates/base/apps/web/src/env.ts +41 -0
  18. package/templates/base/apps/web/src/layouts/Layout.astro +39 -11
  19. package/templates/base/apps/web/src/pages/index.astro +33 -24
  20. package/templates/base/apps/web/src/pages/privacy.astro +10 -2
  21. package/templates/base/apps/web/src/pages/terms.astro +10 -2
  22. package/templates/base/apps/web/wrangler.jsonc +7 -0
  23. package/templates/base/oxlint.config.mjs +91 -4
  24. package/templates/base/package.json +15 -11
  25. package/templates/base/packages/env/_env.example +54 -0
  26. package/templates/base/packages/env/package.json +35 -0
  27. package/templates/base/packages/env/scripts/env-setup.ts +87 -0
  28. package/templates/base/packages/env/src/check.test.ts +41 -0
  29. package/templates/base/packages/env/src/check.ts +50 -0
  30. package/templates/base/packages/env/src/define.test.ts +206 -0
  31. package/templates/base/packages/env/src/define.ts +315 -0
  32. package/templates/base/packages/env/src/deploy.test.ts +438 -0
  33. package/templates/base/packages/env/src/deploy.ts +518 -0
  34. package/templates/base/packages/env/src/dotenv.test.ts +179 -0
  35. package/templates/base/packages/env/src/dotenv.ts +173 -0
  36. package/templates/base/packages/env/src/env-file.ts +51 -0
  37. package/templates/base/packages/env/src/error.ts +38 -0
  38. package/templates/base/packages/env/src/example.test.ts +86 -0
  39. package/templates/base/packages/env/src/example.ts +90 -0
  40. package/templates/base/packages/env/src/index.ts +15 -0
  41. package/templates/base/packages/env/src/schema.ts +63 -0
  42. package/templates/base/packages/env/src/services.test.ts +83 -0
  43. package/templates/base/packages/env/src/services.ts +110 -0
  44. package/templates/base/packages/env/src/setup.test.ts +254 -0
  45. package/templates/base/packages/env/src/setup.ts +177 -0
  46. package/templates/base/packages/env/src/source.ts +20 -0
  47. package/templates/base/packages/env/src/sources.test.ts +137 -0
  48. package/templates/base/packages/env/src/sources.ts +93 -0
  49. package/templates/base/packages/env/src/zod.ts +8 -0
  50. package/templates/base/packages/env/tsconfig.json +13 -0
  51. package/templates/base/packages/tsconfig/base.json +7 -0
  52. package/templates/base/packages/ui/components.json +1 -1
  53. package/templates/base/packages/ui/package.json +5 -4
  54. package/templates/base/packages/ui/src/blocks/audience.tsx +73 -0
  55. package/templates/base/packages/ui/src/blocks/cta.tsx +41 -30
  56. package/templates/base/packages/ui/src/blocks/error-state.tsx +2 -0
  57. package/templates/base/packages/ui/src/blocks/faq.tsx +64 -36
  58. package/templates/base/packages/ui/src/blocks/feature-grid.tsx +48 -146
  59. package/templates/base/packages/ui/src/blocks/footer.tsx +129 -70
  60. package/templates/base/packages/ui/src/blocks/hero.tsx +183 -26
  61. package/templates/base/packages/ui/src/blocks/how-it-works.tsx +75 -0
  62. package/templates/base/packages/ui/src/blocks/logo-cloud.tsx +118 -0
  63. package/templates/base/packages/ui/src/blocks/navbar.tsx +204 -88
  64. package/templates/base/packages/ui/src/blocks/pain-points.tsx +131 -0
  65. package/templates/base/packages/ui/src/blocks/pricing-table.tsx +112 -94
  66. package/templates/base/packages/ui/src/blocks/showcase.tsx +185 -0
  67. package/templates/base/packages/ui/src/blocks/testimonials.tsx +85 -0
  68. package/templates/base/packages/ui/src/blocks/theme-toggle.tsx +16 -12
  69. package/templates/base/packages/ui/src/components/badge.tsx +1 -1
  70. package/templates/base/packages/ui/src/components/button.tsx +2 -2
  71. package/templates/base/packages/ui/src/components/tabs.tsx +1 -1
  72. package/templates/base/packages/ui/src/content/landing.ts +590 -125
  73. package/templates/base/packages/ui/src/index.ts +6 -2
  74. package/templates/base/packages/ui/src/lib/external-link.ts +36 -0
  75. package/templates/base/packages/ui/src/lib/icons.ts +131 -0
  76. package/templates/base/packages/ui/src/lib/inline-code.ts +29 -0
  77. package/templates/base/packages/ui/src/lib/theme.ts +173 -86
  78. package/templates/base/packages/ui/src/styles/globals.css +47 -125
  79. package/templates/base/packages/ui/src/styles/web.css +359 -0
  80. package/templates/base/pnpm-workspace.yaml +0 -6
  81. package/templates/base/saasaloy.json +1 -0
  82. package/templates/base/stylelint.config.js +3 -3
  83. package/templates/base/turbo.json +13 -0
  84. package/templates/base/vitest.config.ts +21 -0
package/dist/index.js CHANGED
@@ -14,17 +14,17 @@ import {
14
14
  confirm,
15
15
  intro,
16
16
  isCancel,
17
- log,
17
+ log as log2,
18
18
  note,
19
19
  outro,
20
20
  select
21
21
  } from "@clack/prompts";
22
- import { posix as posix4 } from "path";
22
+ import { posix as posix3 } from "path";
23
23
  import pc2 from "picocolors";
24
24
 
25
25
  // src/lib/applier.ts
26
- import { mkdir as mkdir3, readFile as readFile5, writeFile as writeFile3 } from "fs/promises";
27
- import { dirname as dirname3, posix as posix2 } from "path";
26
+ import { mkdir as mkdir4, readFile as readFile6, writeFile as writeFile4 } from "fs/promises";
27
+ import { dirname as dirname4, posix as posix2 } from "path";
28
28
 
29
29
  // src/lib/exit.ts
30
30
  var EXIT_OK = 0;
@@ -152,6 +152,10 @@ function formatOwnedCollisions(collisions, requested = "these modules") {
152
152
  );
153
153
  }
154
154
 
155
+ // src/lib/env-example.ts
156
+ import { mkdir as mkdir2, readFile as readFile2, writeFile } from "fs/promises";
157
+ import { dirname as dirname2 } from "path";
158
+
155
159
  // src/lib/fs-utils.ts
156
160
  import { createHash } from "crypto";
157
161
  import {
@@ -274,20 +278,145 @@ async function classifyLink(linkAbs, targetAbs) {
274
278
  return "conflict";
275
279
  }
276
280
  const dest = await readlink(linkAbs);
277
- const resolved2 = resolve(dirname(linkAbs), dest);
278
- return resolved2 === resolve(targetAbs) ? "correct" : "conflict";
281
+ const resolved = resolve(dirname(linkAbs), dest);
282
+ return resolved === resolve(targetAbs) ? "correct" : "conflict";
279
283
  }
280
284
  async function createDirLink(linkAbs, targetAbs) {
281
285
  await mkdir(dirname(linkAbs), { recursive: true });
282
286
  await (process.platform === "win32" ? symlink(resolve(targetAbs), linkAbs, "junction") : symlink(relative(dirname(linkAbs), targetAbs), linkAbs, "dir"));
283
287
  }
284
288
 
289
+ // src/lib/env-example.ts
290
+ var ENV_EXAMPLE = "packages/env/.env.example";
291
+ var PUBLIC_PREFIX = "PUBLIC_";
292
+ var SERVICE_NAMES = ["api", "web", "admin", "deploy"];
293
+ var CLIENT_SERVICES = ["web", "admin"];
294
+ function isPublicVar(name) {
295
+ return name.startsWith(PUBLIC_PREFIX);
296
+ }
297
+ var HEADER = [
298
+ "# The one key list for this repository. Every `.env` that `pnpm env:setup` writes is",
299
+ "# written from this file. There is no second example file, and no `.dev.vars.example`.",
300
+ "#",
301
+ "# Generated by `saasaloy add` from the installed modules' `envVars`. A value you type",
302
+ "# here survives that, and nothing is ever removed \u2014 `saasaloy remove` owns deletions.",
303
+ "#",
304
+ "# `.env` is gitignored; this file is tracked. Keep real secrets out of it.",
305
+ "#",
306
+ "# A `# @services <names>` line sets which services take every key below it, until the",
307
+ "# next such line. A key with no section above it fails `pnpm env:setup`."
308
+ ];
309
+ function servicesFor(name, envServices) {
310
+ const declared = envServices?.[name] ?? envServices?.default;
311
+ if (declared && declared.length > 0) {
312
+ return SERVICE_NAMES.filter((service) => declared.includes(service));
313
+ }
314
+ return isPublicVar(name) ? [...CLIENT_SERVICES] : ["api"];
315
+ }
316
+ var SECTION = /^\s*#\s*@services\b(.*)$/;
317
+ function parseEnvExample(text4) {
318
+ const entries = {};
319
+ let comment = [];
320
+ let services;
321
+ for (const line of text4.split("\n")) {
322
+ const trimmed = line.trim();
323
+ const section = SECTION.exec(trimmed);
324
+ if (section) {
325
+ const named = new Set((section[1] ?? "").split(/[\s,]+/).filter(Boolean));
326
+ services = SERVICE_NAMES.filter((name) => named.has(name));
327
+ comment = [];
328
+ continue;
329
+ }
330
+ if (trimmed.startsWith("#")) {
331
+ comment.push(trimmed);
332
+ continue;
333
+ }
334
+ if (trimmed === "") {
335
+ comment = [];
336
+ continue;
337
+ }
338
+ const eq = trimmed.indexOf("=");
339
+ if (eq <= 0) {
340
+ comment = [];
341
+ continue;
342
+ }
343
+ entries[trimmed.slice(0, eq).trim()] = {
344
+ value: trimmed.slice(eq + 1),
345
+ comment,
346
+ services: services && services.length > 0 ? services : void 0
347
+ };
348
+ comment = [];
349
+ }
350
+ return entries;
351
+ }
352
+ function sectionKey(services) {
353
+ return SERVICE_NAMES.filter((name) => services.includes(name)).join(" ");
354
+ }
355
+ var BLANK_ON_PURPOSE = "# Blank on purpose: this key may stay empty.";
356
+ function renderEnvExample(args) {
357
+ const { envVars, devVars, existing } = args;
358
+ const optional = new Set(args.optional);
359
+ const names = [
360
+ .../* @__PURE__ */ new Set([...Object.keys(envVars), ...Object.keys(existing)])
361
+ ].toSorted();
362
+ const bySection = /* @__PURE__ */ new Map();
363
+ for (const name of names) {
364
+ const declared = args.services[name];
365
+ const kept = existing[name]?.services;
366
+ const section = sectionKey(
367
+ declared || kept ? [...kept ?? [], ...declared ?? []] : servicesFor(name)
368
+ );
369
+ bySection.set(section, [...bySection.get(section) ?? [], name]);
370
+ }
371
+ const blocks = [];
372
+ for (const section of [...bySection.keys()].toSorted()) {
373
+ blocks.push(`# @services ${section}`);
374
+ for (const name of bySection.get(section) ?? []) {
375
+ const entry = existing[name];
376
+ const value = entry?.value ?? devVars[name] ?? "";
377
+ const description = envVars[name];
378
+ const kept = entry?.comment ?? [];
379
+ const lines = description ? description.split("\n").map((line) => `# ${line}`) : kept.length > 0 ? [...kept] : ["# Declared outside saasaloy \u2014 no module describes this one."];
380
+ if (optional.has(name)) {
381
+ lines.unshift(BLANK_ON_PURPOSE);
382
+ }
383
+ lines.push(`${name}=${value}`);
384
+ blocks.push(lines.join("\n"));
385
+ }
386
+ }
387
+ return `${[HEADER.join("\n"), ...blocks].join("\n\n")}
388
+ `;
389
+ }
390
+ async function writeEnvExample(args) {
391
+ const { root, envVars, devVars, services, optional } = args;
392
+ const abs = resolveWithinRoot(root, ENV_EXAMPLE);
393
+ await assertNoSymlinkPath(root, abs);
394
+ const before = await pathExists(abs) ? await readFile2(abs, "utf-8") : "";
395
+ const existing = parseEnvExample(before);
396
+ if (Object.keys(envVars).length === 0 && Object.keys(existing).length === 0) {
397
+ return void 0;
398
+ }
399
+ const content = renderEnvExample({
400
+ devVars,
401
+ envVars,
402
+ existing,
403
+ optional,
404
+ services
405
+ });
406
+ if (before === content) {
407
+ return void 0;
408
+ }
409
+ await mkdir2(dirname2(abs), { recursive: true });
410
+ await writeFile(abs, content, "utf-8");
411
+ return ENV_EXAMPLE;
412
+ }
413
+
285
414
  // src/lib/manifest.ts
286
- import { mkdir as mkdir2, readFile as readFile3, writeFile } from "fs/promises";
287
- import { dirname as dirname2, join as join3 } from "path";
415
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
416
+ import { dirname as dirname3, join as join3 } from "path";
288
417
 
289
418
  // src/lib/schema.ts
290
- import { readFile as readFile2 } from "fs/promises";
419
+ import { readFile as readFile3 } from "fs/promises";
291
420
  import { join as join2 } from "path";
292
421
  import { fileURLToPath } from "url";
293
422
  import AjvDefault from "ajv/dist/2020.js";
@@ -320,7 +449,7 @@ async function getValidator(name) {
320
449
  return cached;
321
450
  }
322
451
  const schema = JSON.parse(
323
- await readFile2(join2(await schemaDir(), SCHEMA_FILES[name]), "utf-8")
452
+ await readFile3(join2(await schemaDir(), SCHEMA_FILES[name]), "utf-8")
324
453
  );
325
454
  const validator = ajv.compile(schema);
326
455
  validators.set(name, validator);
@@ -405,7 +534,7 @@ async function loadManifest(root) {
405
534
  }
406
535
  let raw;
407
536
  try {
408
- raw = JSON.parse(await readFile3(file, "utf-8"));
537
+ raw = JSON.parse(await readFile4(file, "utf-8"));
409
538
  } catch (error) {
410
539
  throw new RefusalError(
411
540
  `${MANIFEST_FILE} is invalid:
@@ -430,8 +559,8 @@ async function loadManifest(root) {
430
559
  }
431
560
  async function saveManifest(root, manifest) {
432
561
  const file = join3(root, MANIFEST_FILE);
433
- await mkdir2(dirname2(file), { recursive: true });
434
- await writeFile(file, `${JSON.stringify(manifest, null, 2)}
562
+ await mkdir3(dirname3(file), { recursive: true });
563
+ await writeFile2(file, `${JSON.stringify(manifest, null, 2)}
435
564
  `, "utf-8");
436
565
  }
437
566
 
@@ -1828,7 +1957,7 @@ function refusalReason(table, source, patch) {
1828
1957
  }
1829
1958
 
1830
1959
  // src/lib/saasaloy-config.ts
1831
- import { readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
1960
+ import { readFile as readFile5, writeFile as writeFile3 } from "fs/promises";
1832
1961
  import { join as join4 } from "path";
1833
1962
  var CONFIG_FILE = "saasaloy.json";
1834
1963
  async function loadConfig(root) {
@@ -1838,7 +1967,7 @@ async function loadConfig(root) {
1838
1967
  `No ${CONFIG_FILE} found in ${root}. Run \`saasaloy init\` first, or cd into a Saasaloy project.`
1839
1968
  );
1840
1969
  }
1841
- const parsed = JSON.parse(await readFile4(file, "utf-8"));
1970
+ const parsed = JSON.parse(await readFile5(file, "utf-8"));
1842
1971
  const result = await validateSaasaloyConfig(parsed);
1843
1972
  if (!result.valid) {
1844
1973
  throw new RefusalError(
@@ -1861,7 +1990,7 @@ function migrateBase(config) {
1861
1990
  }
1862
1991
  async function saveConfig(root, config) {
1863
1992
  const file = join4(root, CONFIG_FILE);
1864
- await writeFile2(file, `${JSON.stringify(config, null, 2)}
1993
+ await writeFile3(file, `${JSON.stringify(config, null, 2)}
1865
1994
  `, "utf-8");
1866
1995
  }
1867
1996
  function resolveTarget(aliases, target) {
@@ -1881,6 +2010,9 @@ function resolveTarget(aliases, target) {
1881
2010
  );
1882
2011
  }
1883
2012
  const trimmed = base.endsWith("/") ? base.slice(0, -1) : base;
2013
+ if (trimmed === "." || trimmed === "") {
2014
+ return rest;
2015
+ }
1884
2016
  return `${trimmed}/${rest}`;
1885
2017
  }
1886
2018
 
@@ -2004,7 +2136,7 @@ async function classify(targetAbs, target, newHash, manifest, module) {
2004
2136
  const stale = manifest.managed[target];
2005
2137
  return stale && stale.module !== module ? { action: "create", staleOwner: stale.module } : { action: "create" };
2006
2138
  }
2007
- const oldContent = await readFile5(targetAbs, "utf-8");
2139
+ const oldContent = await readFile6(targetAbs, "utf-8");
2008
2140
  const oldHash = hashContent(oldContent);
2009
2141
  const managed = manifest.managed[target];
2010
2142
  const ownedBy = managed && managed.module !== module ? { ownedBy: managed.module } : {};
@@ -2021,7 +2153,7 @@ async function classify(targetAbs, target, newHash, manifest, module) {
2021
2153
  return { action: "conflict", oldContent };
2022
2154
  }
2023
2155
  async function planModuleFile(module, ref, root, manifest) {
2024
- const content = await readFile5(ref.abs, "utf-8");
2156
+ const content = await readFile6(ref.abs, "utf-8");
2025
2157
  const newHash = hashContent(content);
2026
2158
  const targetAbs = resolveWithinRoot(root, ref.target);
2027
2159
  const { action, oldContent, ownedBy, staleOwner } = await classify(
@@ -2067,6 +2199,9 @@ async function buildPlan(args) {
2067
2199
  const devDependencies = [];
2068
2200
  const envVars = {};
2069
2201
  const devVars = {};
2202
+ const envServices = {};
2203
+ const envServicesGuessed = /* @__PURE__ */ new Set();
2204
+ const envOptional = /* @__PURE__ */ new Set();
2070
2205
  const removeWarnings = {};
2071
2206
  const aliases = {};
2072
2207
  for (const name of install) {
@@ -2164,8 +2299,20 @@ async function buildPlan(args) {
2164
2299
  for (const [key, value] of Object.entries(item.devVars ?? {})) {
2165
2300
  devVars[key] = value;
2166
2301
  }
2302
+ for (const key of item.envOptional ?? []) {
2303
+ envOptional.add(key);
2304
+ }
2167
2305
  for (const [key, value] of Object.entries(item.envVars ?? {})) {
2168
2306
  envVars[key] = value;
2307
+ if (!item.envServices) {
2308
+ envServicesGuessed.add(name);
2309
+ }
2310
+ envServices[key] = [
2311
+ .../* @__PURE__ */ new Set([
2312
+ ...envServices[key] ?? [],
2313
+ ...servicesFor(key, item.envServices)
2314
+ ])
2315
+ ];
2169
2316
  }
2170
2317
  if (item.removeWarnings && item.removeWarnings.length > 0) {
2171
2318
  removeWarnings[name] = [...item.removeWarnings];
@@ -2212,6 +2359,9 @@ async function buildPlan(args) {
2212
2359
  dependencies,
2213
2360
  devDependencies,
2214
2361
  devVars,
2362
+ envOptional: [...envOptional].toSorted(),
2363
+ envServices,
2364
+ envServicesGuessed: [...envServicesGuessed].toSorted(),
2215
2365
  envVars,
2216
2366
  aliases,
2217
2367
  aliasConflicts,
@@ -2244,8 +2394,8 @@ async function executePlan(plan, root, config, manifest) {
2244
2394
  }
2245
2395
  if (WRITABLE.has(file.action)) {
2246
2396
  await assertNoSymlinkPath(root, file.targetAbs);
2247
- await mkdir3(dirname3(file.targetAbs), { recursive: true });
2248
- await writeFile3(file.targetAbs, file.content, "utf-8");
2397
+ await mkdir4(dirname4(file.targetAbs), { recursive: true });
2398
+ await writeFile4(file.targetAbs, file.content, "utf-8");
2249
2399
  written.push(file);
2250
2400
  } else {
2251
2401
  refreshed.push(file);
@@ -2263,10 +2413,10 @@ async function executePlan(plan, root, config, manifest) {
2263
2413
  continue;
2264
2414
  }
2265
2415
  await assertNoSymlinkPath(root, p.fileAbs);
2266
- const source = await readFile5(p.fileAbs, "utf-8");
2416
+ const source = await readFile6(p.fileAbs, "utf-8");
2267
2417
  const { content, changed, reason } = applyPatch(source, p.patch, p.file);
2268
2418
  if (changed) {
2269
- await writeFile3(p.fileAbs, content, "utf-8");
2419
+ await writeFile4(p.fileAbs, content, "utf-8");
2270
2420
  patched.push(p);
2271
2421
  recordPatch(manifest, {
2272
2422
  file: p.file,
@@ -2652,6 +2802,39 @@ function formatCliMismatches(mismatches, requested, cliVersion) {
2652
2802
  `Nothing was written.`
2653
2803
  ].join("\n");
2654
2804
  }
2805
+ function describeInvalidDescriptor(args) {
2806
+ const { name, raw, errors, cliVersion } = args;
2807
+ const range = rawRequiresRange(raw);
2808
+ if (range !== void 0 && isValidRange(range)) {
2809
+ if (cliVersion === UNKNOWN_VERSION) {
2810
+ return `Module "${name}" needs saasaloy ${range}, and this CLI's own version could not be read. Upgrade with \`${UPGRADE_COMMAND}\`.`;
2811
+ }
2812
+ if (!satisfies(cliVersion, range)) {
2813
+ return `Module "${name}" needs saasaloy ${range}, and ${cliVersion} is installed. Upgrade with \`${UPGRADE_COMMAND}\`.`;
2814
+ }
2815
+ }
2816
+ const lines = [
2817
+ `Module "${name}" has an invalid descriptor:`,
2818
+ ...errors.map((e) => ` ${e}`)
2819
+ ];
2820
+ if (errors.some((e) => e.includes("unexpected property"))) {
2821
+ lines.push(
2822
+ `It may use a field newer than saasaloy ${cliVersion}. Upgrade with \`${UPGRADE_COMMAND}\` and retry.`
2823
+ );
2824
+ }
2825
+ return lines.join("\n");
2826
+ }
2827
+ function rawRequiresRange(raw) {
2828
+ if (typeof raw !== "object" || raw === null) {
2829
+ return void 0;
2830
+ }
2831
+ const { requires } = raw;
2832
+ if (typeof requires !== "object" || requires === null) {
2833
+ return void 0;
2834
+ }
2835
+ const { saasaloy } = requires;
2836
+ return typeof saasaloy === "string" ? saasaloy : void 0;
2837
+ }
2655
2838
 
2656
2839
  // src/lib/conflicts.ts
2657
2840
  function pairKey(a, b) {
@@ -2740,81 +2923,88 @@ function planWritesUi(plan) {
2740
2923
  );
2741
2924
  }
2742
2925
 
2743
- // src/lib/dev-vars.ts
2744
- import { mkdir as mkdir4, readFile as readFile6, writeFile as writeFile4 } from "fs/promises";
2745
- import { dirname as dirname4, posix as posix3 } from "path";
2746
- var DEV_VARS_EXAMPLE = ".dev.vars.example";
2747
- var API_ALIAS = "@api";
2748
- function apiWorkspace(aliases) {
2749
- const src = aliases[API_ALIAS];
2750
- if (!src) {
2751
- return void 0;
2926
+ // src/lib/format.ts
2927
+ import { spawn } from "child_process";
2928
+ import { join as join5 } from "path";
2929
+ import { log } from "@clack/prompts";
2930
+ function prettierBin(root) {
2931
+ const name = process.platform === "win32" ? "prettier.cmd" : "prettier";
2932
+ return join5(root, "node_modules", ".bin", name);
2933
+ }
2934
+ async function formatFiles(root, files, run2 = runChild) {
2935
+ const unique = [...new Set(files)];
2936
+ if (unique.length === 0) {
2937
+ return { kind: "nothing" };
2938
+ }
2939
+ const bin = prettierBin(root);
2940
+ if (!await pathExists(bin)) {
2941
+ return { files: unique, kind: "no-prettier" };
2942
+ }
2943
+ const result = await run2(
2944
+ bin,
2945
+ ["--write", "--ignore-unknown", "--log-level", "warn", "--", ...unique],
2946
+ root
2947
+ );
2948
+ if (!result.ok) {
2949
+ return {
2950
+ files: unique,
2951
+ kind: "failed",
2952
+ message: result.message ?? "prettier failed"
2953
+ };
2752
2954
  }
2753
- const workspace = posix3.dirname(src.replace(/\/+$/, ""));
2754
- return workspace === "." || workspace === "/" ? void 0 : workspace;
2755
- }
2756
- var HEADER = [
2757
- "# Environment variables this project's modules declare.",
2758
- "# Generated by `saasaloy add`, one entry per declared variable, comments and all.",
2759
- "#",
2760
- "# Copy this file to `.dev.vars` and fill in the blanks: `.dev.vars` is what",
2761
- "# `wrangler dev` reads, and it is gitignored. This example is not \u2014 keep it",
2762
- "# checked in, and keep real secrets out of it."
2763
- ];
2764
- function devVarsExamplePath(aliases) {
2765
- const workspace = apiWorkspace(aliases);
2766
- return workspace ? posix3.join(workspace, DEV_VARS_EXAMPLE) : void 0;
2955
+ return { files: unique, kind: "formatted" };
2767
2956
  }
2768
- function parseDevVars(text4) {
2769
- const values = {};
2770
- for (const line of text4.split("\n")) {
2771
- const trimmed = line.trim();
2772
- if (!trimmed || trimmed.startsWith("#")) {
2773
- continue;
2774
- }
2775
- const eq = trimmed.indexOf("=");
2776
- if (eq <= 0) {
2777
- continue;
2778
- }
2779
- values[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1);
2957
+ function formatReport(outcome) {
2958
+ if (outcome.kind === "nothing") {
2959
+ return null;
2780
2960
  }
2781
- return values;
2782
- }
2783
- function renderDevVarsExample(args) {
2784
- const { envVars, devVars, existing } = args;
2785
- const names = [
2786
- .../* @__PURE__ */ new Set([...Object.keys(envVars), ...Object.keys(existing)])
2787
- ].toSorted();
2788
- const blocks = names.map((name) => {
2789
- const value = existing[name] ?? devVars[name] ?? "";
2790
- const description = envVars[name];
2791
- const lines = description ? description.split("\n").map((line) => `# ${line}`) : ["# Declared outside saasaloy \u2014 no module describes this one."];
2792
- lines.push(`${name}=${value}`);
2793
- return lines.join("\n");
2794
- });
2795
- return `${[HEADER.join("\n"), ...blocks].join("\n\n")}
2796
- `;
2797
- }
2798
- async function writeDevVarsExample(args) {
2799
- const { root, aliases, envVars, devVars } = args;
2800
- const target = devVarsExamplePath(aliases);
2801
- if (!target) {
2802
- return void 0;
2961
+ const count = `${String(outcome.files.length)} patched file${outcome.files.length === 1 ? "" : "s"}`;
2962
+ if (outcome.kind === "formatted") {
2963
+ return { level: "step", message: `format ${count} with Prettier` };
2803
2964
  }
2804
- const abs = resolveWithinRoot(root, target);
2805
- await assertNoSymlinkPath(root, abs);
2806
- const before = await pathExists(abs) ? await readFile6(abs, "utf-8") : void 0;
2807
- const existing = before === void 0 ? {} : parseDevVars(before);
2808
- if (Object.keys(envVars).length === 0 && Object.keys(existing).length === 0) {
2809
- return void 0;
2965
+ if (outcome.kind === "no-prettier") {
2966
+ return {
2967
+ level: "info",
2968
+ message: `Prettier isn't installed yet, so ${count} kept the patch layout. Run \`pnpm install\`, then \`pnpm format\`.`
2969
+ };
2810
2970
  }
2811
- const content = renderDevVarsExample({ devVars, envVars, existing });
2812
- if (before === content) {
2813
- return void 0;
2971
+ return {
2972
+ level: "warn",
2973
+ message: `Couldn't format ${count} \u2014 ${outcome.message}. Run \`pnpm format\`.`
2974
+ };
2975
+ }
2976
+ async function formatPatchedFiles(root, files, options) {
2977
+ const report = formatReport(await formatFiles(root, files));
2978
+ if (report !== null) {
2979
+ log[report.level](report.message, options);
2814
2980
  }
2815
- await mkdir4(dirname4(abs), { recursive: true });
2816
- await writeFile4(abs, content, "utf-8");
2817
- return target;
2981
+ }
2982
+ function runChild(command, args, cwd) {
2983
+ return new Promise((resolvePromise) => {
2984
+ const child = spawn(command, args, {
2985
+ cwd,
2986
+ shell: process.platform === "win32",
2987
+ stdio: ["ignore", "pipe", "pipe"]
2988
+ });
2989
+ let output = "";
2990
+ child.stdout?.on("data", (chunk) => {
2991
+ output += chunk.toString();
2992
+ });
2993
+ child.stderr?.on("data", (chunk) => {
2994
+ output += chunk.toString();
2995
+ });
2996
+ child.on("error", (error) => {
2997
+ resolvePromise({ message: error.message, ok: false });
2998
+ });
2999
+ child.on("close", (code) => {
3000
+ resolvePromise(
3001
+ code === 0 ? { ok: true } : {
3002
+ message: output.trim() || `prettier exited with code ${String(code)}`,
3003
+ ok: false
3004
+ }
3005
+ );
3006
+ });
3007
+ });
2818
3008
  }
2819
3009
 
2820
3010
  // src/lib/diff.ts
@@ -2865,7 +3055,7 @@ function lineDiff(oldText, newText) {
2865
3055
 
2866
3056
  // src/lib/lock.ts
2867
3057
  import { readFile as readFile7, writeFile as writeFile5 } from "fs/promises";
2868
- import { join as join5 } from "path";
3058
+ import { join as join6 } from "path";
2869
3059
  var LOCK_FILE = "saasaloy-lock.json";
2870
3060
  var LOCKFILE_VERSION = 1;
2871
3061
  var LOCK_SCHEMA_URL = "https://saasaloy.dev/schemas/saasaloy-lock.schema.json";
@@ -2877,7 +3067,7 @@ function emptyLock() {
2877
3067
  };
2878
3068
  }
2879
3069
  async function loadLock(root) {
2880
- const file = join5(root, LOCK_FILE);
3070
+ const file = join6(root, LOCK_FILE);
2881
3071
  if (!await pathExists(file)) {
2882
3072
  return emptyLock();
2883
3073
  }
@@ -2907,7 +3097,7 @@ async function loadLock(root) {
2907
3097
  };
2908
3098
  }
2909
3099
  async function saveLock(root, lock) {
2910
- const file = join5(root, LOCK_FILE);
3100
+ const file = join6(root, LOCK_FILE);
2911
3101
  await writeFile5(file, `${JSON.stringify(lock, null, 2)}
2912
3102
  `, "utf-8");
2913
3103
  }
@@ -2944,7 +3134,7 @@ async function persistLedger(input) {
2944
3134
 
2945
3135
  // src/lib/pkg-json.ts
2946
3136
  import { readFile as readFile8, writeFile as writeFile6 } from "fs/promises";
2947
- import { join as join6 } from "path";
3137
+ import { join as join7 } from "path";
2948
3138
  function parseDep(spec) {
2949
3139
  const at = spec.lastIndexOf("@");
2950
3140
  if (at > 0) {
@@ -2987,7 +3177,7 @@ function planDeps(pkg, deps, devDeps = []) {
2987
3177
  return { added, conflicts, devAdded, skipped };
2988
3178
  }
2989
3179
  async function readRootPackageJson(root) {
2990
- const file = join6(root, "package.json");
3180
+ const file = join7(root, "package.json");
2991
3181
  if (!await pathExists(file)) {
2992
3182
  return null;
2993
3183
  }
@@ -3014,20 +3204,20 @@ async function writeDeps(root, pkg, added, devAdded = []) {
3014
3204
  if (devAdded.length > 0) {
3015
3205
  pkg.devDependencies = mergeSorted(pkg.devDependencies, devAdded);
3016
3206
  }
3017
- const file = join6(root, "package.json");
3207
+ const file = join7(root, "package.json");
3018
3208
  await writeFile6(file, `${JSON.stringify(pkg, null, 2)}
3019
3209
  `, "utf-8");
3020
3210
  }
3021
3211
 
3022
3212
  // src/lib/project.ts
3023
- import { dirname as dirname5, join as join7, parse } from "path";
3213
+ import { dirname as dirname5, join as join8, parse } from "path";
3024
3214
  var MARKERS = ["saasaloy.json"];
3025
3215
  async function findProjectRoot(start = process.cwd()) {
3026
3216
  let dir = start;
3027
3217
  const { root } = parse(dir);
3028
3218
  for (; ; ) {
3029
3219
  for (const marker of MARKERS) {
3030
- if (await pathExists(join7(dir, marker))) {
3220
+ if (await pathExists(join8(dir, marker))) {
3031
3221
  return dir;
3032
3222
  }
3033
3223
  }
@@ -3039,10 +3229,26 @@ async function findProjectRoot(start = process.cwd()) {
3039
3229
  }
3040
3230
 
3041
3231
  // src/lib/registry.ts
3042
- import { mkdtemp, readFile as readFile9, rm } from "fs/promises";
3232
+ import { mkdtemp, readFile as readFile10, rm } from "fs/promises";
3043
3233
  import { tmpdir } from "os";
3044
- import { isAbsolute as isAbsolute2, join as join8, resolve as resolve2 } from "path";
3234
+ import { isAbsolute as isAbsolute2, join as join9, resolve as resolve2 } from "path";
3045
3235
  import { downloadTemplate } from "giget";
3236
+
3237
+ // src/version.ts
3238
+ import { readFile as readFile9 } from "fs/promises";
3239
+ import { fileURLToPath as fileURLToPath2 } from "url";
3240
+ var UNKNOWN_VERSION2 = "unknown";
3241
+ async function readVersion() {
3242
+ try {
3243
+ const file = fileURLToPath2(new URL("../package.json", import.meta.url));
3244
+ const parsed = JSON.parse(await readFile9(file, "utf-8"));
3245
+ return typeof parsed.version === "string" ? parsed.version : UNKNOWN_VERSION2;
3246
+ } catch {
3247
+ return UNKNOWN_VERSION2;
3248
+ }
3249
+ }
3250
+
3251
+ // src/lib/registry.ts
3046
3252
  var REGISTRY_ENV = "SAASALOY_REGISTRY_DIR";
3047
3253
  var DEFAULT_OWNER = "mimukit";
3048
3254
  var DEFAULT_REPO = "saasaloy";
@@ -3093,7 +3299,7 @@ function createRegistrySource(coord) {
3093
3299
  );
3094
3300
  }
3095
3301
  async function loadModuleFolder(dir, name, requiredBy) {
3096
- const file = join8(dir, "registry-item.json");
3302
+ const file = join9(dir, "registry-item.json");
3097
3303
  if (!await pathExists(file)) {
3098
3304
  const because = requiredBy ? ` (required by ${requiredBy})` : "";
3099
3305
  throw new RefusalError(
@@ -3102,7 +3308,7 @@ async function loadModuleFolder(dir, name, requiredBy) {
3102
3308
  }
3103
3309
  let parsed;
3104
3310
  try {
3105
- parsed = JSON.parse(await readFile9(file, "utf-8"));
3311
+ parsed = JSON.parse(await readFile10(file, "utf-8"));
3106
3312
  } catch (error) {
3107
3313
  throw new RefusalError(
3108
3314
  `Module "${name}" has an unreadable descriptor: ${file} is not valid JSON.`,
@@ -3112,8 +3318,12 @@ async function loadModuleFolder(dir, name, requiredBy) {
3112
3318
  const result = await validateRegistryItem(parsed);
3113
3319
  if (!result.valid) {
3114
3320
  throw new RefusalError(
3115
- `Module "${name}" has an invalid descriptor:
3116
- ${result.errors.join("\n ")}`
3321
+ describeInvalidDescriptor({
3322
+ cliVersion: await readVersion(),
3323
+ errors: result.errors,
3324
+ name,
3325
+ raw: parsed
3326
+ })
3117
3327
  );
3118
3328
  }
3119
3329
  const item = parsed;
@@ -3134,13 +3344,13 @@ var LocalRegistrySource = class {
3134
3344
  }
3135
3345
  async readModule(name, requiredBy) {
3136
3346
  await this.assertExists();
3137
- return loadModuleFolder(join8(this.dir, name), name, requiredBy);
3347
+ return loadModuleFolder(join9(this.dir, name), name, requiredBy);
3138
3348
  }
3139
3349
  async listModules() {
3140
3350
  await this.assertExists();
3141
3351
  const names = [];
3142
3352
  for (const name of await readDirNames(this.dir)) {
3143
- if (await pathExists(join8(this.dir, name, "registry-item.json"))) {
3353
+ if (await pathExists(join9(this.dir, name, "registry-item.json"))) {
3144
3354
  names.push(name);
3145
3355
  }
3146
3356
  }
@@ -3209,9 +3419,9 @@ var RemoteRegistrySource = class {
3209
3419
  }
3210
3420
  async readModule(name, requiredBy) {
3211
3421
  const { sha } = await this.resolve();
3212
- const parent = await mkdtemp(join8(tmpdir(), "saasaloy-reg-"));
3422
+ const parent = await mkdtemp(join9(tmpdir(), "saasaloy-reg-"));
3213
3423
  this.tempDirs.push(parent);
3214
- const dir = join8(parent, name);
3424
+ const dir = join9(parent, name);
3215
3425
  try {
3216
3426
  await withDeadline(
3217
3427
  downloadTemplate(
@@ -3513,20 +3723,6 @@ function wantsHelp(argv) {
3513
3723
  return argv.includes("--help") || argv.includes("-h");
3514
3724
  }
3515
3725
 
3516
- // src/version.ts
3517
- import { readFile as readFile10 } from "fs/promises";
3518
- import { fileURLToPath as fileURLToPath2 } from "url";
3519
- var UNKNOWN_VERSION2 = "unknown";
3520
- async function readVersion() {
3521
- try {
3522
- const file = fileURLToPath2(new URL("../package.json", import.meta.url));
3523
- const parsed = JSON.parse(await readFile10(file, "utf-8"));
3524
- return typeof parsed.version === "string" ? parsed.version : UNKNOWN_VERSION2;
3525
- } catch {
3526
- return UNKNOWN_VERSION2;
3527
- }
3528
- }
3529
-
3530
3726
  // src/commands/descriptions.ts
3531
3727
  var DESCRIPTIONS = {
3532
3728
  init: "scaffold a new Saasaloy project (base: Astro landing + ui + config)",
@@ -3675,12 +3871,12 @@ ${pc2.dim("New workspace(s) \u2014 run `pnpm install` to link them.")}`
3675
3871
  );
3676
3872
  }
3677
3873
  for (const conflict of plan.aliasConflicts) {
3678
- log.warn(
3874
+ log2.warn(
3679
3875
  `Alias redefinition: ${conflict} ${pc2.dim("(scaffold overrides the existing alias)")}.`
3680
3876
  );
3681
3877
  }
3682
3878
  for (const stale of plan.staleOwners) {
3683
- log.warn(describeStaleOwner({ ...stale, target: pc2.cyan(stale.target) }));
3879
+ log2.warn(describeStaleOwner({ ...stale, target: pc2.cyan(stale.target) }));
3684
3880
  }
3685
3881
  const newLinks = plan.links.filter((l) => l.action !== "conflict");
3686
3882
  if (newLinks.length > 0) {
@@ -3698,7 +3894,7 @@ ${pc2.dim("Symlinked for Claude Code \u2014 the skill files live in `.agents/ski
3698
3894
  }
3699
3895
  for (const link of plan.links) {
3700
3896
  if (link.action === "conflict") {
3701
- log.warn(
3897
+ log2.warn(
3702
3898
  `Skill link ${pc2.cyan(link.path)} already exists and isn't ours \u2014 left untouched ${pc2.dim("(remove it to let `add` link the skill)")}.`
3703
3899
  );
3704
3900
  }
@@ -3712,7 +3908,7 @@ ${pc2.dim("Symlinked for Claude Code \u2014 the skill files live in `.agents/ski
3712
3908
  }
3713
3909
  for (const p of plan.patches) {
3714
3910
  if (p.action === "missing") {
3715
- log.warn(
3911
+ log2.warn(
3716
3912
  `Config patch target ${pc2.cyan(p.file)} is missing \u2014 ${p.patch.kind} not applied ${pc2.dim(`(is ${p.module}'s prerequisite installed?)`)}.`
3717
3913
  );
3718
3914
  }
@@ -3727,7 +3923,7 @@ function wireUpSteps(result, aliases) {
3727
3923
  return [];
3728
3924
  }
3729
3925
  const skillByModule = new Map(
3730
- result.links.map((link) => [link.module, posix4.basename(link.path)])
3926
+ result.links.map((link) => [link.module, posix3.basename(link.path)])
3731
3927
  );
3732
3928
  const byModule = /* @__PURE__ */ new Map();
3733
3929
  for (const block of blocks) {
@@ -3743,13 +3939,13 @@ function wireUpSteps(result, aliases) {
3743
3939
  const verb = moduleTargets.length === 1 ? "is" : "are";
3744
3940
  const skill = skillByModule.get(module);
3745
3941
  if (skill === void 0) {
3746
- log.warn(
3942
+ log2.warn(
3747
3943
  `${pc2.cyan(module)} wrote ${targets} but ships no skill \u2014 its wire-up steps are missing. Import the block yourself ${pc2.dim("(a module that ships a block should ship a skill with a Wire-up section)")}.`
3748
3944
  );
3749
3945
  continue;
3750
3946
  }
3751
3947
  lines.push(
3752
- `Manual wire-up needed \u2014 ${targets} ${verb} on disk but on no page yet. In Claude Code, run ${pc2.cyan(`/${skill}`)} and follow its Wire-up section.`
3948
+ `Manual wire-up needed \u2014 ${targets} ${verb} on disk but on no page yet. In your AI coding tool, run ${pc2.cyan(`/${skill}`)} and follow its Wire-up section.`
3753
3949
  );
3754
3950
  }
3755
3951
  return lines;
@@ -3762,7 +3958,8 @@ function envSteps(names, devVarsPath) {
3762
3958
  const where = devVarsPath ? ` ${pc2.dim(`${devVarsPath} keeps the checked-in description of each.`)}` : "";
3763
3959
  return [
3764
3960
  `Set ${keys.map((key) => pc2.cyan(key)).join(", ")}.`,
3765
- `Run ${pc2.cyan("saasaloy env")} \u2014 it prompts for each unset one and writes it to the ${pc2.cyan(".dev.vars")} or ${pc2.cyan(".env")} that reads it.${where}`
3961
+ `Run ${pc2.cyan("saasaloy env")} \u2014 it prompts for each unset one and writes it to ${pc2.cyan("packages/env/.env")}.${where}`,
3962
+ `Then run ${pc2.cyan("pnpm env:setup")} to write each service's own ${pc2.cyan(".env")} from it.`
3766
3963
  ];
3767
3964
  }
3768
3965
  function formatIncomplete(incomplete, requested) {
@@ -3785,11 +3982,11 @@ async function persistState(input) {
3785
3982
  );
3786
3983
  } catch (error) {
3787
3984
  failures.push(error);
3788
- log.warn(`Couldn't pin ${LOCK_FILE} \u2014 ${formatFailure(error)}.`);
3985
+ log2.warn(`Couldn't pin ${LOCK_FILE} \u2014 ${formatFailure(error)}.`);
3789
3986
  }
3790
3987
  for (const failure of await persistLedger(input)) {
3791
3988
  failures.push(failure.error);
3792
- log.warn(
3989
+ log2.warn(
3793
3990
  `Couldn't write ${failure.file} \u2014 ${formatFailure(failure.error)}.`
3794
3991
  );
3795
3992
  }
@@ -3820,10 +4017,10 @@ async function applyAndPersist(input) {
3820
4017
  }
3821
4018
  function printNextSteps(plan, result, devVarsPath, aliases) {
3822
4019
  const lines = [...wireUpSteps(result, aliases)];
3823
- const skills = result.links.map((link) => posix4.basename(link.path)).toSorted();
4020
+ const skills = result.links.map((link) => posix3.basename(link.path)).toSorted();
3824
4021
  if (skills.length > 0) {
3825
4022
  lines.push(
3826
- `Read the module's own procedure \u2014 in Claude Code, run ${skills.map((name) => pc2.cyan(`/${name}`)).join(" or ")}.`
4023
+ `Read the module's own procedure \u2014 in your AI coding tool, run ${skills.map((name) => pc2.cyan(`/${name}`)).join(" or ")}.`
3827
4024
  );
3828
4025
  }
3829
4026
  lines.push(...envSteps(Object.keys(plan.envVars), devVarsPath));
@@ -3876,7 +4073,7 @@ async function runAdd(argv) {
3876
4073
  coord = pinToLock(coord, lock);
3877
4074
  source = createRegistrySource(coord);
3878
4075
  if (process.env[REGISTRY_ENV] && (coord.owner || coord.repo)) {
3879
- log.warn(
4076
+ log2.warn(
3880
4077
  `Ignoring source "${coord.owner}/${coord.repo}" \u2014 ${REGISTRY_ENV} override is set.`
3881
4078
  );
3882
4079
  }
@@ -3958,7 +4155,7 @@ async function runAdd(argv) {
3958
4155
  const manifest = await loadManifest(root);
3959
4156
  const conflicts = detectConflicts({ graph, config, lock });
3960
4157
  if (conflicts.missingLockEntries.length > 0) {
3961
- log.warn(
4158
+ log2.warn(
3962
4159
  `No lock entry for ${conflicts.missingLockEntries.map((m) => pc2.cyan(m)).join(", ")} \u2014 any conflict they declare can't be checked ${pc2.dim("(re-add them to record it)")}.`
3963
4160
  );
3964
4161
  }
@@ -3991,7 +4188,7 @@ async function runAdd(argv) {
3991
4188
  });
3992
4189
  summarizePlan(plan, requested, prereqs);
3993
4190
  if (planWritesUi(plan)) {
3994
- log.info(
4191
+ log2.info(
3995
4192
  `This module writes ${pc2.cyan("packages/ui/")}. Run ${pc2.cyan("/saasaloy-design update")} after it applies.`
3996
4193
  );
3997
4194
  }
@@ -4057,41 +4254,45 @@ async function runAdd(argv) {
4057
4254
  await writeDeps(root, pkg, added, devAdded);
4058
4255
  depsAdded = [...added, ...devAdded].map((d) => d.name);
4059
4256
  for (const conflict of depConflicts) {
4060
- log.warn(`Dependency version conflict \u2014 ${conflict}.`);
4257
+ log2.warn(`Dependency version conflict \u2014 ${conflict}.`);
4061
4258
  }
4062
4259
  } else {
4063
- log.warn(
4260
+ log2.warn(
4064
4261
  `No package.json at the project root \u2014 add ${allDeps.join(", ")} yourself.`
4065
4262
  );
4066
4263
  }
4067
4264
  }
4068
4265
  for (const file of result.written) {
4069
- log.step(`${ACTION_LABEL[file.action]} ${file.target}`);
4266
+ log2.step(`${ACTION_LABEL[file.action]} ${file.target}`);
4070
4267
  }
4071
4268
  for (const file of result.refreshed) {
4072
- log.step(`${ACTION_LABEL[file.action]} ${file.target}`);
4269
+ log2.step(`${ACTION_LABEL[file.action]} ${file.target}`);
4073
4270
  }
4074
4271
  for (const link of result.links) {
4075
4272
  const label = link.action === "create" ? pc2.green("link") : pc2.dim("link");
4076
- log.step(`${label} ${link.path} ${pc2.dim(`\u2192 ${link.target}`)}`);
4273
+ log2.step(`${label} ${link.path} ${pc2.dim(`\u2192 ${link.target}`)}`);
4077
4274
  }
4078
4275
  for (const link of result.linkConflicts) {
4079
- log.warn(
4276
+ log2.warn(
4080
4277
  `Skill link ${pc2.cyan(link.path)} left untouched \u2014 a non-saasaloy path already occupies it.`
4081
4278
  );
4082
4279
  }
4083
4280
  for (const p of result.patched) {
4084
- log.step(
4281
+ log2.step(
4085
4282
  `${pc2.green("patch")} ${p.file} ${pc2.dim(`\u2014 ${p.patch.kind}`)}`
4086
4283
  );
4087
4284
  }
4285
+ await formatPatchedFiles(
4286
+ root,
4287
+ result.patched.map((p) => p.file)
4288
+ );
4088
4289
  for (const p of result.patchConflicts) {
4089
- log.warn(
4290
+ log2.warn(
4090
4291
  `Config patch target ${pc2.cyan(p.file)} missing \u2014 ${p.patch.kind} skipped.`
4091
4292
  );
4092
4293
  }
4093
4294
  for (const r of result.patchRefusals) {
4094
- log.warn(
4295
+ log2.warn(
4095
4296
  `Config patch on ${pc2.cyan(r.patch.file)} skipped \u2014 ${r.reason}. Wire it by hand.`
4096
4297
  );
4097
4298
  }
@@ -4110,7 +4311,7 @@ ${pc2.dim("Changed while the plan was open \u2014 left alone rather than overwri
4110
4311
  (name) => !result.completed.includes(name)
4111
4312
  );
4112
4313
  if (incomplete.length > 0) {
4113
- log.warn(formatIncomplete(incomplete, requested));
4314
+ log2.warn(formatIncomplete(incomplete, requested));
4114
4315
  }
4115
4316
  if (result.heldBack.length > 0) {
4116
4317
  const merges = result.heldBack.map((f) => ` ${ACTION_LABEL[f.action]} ${f.target}`).join("\n");
@@ -4133,16 +4334,22 @@ ${pc2.dim("Run `pnpm install` to fetch them.")}`
4133
4334
  "Dependencies added"
4134
4335
  );
4135
4336
  }
4337
+ if (plan.envServicesGuessed.length > 0) {
4338
+ log2.warn(
4339
+ `${plan.envServicesGuessed.join(", ")} declare env vars without \`envServices\`, so the PUBLIC_ prefix decided which service reads each one. Check ${ENV_EXAMPLE}.`
4340
+ );
4341
+ }
4136
4342
  let devVarsPath;
4137
4343
  try {
4138
- devVarsPath = await writeDevVarsExample({
4139
- aliases: config.aliases,
4344
+ devVarsPath = await writeEnvExample({
4140
4345
  devVars: plan.devVars,
4141
4346
  envVars: plan.envVars,
4347
+ optional: plan.envOptional,
4348
+ services: plan.envServices,
4142
4349
  root
4143
4350
  });
4144
4351
  } catch (error) {
4145
- log.warn(`Couldn't write ${DEV_VARS_EXAMPLE} \u2014 ${formatFailure(error)}.`);
4352
+ log2.warn(`Couldn't write ${ENV_EXAMPLE} \u2014 ${formatFailure(error)}.`);
4146
4353
  }
4147
4354
  printNextSteps(plan, result, devVarsPath, config.aliases);
4148
4355
  outro(
@@ -4155,7 +4362,7 @@ ${pc2.dim("Run `pnpm install` to fetch them.")}`
4155
4362
  return EXIT_OK;
4156
4363
  } catch (error) {
4157
4364
  if (partial) {
4158
- log.warn(formatRecovery(partial));
4365
+ log2.warn(formatRecovery(partial));
4159
4366
  }
4160
4367
  cancel(formatFailure(error));
4161
4368
  return exitCodeFor(error);
@@ -4165,25 +4372,25 @@ ${pc2.dim("Run `pnpm install` to fetch them.")}`
4165
4372
  }
4166
4373
 
4167
4374
  // src/commands/doctor.ts
4168
- import { cancel as cancel2, intro as intro2, log as log2, note as note2, outro as outro2 } from "@clack/prompts";
4169
- import { join as join12, relative as relative2 } from "path";
4375
+ import { cancel as cancel2, intro as intro2, log as log3, note as note2, outro as outro2 } from "@clack/prompts";
4376
+ import { join as join13, relative as relative2 } from "path";
4170
4377
  import pc3 from "picocolors";
4171
4378
 
4172
4379
  // src/lib/base.ts
4173
4380
  import { createHash as createHash2 } from "crypto";
4174
4381
  import { mkdtemp as mkdtemp2, readdir as readdir3, readFile as readFile12, rm as rm2 } from "fs/promises";
4175
4382
  import { tmpdir as tmpdir2 } from "os";
4176
- import { join as join10, posix as posix6 } from "path";
4383
+ import { join as join11, posix as posix5 } from "path";
4177
4384
 
4178
4385
  // src/lib/scaffold.ts
4179
4386
  import { mkdir as mkdir5, readdir as readdir2, readFile as readFile11, writeFile as writeFile7 } from "fs/promises";
4180
- import { join as join9, posix as posix5 } from "path";
4387
+ import { join as join10, posix as posix4 } from "path";
4181
4388
  import { fileURLToPath as fileURLToPath3 } from "url";
4182
4389
 
4183
4390
  // package.json
4184
4391
  var package_default = {
4185
4392
  name: "saasaloy",
4186
- version: "0.5.1",
4393
+ version: "0.6.0",
4187
4394
  description: "Composable SaaS accelerator kit.",
4188
4395
  keywords: [
4189
4396
  "saas",
@@ -4222,6 +4429,7 @@ var package_default = {
4222
4429
  },
4223
4430
  scripts: {
4224
4431
  build: "tsup",
4432
+ clean: "rimraf dist coverage",
4225
4433
  prepack: "pnpm -w run readme:cli && pnpm run build",
4226
4434
  dev: "tsup --watch",
4227
4435
  start: "node ./dist/index.js",
@@ -4237,6 +4445,7 @@ var package_default = {
4237
4445
  "@types/node": "26.4.1",
4238
4446
  "@vitest/coverage-v8": "5.0.0",
4239
4447
  "release-it": "21.0.2",
4448
+ rimraf: "6.1.3",
4240
4449
  tsup: "8.5.1",
4241
4450
  vitest: "5.0.0"
4242
4451
  },
@@ -4257,7 +4466,7 @@ var TEMPLATE_DIR_CANDIDATES = ["../templates/base", "../../templates/base"];
4257
4466
  async function baseTemplateDir() {
4258
4467
  for (const candidate of TEMPLATE_DIR_CANDIDATES) {
4259
4468
  const dir = fileURLToPath3(new URL(candidate, import.meta.url));
4260
- if (await pathExists(join9(dir, "package.json"))) {
4469
+ if (await pathExists(join10(dir, "package.json"))) {
4261
4470
  return dir;
4262
4471
  }
4263
4472
  }
@@ -4280,11 +4489,11 @@ async function copyTree(srcDir, destDir, vars, options, fromPrefix, targetPrefix
4280
4489
  if (isRoot && entry.name === BASE_DECLARATION) {
4281
4490
  continue;
4282
4491
  }
4283
- const srcPath = join9(srcDir, entry.name);
4492
+ const srcPath = join10(srcDir, entry.name);
4284
4493
  const outName = renderedName(entry.name);
4285
- const destPath = join9(destDir, options.keepNames ? entry.name : outName);
4286
- const from = posix5.join(fromPrefix, entry.name);
4287
- const target = posix5.join(targetPrefix, outName);
4494
+ const destPath = join10(destDir, options.keepNames ? entry.name : outName);
4495
+ const from = posix4.join(fromPrefix, entry.name);
4496
+ const target = posix4.join(targetPrefix, outName);
4288
4497
  if (entry.isDirectory()) {
4289
4498
  written.push(
4290
4499
  ...await copyTree(
@@ -4334,9 +4543,9 @@ function ownedMatcher(declaration) {
4334
4543
  return (target) => patterns.some((re) => re.test(target));
4335
4544
  }
4336
4545
  async function readBaseDeclaration(templateDir) {
4337
- const raw = await readIfPresent(join10(templateDir, BASE_DECLARATION));
4546
+ const raw = await readIfPresent(join11(templateDir, BASE_DECLARATION));
4338
4547
  if (raw === void 0) {
4339
- return { seedFiles: [], ownedFiles: [] };
4548
+ return { seedFiles: [], retiredSeedFiles: [], ownedFiles: [] };
4340
4549
  }
4341
4550
  const parsed = JSON.parse(raw);
4342
4551
  const seedFiles = parsed.seedFiles;
@@ -4345,6 +4554,14 @@ async function readBaseDeclaration(templateDir) {
4345
4554
  `${BASE_DECLARATION} must declare "seedFiles" as a list of project-relative paths.`
4346
4555
  );
4347
4556
  }
4557
+ const retiredSeedFiles = parsed.retiredSeedFiles ?? [];
4558
+ if (!Array.isArray(retiredSeedFiles) || !retiredSeedFiles.every(
4559
+ (entry) => typeof entry === "string" && entry !== ""
4560
+ )) {
4561
+ throw new Error(
4562
+ `${BASE_DECLARATION} must declare "retiredSeedFiles" as a list of project-relative paths.`
4563
+ );
4564
+ }
4348
4565
  const ownedFiles = parsed.ownedFiles ?? [];
4349
4566
  if (!Array.isArray(ownedFiles) || !ownedFiles.every((entry) => typeof entry === "string" && entry !== "")) {
4350
4567
  throw new Error(
@@ -4352,7 +4569,8 @@ async function readBaseDeclaration(templateDir) {
4352
4569
  );
4353
4570
  }
4354
4571
  return {
4355
- seedFiles,
4572
+ seedFiles: [...seedFiles, ...retiredSeedFiles],
4573
+ retiredSeedFiles,
4356
4574
  ownedFiles
4357
4575
  };
4358
4576
  }
@@ -4363,9 +4581,9 @@ async function listTemplateFiles(templateDir) {
4363
4581
  if (isRoot && entry.name === BASE_DECLARATION) {
4364
4582
  continue;
4365
4583
  }
4366
- const from = posix6.join(fromPrefix, entry.name);
4367
- const target = posix6.join(targetPrefix, renderedName(entry.name));
4368
- const abs = join10(dir, entry.name);
4584
+ const from = posix5.join(fromPrefix, entry.name);
4585
+ const target = posix5.join(targetPrefix, renderedName(entry.name));
4586
+ const abs = join11(dir, entry.name);
4369
4587
  if (entry.isDirectory()) {
4370
4588
  await walk(abs, from, target, false);
4371
4589
  } else if (entry.isFile()) {
@@ -4439,7 +4657,7 @@ async function missingBaseTargets(root, manifest, templateDir) {
4439
4657
  return missing.toSorted((a, b) => a < b ? -1 : 1);
4440
4658
  }
4441
4659
  async function renderTemplate(root, templateDir, projectName) {
4442
- const dir = await mkdtemp2(join10(tmpdir2(), "saasaloy-base-render-"));
4660
+ const dir = await mkdtemp2(join11(tmpdir2(), "saasaloy-base-render-"));
4443
4661
  const files = await copyTemplate(
4444
4662
  templateDir,
4445
4663
  dir,
@@ -4555,7 +4773,7 @@ async function baseUpdateInput(args) {
4555
4773
  import { findNodeAtLocation as findNodeAtLocation4, getNodeValue as getNodeValue4, parseTree as parseTree4 } from "jsonc-parser";
4556
4774
  import { parseModule as parseModule7 } from "magicast";
4557
4775
  import { readFile as readFile13 } from "fs/promises";
4558
- import { basename, dirname as dirname6, join as join11, posix as posix7, resolve as resolve3 } from "path";
4776
+ import { basename, dirname as dirname6, join as join12, posix as posix6, resolve as resolve3 } from "path";
4559
4777
  var SKILL_PREFIX = "saasaloy-";
4560
4778
  var PINNED_DEP = /^(@[^/@]+\/)?[^/@]+@\d+\.\d+\.\d+(-[\w.-]+)?(\+[\w.-]+)?$/;
4561
4779
  function finding(module, where, message) {
@@ -4570,7 +4788,7 @@ function asRecord(value) {
4570
4788
  async function registryModuleNames(dir) {
4571
4789
  const names = [];
4572
4790
  for (const name of await readDirNames(dir)) {
4573
- if (await pathExists(join11(dir, name, "registry-item.json"))) {
4791
+ if (await pathExists(join12(dir, name, "registry-item.json"))) {
4574
4792
  names.push(name);
4575
4793
  }
4576
4794
  }
@@ -4579,12 +4797,12 @@ async function registryModuleNames(dir) {
4579
4797
  async function collectAliases(registryDir, names) {
4580
4798
  const templateDir = await baseTemplateDir();
4581
4799
  let base = {};
4582
- if (await pathExists(join11(templateDir, "saasaloy.json"))) {
4800
+ if (await pathExists(join12(templateDir, "saasaloy.json"))) {
4583
4801
  base = (await loadConfig(templateDir)).aliases;
4584
4802
  }
4585
4803
  const fromScaffolds = {};
4586
4804
  for (const name of names) {
4587
- const item = asRecord(await readDescriptor(join11(registryDir, name)));
4805
+ const item = asRecord(await readDescriptor(join12(registryDir, name)));
4588
4806
  for (const scaffold of asArray2(item.scaffolds)) {
4589
4807
  const aliases = asRecord(asRecord(scaffold).aliases);
4590
4808
  for (const [alias, prefix] of Object.entries(aliases)) {
@@ -4597,7 +4815,7 @@ async function collectAliases(registryDir, names) {
4597
4815
  return { base, fromScaffolds };
4598
4816
  }
4599
4817
  async function readDescriptor(dir) {
4600
- const file = join11(dir, "registry-item.json");
4818
+ const file = join12(dir, "registry-item.json");
4601
4819
  if (!await pathExists(file)) {
4602
4820
  return void 0;
4603
4821
  }
@@ -4612,7 +4830,7 @@ async function checkModule(input) {
4612
4830
  const module = basename(dir);
4613
4831
  const findings = [];
4614
4832
  const report = () => ({ dir, findings, module });
4615
- const file = join11(dir, "registry-item.json");
4833
+ const file = join12(dir, "registry-item.json");
4616
4834
  if (!await pathExists(file)) {
4617
4835
  findings.push(
4618
4836
  finding(module, "(root)", `no registry-item.json in ${module}/`)
@@ -4730,6 +4948,28 @@ async function checkModule(input) {
4730
4948
  );
4731
4949
  }
4732
4950
  }
4951
+ for (const key of Object.keys(asRecord(item.envServices))) {
4952
+ if (key !== "default" && !(key in asRecord(item.envVars))) {
4953
+ findings.push(
4954
+ finding(
4955
+ module,
4956
+ `/envServices/${key}`,
4957
+ "has no matching entry in envVars, so it routes nothing"
4958
+ )
4959
+ );
4960
+ }
4961
+ }
4962
+ for (const key of asArray2(item.envOptional)) {
4963
+ if (typeof key === "string" && !(key in asRecord(item.envVars))) {
4964
+ findings.push(
4965
+ finding(
4966
+ module,
4967
+ `/envOptional/${key}`,
4968
+ "has no matching entry in envVars, so nothing marks it blank on purpose"
4969
+ )
4970
+ );
4971
+ }
4972
+ }
4733
4973
  for (const key of Object.keys(asRecord(item.devVars))) {
4734
4974
  if (!(key in asRecord(item.envVars))) {
4735
4975
  findings.push(
@@ -4745,7 +4985,7 @@ async function checkModule(input) {
4745
4985
  if (typeof skill !== "string") {
4746
4986
  continue;
4747
4987
  }
4748
- const folder = posix7.basename(skill);
4988
+ const folder = posix6.basename(skill);
4749
4989
  if (!folder.startsWith(SKILL_PREFIX)) {
4750
4990
  findings.push(
4751
4991
  finding(
@@ -4979,7 +5219,7 @@ async function readPolicyState(root, installed) {
4979
5219
  const source = await readIfPresent(
4980
5220
  resolveWithinRoot(
4981
5221
  root,
4982
- posix7.join(posix7.dirname(KV_INDEX_FILE), `${specifier}.ts`)
5222
+ posix6.join(posix6.dirname(KV_INDEX_FILE), `${specifier}.ts`)
4983
5223
  )
4984
5224
  );
4985
5225
  if (source !== void 0) {
@@ -5006,7 +5246,7 @@ function relativeImportsIn(index) {
5006
5246
  }
5007
5247
  async function resolveDoctorTarget(path) {
5008
5248
  const dir = resolve3(path);
5009
- if (await pathExists(join11(dir, "registry-item.json"))) {
5249
+ if (await pathExists(join12(dir, "registry-item.json"))) {
5010
5250
  return { names: [basename(dir)], registryDir: dirname6(dir) };
5011
5251
  }
5012
5252
  return { names: await registryModuleNames(dir), registryDir: dir };
@@ -5028,6 +5268,54 @@ function checkPartialInstalls(state) {
5028
5268
  )
5029
5269
  );
5030
5270
  }
5271
+ var LEGACY_ENV_FILES = [".dev.vars", ".dev.vars.example"];
5272
+ function checkEnv(args) {
5273
+ const findings = args.legacy.toSorted().map(
5274
+ (path) => finding(
5275
+ "env",
5276
+ `/${path}`,
5277
+ `${path} still exists, and nothing reads it. wrangler loads .env only while no .dev.vars sits beside it, so every value pnpm env:setup writes is ignored. Run \`pnpm env:setup\` to carry its values into .env and delete it.`
5278
+ )
5279
+ );
5280
+ if (args.legacy.length > 0 && !args.hasKeyList) {
5281
+ findings.push(
5282
+ finding(
5283
+ "env",
5284
+ `/${ENV_EXAMPLE}`,
5285
+ `${ENV_EXAMPLE} is missing, so there is no key list to migrate those values into. Re-run \`saasaloy add\` for an installed module to regenerate it.`
5286
+ )
5287
+ );
5288
+ }
5289
+ return findings;
5290
+ }
5291
+ var INFRA_RUNBOOK = "https://github.com/mimukit/saasaloy/blob/main/docs/wiki/runbooks/move-off-the-infra-module.md";
5292
+ function checkRetiredInfra(hasInfra) {
5293
+ return hasInfra ? [
5294
+ finding(
5295
+ "deploy",
5296
+ "/infra",
5297
+ `infra/ is the Pulumi deploy that pnpm deploy:production replaced, and nothing runs it any more. Never run pulumi destroy: it deletes the live Workers and their data. Follow ${INFRA_RUNBOOK} to move the resource ids into wrangler.jsonc, then delete infra/.`
5298
+ )
5299
+ ] : [];
5300
+ }
5301
+ function hasRetiredInfra(root) {
5302
+ return pathExists(resolveWithinRoot(root, "infra"));
5303
+ }
5304
+ async function readEnvState(root) {
5305
+ const legacy = [];
5306
+ for (const workspace of ["apps/api", "apps/web", "apps/admin"]) {
5307
+ for (const name of LEGACY_ENV_FILES) {
5308
+ const path = posix6.join(workspace, name);
5309
+ if (await pathExists(resolveWithinRoot(root, path))) {
5310
+ legacy.push(path);
5311
+ }
5312
+ }
5313
+ }
5314
+ return {
5315
+ legacy,
5316
+ hasKeyList: await pathExists(resolveWithinRoot(root, ENV_EXAMPLE))
5317
+ };
5318
+ }
5031
5319
  async function checkTarget(target) {
5032
5320
  const siblings = await registryModuleNames(target.registryDir);
5033
5321
  const aliases = await collectAliases(target.registryDir, siblings);
@@ -5036,7 +5324,7 @@ async function checkTarget(target) {
5036
5324
  reports.push(
5037
5325
  await checkModule({
5038
5326
  aliases,
5039
- dir: join11(target.registryDir, name),
5327
+ dir: join12(target.registryDir, name),
5040
5328
  siblings
5041
5329
  })
5042
5330
  );
@@ -5150,10 +5438,13 @@ async function reportProject(path) {
5150
5438
  });
5151
5439
  note2(wrapForNote(renderBase(base)), "Base");
5152
5440
  const policyState = await readPolicyState(path, config.installed);
5441
+ const envState = await readEnvState(path);
5153
5442
  const findings = [
5154
5443
  ...checkProject({ config, manifest }),
5155
5444
  ...checkPartialInstalls({ installed: config.installed, manifest }),
5156
- ...policyState ? checkPolicyBindings(policyState) : []
5445
+ ...policyState ? checkPolicyBindings(policyState) : [],
5446
+ ...checkEnv(envState),
5447
+ ...checkRetiredInfra(await hasRetiredInfra(path))
5157
5448
  ];
5158
5449
  if (findings.length === 0) {
5159
5450
  const count = config.installed.length;
@@ -5198,7 +5489,7 @@ async function runDoctor(argv) {
5198
5489
  );
5199
5490
  return EXIT_REFUSED;
5200
5491
  }
5201
- if (await pathExists(join12(path, CONFIG_FILE))) {
5492
+ if (await pathExists(join13(path, CONFIG_FILE))) {
5202
5493
  return await reportProject(path);
5203
5494
  }
5204
5495
  const target = await resolveDoctorTarget(path);
@@ -5228,7 +5519,7 @@ async function runDoctor(argv) {
5228
5519
  `${pc3.yellow(report.module)} ${pc3.dim(`(${report.findings.length})`)}`
5229
5520
  );
5230
5521
  }
5231
- log2.info(
5522
+ log3.info(
5232
5523
  pc3.dim(
5233
5524
  `Checked ${reports.length} module${reports.length === 1 ? "" : "s"}; ${reports.length - bad.length} clean.`
5234
5525
  )
@@ -5250,118 +5541,29 @@ import {
5250
5541
  cancel as cancel3,
5251
5542
  intro as intro3,
5252
5543
  isCancel as isCancel2,
5253
- log as log3,
5544
+ log as log4,
5254
5545
  note as note3,
5255
5546
  outro as outro3,
5256
- select as select2,
5257
5547
  text
5258
5548
  } from "@clack/prompts";
5259
5549
  import pc4 from "picocolors";
5260
5550
 
5261
5551
  // src/lib/env-vars.ts
5262
- import { posix as posix8 } from "path";
5263
- var ENV_FILE = ".env";
5264
- var DEV_VARS_FILE = ".dev.vars";
5265
- var PUBLIC_PREFIX = "PUBLIC_";
5266
- var APPS_DIR = "apps/";
5267
- var WRANGLER_CONFIGS = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"];
5268
- function isPublicVar(name) {
5269
- return name.startsWith(PUBLIC_PREFIX);
5270
- }
5271
- function targetFileName(name) {
5272
- return isPublicVar(name) ? ENV_FILE : DEV_VARS_FILE;
5273
- }
5274
- function resolved(workspace) {
5275
- return workspace ? { kind: "resolved", workspace } : void 0;
5276
- }
5277
- function routeVariable(args) {
5278
- const { candidates, wranglerWorkspaces } = args;
5279
- if (isPublicVar(args.name)) {
5280
- const apps = candidates.filter(
5281
- (w) => w.startsWith(APPS_DIR) && w !== args.apiWorkspace
5282
- );
5283
- if (apps.length === 1) {
5284
- return { kind: "resolved", workspace: apps[0] };
5285
- }
5286
- if (apps.length > 1) {
5287
- return { choices: apps.toSorted(), kind: "ambiguous" };
5288
- }
5289
- return resolved(args.baseWorkspace) ?? fromCandidatesAlone(candidates);
5290
- }
5291
- const wrangler = candidates.filter((w) => wranglerWorkspaces.includes(w));
5292
- if (wrangler.length === 1) {
5293
- return { kind: "resolved", workspace: wrangler[0] };
5294
- }
5295
- if (wrangler.length > 1) {
5296
- return (args.apiWorkspace && wrangler.includes(args.apiWorkspace) ? resolved(args.apiWorkspace) : void 0) ?? { choices: wrangler.toSorted(), kind: "ambiguous" };
5297
- }
5298
- return resolved(args.apiWorkspace) ?? fromCandidatesAlone(candidates);
5299
- }
5300
- function fromCandidatesAlone(candidates) {
5301
- if (candidates.length === 1) {
5302
- return { kind: "resolved", workspace: candidates[0] };
5303
- }
5304
- return candidates.length === 0 ? { kind: "unknown" } : { choices: candidates.toSorted(), kind: "ambiguous" };
5305
- }
5306
- async function discoverWorkspaces(root, aliases) {
5307
- const found = /* @__PURE__ */ new Set();
5308
- for (const value of Object.values(aliases)) {
5309
- const workspace = await walkUpToWorkspace(root, value);
5310
- if (workspace) {
5311
- found.add(workspace);
5312
- }
5313
- }
5314
- return [...found].toSorted();
5315
- }
5316
- async function walkUpToWorkspace(root, relPosixPath) {
5317
- let current = relPosixPath.replace(/\/+$/, "");
5318
- while (current && current !== "." && current !== "/") {
5319
- if (await pathExists(resolveWithinRoot(root, `${current}/package.json`))) {
5320
- return current;
5321
- }
5322
- const parent = posix8.dirname(current);
5323
- if (parent === current) {
5324
- return void 0;
5325
- }
5326
- current = parent;
5327
- }
5328
- return void 0;
5329
- }
5330
- async function findWranglerWorkspaces(root, workspaces) {
5331
- const found = [];
5332
- for (const workspace of workspaces) {
5333
- for (const config of WRANGLER_CONFIGS) {
5334
- if (await pathExists(resolveWithinRoot(root, `${workspace}/${config}`))) {
5335
- found.push(workspace);
5336
- break;
5337
- }
5338
- }
5339
- }
5340
- return found;
5341
- }
5342
- function workspacesByModule(manifest, workspaces) {
5343
- const byModule = /* @__PURE__ */ new Map();
5344
- for (const [path, entry] of Object.entries(manifest.managed)) {
5345
- const workspace = workspaceForPath(path, workspaces);
5346
- if (!workspace) {
5552
+ var LOCAL_VALUES = "packages/env/.env";
5553
+ function parseEnvValues(text4) {
5554
+ const values = {};
5555
+ for (const line of text4.split("\n")) {
5556
+ const trimmed = line.trim();
5557
+ if (!trimmed || trimmed.startsWith("#")) {
5347
5558
  continue;
5348
5559
  }
5349
- const set = byModule.get(entry.module) ?? /* @__PURE__ */ new Set();
5350
- set.add(workspace);
5351
- byModule.set(entry.module, set);
5352
- }
5353
- return new Map(
5354
- [...byModule].map(([module, set]) => [module, [...set].toSorted()])
5355
- );
5356
- }
5357
- function workspaceForPath(path, workspaces) {
5358
- let best;
5359
- for (const workspace of workspaces) {
5360
- if (path.startsWith(`${workspace}/`) && (best === void 0 || workspace.length > best.length)) {
5361
- best = workspace;
5560
+ const eq = trimmed.indexOf("=");
5561
+ if (eq <= 0) {
5562
+ continue;
5362
5563
  }
5564
+ values[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1);
5363
5565
  }
5364
- return best;
5566
+ return values;
5365
5567
  }
5366
5568
  function isSet(values, name) {
5367
5569
  return (values[name] ?? "").trim() !== "";
@@ -5397,27 +5599,20 @@ function appendVars(existing, additions) {
5397
5599
  return appended.length === 0 ? head : `${head}${appended.join("\n")}
5398
5600
  `;
5399
5601
  }
5400
- function productionSecretCommands(targets) {
5401
- const byWorkspace = /* @__PURE__ */ new Map();
5402
- for (const { name, workspace } of targets) {
5403
- if (isPublicVar(name)) {
5404
- continue;
5405
- }
5406
- byWorkspace.set(workspace, [...byWorkspace.get(workspace) ?? [], name]);
5407
- }
5408
- const lines = [];
5409
- for (const workspace of [...byWorkspace.keys()].toSorted()) {
5410
- lines.push(`# from ${workspace}`);
5411
- for (const name of (byWorkspace.get(workspace) ?? []).toSorted()) {
5412
- lines.push(`wrangler secret put ${name}`);
5413
- }
5602
+ function productionSecretCommands(names) {
5603
+ const secrets = names.filter((name) => !isPublicVar(name)).toSorted();
5604
+ if (secrets.length === 0) {
5605
+ return [];
5414
5606
  }
5415
- return lines;
5607
+ return [
5608
+ "# from the Worker's workspace, e.g. apps/api",
5609
+ ...secrets.map((name) => `wrangler secret put ${name}`)
5610
+ ];
5416
5611
  }
5417
5612
 
5418
5613
  // src/lib/gitignore.ts
5419
5614
  import { readFile as readFile14 } from "fs/promises";
5420
- import { dirname as dirname7, join as join13, relative as relative3, resolve as resolve4, sep as sep2 } from "path";
5615
+ import { dirname as dirname7, join as join14, relative as relative3, resolve as resolve4, sep as sep2 } from "path";
5421
5616
  var REGEX_SPECIAL = /[.+^${}()|[\]\\]/g;
5422
5617
  function globToRegex(glob) {
5423
5618
  let out = "";
@@ -5501,7 +5696,7 @@ function excludes(layers, candidate, isDir) {
5501
5696
  async function findRepoRoot(start) {
5502
5697
  let current = resolve4(start);
5503
5698
  for (; ; ) {
5504
- if (await pathExists(join13(current, ".git"))) {
5699
+ if (await pathExists(join14(current, ".git"))) {
5505
5700
  return current;
5506
5701
  }
5507
5702
  const parent = dirname7(current);
@@ -5518,7 +5713,7 @@ async function isPathIgnored(root, relPosixPath) {
5518
5713
  }
5519
5714
  const fromRepo = relative3(
5520
5715
  repoRoot,
5521
- join13(resolve4(root), ...relPosixPath.split("/"))
5716
+ join14(resolve4(root), ...relPosixPath.split("/"))
5522
5717
  );
5523
5718
  const segments = fromRepo.split(sep2).filter(Boolean);
5524
5719
  if (segments.length === 0 || segments.includes("..")) {
@@ -5527,7 +5722,7 @@ async function isPathIgnored(root, relPosixPath) {
5527
5722
  const layers = [];
5528
5723
  for (let depth = 0; depth < segments.length; depth += 1) {
5529
5724
  const parts = segments.slice(0, depth);
5530
- const file = join13(repoRoot, ...parts, ".gitignore");
5725
+ const file = join14(repoRoot, ...parts, ".gitignore");
5531
5726
  if (await pathExists(file)) {
5532
5727
  layers.push({
5533
5728
  base: parts.join("/"),
@@ -5565,16 +5760,9 @@ function parseArgs3(argv) {
5565
5760
  return { check: argv.includes("--check"), unknown };
5566
5761
  }
5567
5762
  function renderPending(pending) {
5568
- return pending.map((entry) => {
5569
- const head = `${pc4.cyan(entry.declaration.name)} ${pc4.dim(`\u2014 declared by ${entry.declaration.module}`)}`;
5570
- if (entry.file) {
5571
- return `${head}
5572
- ${pc4.dim(`\u2192 ${entry.file}`)}`;
5573
- }
5574
- const why = entry.choices ? `several workspaces fit (${entry.choices.join(", ")}) \u2014 run \`saasaloy env\` to pick one` : "no target workspace found \u2014 this module wrote no files under a known alias";
5575
- return `${head}
5576
- ${pc4.yellow(`\u2192 ${why}`)}`;
5577
- });
5763
+ return pending.map(
5764
+ (entry) => `${pc4.cyan(entry.declaration.name)} ${pc4.dim(`\u2014 declared by ${entry.declaration.module}`)}`
5765
+ );
5578
5766
  }
5579
5767
  async function loadDeclarations(config, lock, sources, onSkip) {
5580
5768
  const override = process.env[REGISTRY_ENV];
@@ -5643,31 +5831,15 @@ async function loadDeclarations(config, lock, sources, onSkip) {
5643
5831
  return true;
5644
5832
  });
5645
5833
  }
5646
- function workspaceForAlias(aliases, alias, workspaces) {
5647
- const value = aliases[alias];
5648
- if (!value) {
5649
- return void 0;
5650
- }
5651
- return workspaceForPath(
5652
- value.endsWith("/") ? value : `${value}/`,
5653
- workspaces
5654
- );
5655
- }
5656
- async function writeAnswers(root, answers, contents) {
5657
- const written = [];
5658
- for (const [file, additions] of [...answers].toSorted(
5659
- ([a], [b]) => a.localeCompare(b)
5660
- )) {
5661
- if (additions.length === 0) {
5662
- continue;
5663
- }
5664
- const abs = resolveWithinRoot(root, file);
5665
- await assertNoSymlinkPath(root, abs);
5666
- await mkdir6(dirname8(abs), { recursive: true });
5667
- await writeFile8(abs, appendVars(contents.get(file), additions), "utf-8");
5668
- written.push([file, additions.length]);
5834
+ async function writeAnswers(root, additions, before) {
5835
+ if (additions.length === 0) {
5836
+ return 0;
5669
5837
  }
5670
- return written;
5838
+ const abs = resolveWithinRoot(root, LOCAL_VALUES);
5839
+ await assertNoSymlinkPath(root, abs);
5840
+ await mkdir6(dirname8(abs), { recursive: true });
5841
+ await writeFile8(abs, appendVars(before, additions), "utf-8");
5842
+ return additions.length;
5671
5843
  }
5672
5844
  async function readTarget(root, file) {
5673
5845
  const abs = resolveWithinRoot(root, file);
@@ -5691,62 +5863,34 @@ async function runEnv(argv) {
5691
5863
  try {
5692
5864
  const root = await findProjectRoot();
5693
5865
  const config = await loadConfig(root);
5694
- const manifest = await loadManifest(root);
5695
5866
  const lock = await loadLock(root);
5696
5867
  const declarations = await loadDeclarations(
5697
5868
  config,
5698
5869
  lock,
5699
5870
  sources,
5700
5871
  (msg) => {
5701
- log3.warn(msg);
5872
+ log4.warn(msg);
5702
5873
  }
5703
5874
  );
5704
5875
  if (declarations.length === 0) {
5705
5876
  outro3(pc4.dim("No module declares an environment variable."));
5706
5877
  return EXIT_OK;
5707
5878
  }
5708
- const workspaces = await discoverWorkspaces(root, config.aliases);
5709
- const wranglerWorkspaces = await findWranglerWorkspaces(root, workspaces);
5710
- const byModule = workspacesByModule(manifest, workspaces);
5711
- const api = apiWorkspace(config.aliases);
5712
- const base = workspaceForAlias(
5713
- config.aliases,
5714
- `@${config.base ?? "web"}`,
5715
- workspaces
5716
- );
5717
- const contents = /* @__PURE__ */ new Map();
5718
- const readOnce = async (file) => {
5719
- if (!contents.has(file)) {
5720
- contents.set(file, await readTarget(root, file));
5721
- }
5722
- return contents.get(file);
5723
- };
5724
- const pending = [];
5725
- const routed = [];
5726
- for (const declaration of declarations) {
5727
- const route = routeVariable({
5728
- candidates: byModule.get(declaration.module) ?? [],
5729
- name: declaration.name,
5730
- wranglerWorkspaces,
5731
- ...api ? { apiWorkspace: api } : {},
5732
- ...base ? { baseWorkspace: base } : {}
5733
- });
5734
- if (route.kind === "resolved") {
5735
- const file = `${route.workspace}/${targetFileName(declaration.name)}`;
5736
- routed.push({ name: declaration.name, workspace: route.workspace });
5737
- const values = parseDevVars(await readOnce(file) ?? "");
5738
- if (isSet(values, declaration.name)) {
5739
- continue;
5740
- }
5741
- pending.push({ declaration, file, workspace: route.workspace });
5742
- continue;
5743
- }
5744
- pending.push({
5745
- declaration,
5746
- ...route.kind === "ambiguous" ? { choices: route.choices } : {}
5747
- });
5879
+ const listed = new Set(
5880
+ Object.keys(parseEnvExample(await readTarget(root, ENV_EXAMPLE) ?? ""))
5881
+ );
5882
+ const undeclared = declarations.filter((declaration) => !listed.has(declaration.name)).map((declaration) => declaration.name);
5883
+ if (undeclared.length > 0) {
5884
+ log4.warn(
5885
+ `${ENV_EXAMPLE} does not list ${undeclared.join(", ")}, so no service reads ${undeclared.length === 1 ? "it" : "them"}. Re-run \`saasaloy add\` for the declaring module to regenerate the key list.`
5886
+ );
5748
5887
  }
5749
- const secretCommands = productionSecretCommands(routed);
5888
+ const before = await readTarget(root, LOCAL_VALUES);
5889
+ const values = parseEnvValues(before ?? "");
5890
+ const pending = declarations.filter((declaration) => !isSet(values, declaration.name)).map((declaration) => ({ declaration }));
5891
+ const secretCommands = productionSecretCommands(
5892
+ declarations.map((declaration) => declaration.name)
5893
+ );
5750
5894
  const printSecrets = () => {
5751
5895
  if (secretCommands.length > 0) {
5752
5896
  note3(
@@ -5764,16 +5908,11 @@ ${pc4.dim("Printed, never run \u2014 copy these when you deploy.")}`
5764
5908
  outro3(pc4.green("Every declared variable is set."));
5765
5909
  return EXIT_OK;
5766
5910
  }
5767
- const files = [
5768
- ...new Set(pending.flatMap((entry) => entry.file ? [entry.file] : []))
5769
- ].toSorted();
5770
- for (const file of files) {
5771
- if (!await isPathIgnored(root, file)) {
5772
- cancel3(
5773
- `${pc4.cyan(file)} isn't gitignored \u2014 refusing to write a secret into a tracked file. Add it to .gitignore (the base template already does) and run again.`
5774
- );
5775
- return EXIT_REFUSED;
5776
- }
5911
+ if (!await isPathIgnored(root, LOCAL_VALUES)) {
5912
+ cancel3(
5913
+ `${pc4.cyan(LOCAL_VALUES)} isn't gitignored \u2014 refusing to write a secret into a tracked file. Add it to .gitignore (the base template already does) and run again.`
5914
+ );
5915
+ return EXIT_REFUSED;
5777
5916
  }
5778
5917
  if (opts.check || !isInteractive()) {
5779
5918
  note3(wrapForNote(renderPending(pending).join("\n")), "Missing");
@@ -5784,42 +5923,11 @@ ${pc4.dim("Printed, never run \u2014 copy these when you deploy.")}`
5784
5923
  );
5785
5924
  return EXIT_REFUSED;
5786
5925
  }
5787
- const answers = /* @__PURE__ */ new Map();
5926
+ const answers = [];
5788
5927
  for (const entry of pending) {
5789
- let file = entry.file;
5790
- if (!file) {
5791
- if (!entry.choices) {
5792
- log3.warn(
5793
- `${pc4.cyan(entry.declaration.name)} \u2014 no target workspace found; set it by hand.`
5794
- );
5795
- continue;
5796
- }
5797
- const picked = await select2({
5798
- message: `Which workspace reads ${entry.declaration.name}?`,
5799
- options: entry.choices.map((workspace) => ({
5800
- label: workspace,
5801
- value: workspace
5802
- }))
5803
- });
5804
- if (isCancel2(picked)) {
5805
- cancel3("Cancelled \u2014 nothing written.");
5806
- return EXIT_REFUSED;
5807
- }
5808
- file = `${picked}/${targetFileName(entry.declaration.name)}`;
5809
- if (!await isPathIgnored(root, file)) {
5810
- cancel3(
5811
- `${pc4.cyan(file)} isn't gitignored \u2014 refusing to write a secret into a tracked file.`
5812
- );
5813
- return EXIT_REFUSED;
5814
- }
5815
- const values = parseDevVars(await readOnce(file) ?? "");
5816
- if (isSet(values, entry.declaration.name)) {
5817
- continue;
5818
- }
5819
- }
5820
5928
  const answer = await text({
5821
5929
  defaultValue: entry.declaration.devValue ?? "",
5822
- message: `${pc4.cyan(entry.declaration.name)} ${pc4.dim(`\u2192 ${file}`)}
5930
+ message: `${pc4.cyan(entry.declaration.name)} ${pc4.dim(`\u2192 ${LOCAL_VALUES}`)}
5823
5931
  ${entry.declaration.description}`,
5824
5932
  ...entry.declaration.devValue === void 0 ? {} : { initialValue: entry.declaration.devValue }
5825
5933
  });
@@ -5827,18 +5935,15 @@ ${entry.declaration.description}`,
5827
5935
  cancel3("Cancelled \u2014 nothing written.");
5828
5936
  return EXIT_REFUSED;
5829
5937
  }
5830
- answers.set(file, [
5831
- ...answers.get(file) ?? [],
5832
- [entry.declaration.name, answer]
5833
- ]);
5938
+ answers.push([entry.declaration.name, answer]);
5834
5939
  }
5835
- const written = await writeAnswers(root, answers, contents);
5836
- if (written.length > 0) {
5940
+ const written = await writeAnswers(root, answers, before);
5941
+ if (written > 0) {
5837
5942
  note3(
5838
5943
  wrapForNote(
5839
- written.map(
5840
- ([file, count]) => `${pc4.cyan(file)} ${pc4.dim(`\u2014 ${count} added`)}`
5841
- ).join("\n")
5944
+ `${pc4.cyan(LOCAL_VALUES)} ${pc4.dim(`\u2014 ${written} added`)}
5945
+
5946
+ ${pc4.dim("Run `pnpm env:setup` to write each service's own .env from it.")}`
5842
5947
  ),
5843
5948
  "Written"
5844
5949
  );
@@ -5857,39 +5962,39 @@ ${entry.declaration.description}`,
5857
5962
  }
5858
5963
 
5859
5964
  // src/commands/init.ts
5860
- import { spawn } from "child_process";
5965
+ import { spawn as spawn2 } from "child_process";
5861
5966
  import { readdir as readdir4, readFile as readFile16 } from "fs/promises";
5862
- import { basename as basename2, join as join14, resolve as resolve6 } from "path";
5967
+ import { basename as basename2, join as join15, resolve as resolve6 } from "path";
5863
5968
  import {
5864
5969
  cancel as cancel4,
5865
5970
  intro as intro4,
5866
5971
  isCancel as isCancel3,
5867
5972
  note as note4,
5868
5973
  outro as outro4,
5869
- select as select3,
5974
+ select as select2,
5870
5975
  spinner,
5871
5976
  text as text2
5872
5977
  } from "@clack/prompts";
5873
5978
  import pc6 from "picocolors";
5874
5979
 
5875
5980
  // src/lib/logger.ts
5876
- import { log as log4 } from "@clack/prompts";
5981
+ import { log as log5 } from "@clack/prompts";
5877
5982
  import pc5 from "picocolors";
5878
5983
  var logger = {
5879
5984
  error(message) {
5880
- log4.error(message);
5985
+ log5.error(message);
5881
5986
  },
5882
5987
  info(message) {
5883
- log4.info(message);
5988
+ log5.info(message);
5884
5989
  },
5885
5990
  step(message) {
5886
- log4.step(pc5.dim(message));
5991
+ log5.step(pc5.dim(message));
5887
5992
  },
5888
5993
  success(message) {
5889
- log4.success(message);
5994
+ log5.success(message);
5890
5995
  },
5891
5996
  warn(message) {
5892
- log4.warn(message);
5997
+ log5.warn(message);
5893
5998
  }
5894
5999
  };
5895
6000
 
@@ -5936,7 +6041,7 @@ function parseArgs4(argv) {
5936
6041
  }
5937
6042
  function run(command, args, cwd) {
5938
6043
  return new Promise((resolvePromise) => {
5939
- const child = spawn(command, args, {
6044
+ const child = spawn2(command, args, {
5940
6045
  cwd,
5941
6046
  stdio: ["ignore", "pipe", "pipe"],
5942
6047
  shell: process.platform === "win32"
@@ -5971,7 +6076,10 @@ function runPnpmInstall(cwd) {
5971
6076
  async function initGitRepo(cwd) {
5972
6077
  const inside = await run("git", ["rev-parse", "--is-inside-work-tree"], cwd);
5973
6078
  if (inside.ok) {
5974
- return { created: false };
6079
+ const ignored = await run("git", ["check-ignore", "--quiet", "."], cwd);
6080
+ if (!ignored.ok) {
6081
+ return { created: false };
6082
+ }
5975
6083
  }
5976
6084
  const result = await run("git", ["init"], cwd);
5977
6085
  if (!result.ok) {
@@ -5992,7 +6100,7 @@ function bySkillOrder(a, b) {
5992
6100
  }
5993
6101
  async function linkAgentSkills(target) {
5994
6102
  const result = { linked: [], conflicts: [], failures: [] };
5995
- const skillsDir = join14(target, ".agents", "skills");
6103
+ const skillsDir = join15(target, ".agents", "skills");
5996
6104
  let names;
5997
6105
  try {
5998
6106
  names = await readDirNames(skillsDir);
@@ -6001,8 +6109,8 @@ async function linkAgentSkills(target) {
6001
6109
  return result;
6002
6110
  }
6003
6111
  for (const name of names.toSorted(bySkillOrder)) {
6004
- const linkAbs = join14(target, ".claude", "skills", name);
6005
- const targetAbs = join14(skillsDir, name);
6112
+ const linkAbs = join15(target, ".claude", "skills", name);
6113
+ const targetAbs = join15(skillsDir, name);
6006
6114
  let state;
6007
6115
  try {
6008
6116
  state = await classifyLink(linkAbs, targetAbs);
@@ -6160,7 +6268,7 @@ ${pc6.dim("Symlinked for Claude Code \u2014 the skill files live in `.agents/ski
6160
6268
  );
6161
6269
  }
6162
6270
  let installed = false;
6163
- const wantsInstall = noInstall ? false : await select3({
6271
+ const wantsInstall = noInstall ? false : await select2({
6164
6272
  message: "Install dependencies now?",
6165
6273
  options: [
6166
6274
  { value: true, label: `Yes, run ${pc6.cyan("pnpm install")}` },
@@ -6195,7 +6303,7 @@ ${pc6.dim("Symlinked for Claude Code \u2014 the skill files live in `.agents/ski
6195
6303
  // owner who never opens AGENTS.md would otherwise not know they exist.
6196
6304
  // Pad to the same column the commands above comment at (`pnpm dev` + 21 spaces).
6197
6305
  ...skills.linked.map(
6198
- (name) => `${`/${name}`.padEnd(29)}${pc6.dim("# in Claude Code")}`
6306
+ (name) => `${`/${name}`.padEnd(29)}${pc6.dim("# in your AI coding tool")}`
6199
6307
  )
6200
6308
  ].filter((line) => line !== null).map((line) => pc6.cyan(line)).join("\n");
6201
6309
  note4(steps, "Next steps");
@@ -6205,7 +6313,7 @@ ${pc6.dim("Symlinked for Claude Code \u2014 the skill files live in `.agents/ski
6205
6313
 
6206
6314
  // src/commands/list.ts
6207
6315
  import { cancel as cancel5, intro as intro5, note as note5, outro as outro5 } from "@clack/prompts";
6208
- import { join as join15 } from "path";
6316
+ import { join as join16 } from "path";
6209
6317
  import pc7 from "picocolors";
6210
6318
  var KNOWN_FLAGS5 = /* @__PURE__ */ new Set(["--installed", "--available", "--help", "-h"]);
6211
6319
  var USAGE5 = "saasaloy list [<owner/repo[@ref]>] [--installed] [--available]";
@@ -6238,7 +6346,7 @@ function parseArgs5(argv) {
6238
6346
  }
6239
6347
  async function localProject() {
6240
6348
  const root = await findProjectRoot();
6241
- if (!await pathExists(join15(root, CONFIG_FILE))) {
6349
+ if (!await pathExists(join16(root, CONFIG_FILE))) {
6242
6350
  return { installed: /* @__PURE__ */ new Set() };
6243
6351
  }
6244
6352
  const config = await loadConfig(root);
@@ -6338,21 +6446,21 @@ async function runList(argv) {
6338
6446
  }
6339
6447
 
6340
6448
  // src/commands/new.ts
6341
- import { join as join17, relative as relative4, resolve as resolve7 } from "path";
6449
+ import { join as join18, relative as relative4, resolve as resolve7 } from "path";
6342
6450
  import {
6343
6451
  cancel as cancel6,
6344
6452
  intro as intro6,
6345
6453
  isCancel as isCancel4,
6346
6454
  note as note6,
6347
6455
  outro as outro6,
6348
- select as select4,
6456
+ select as select3,
6349
6457
  text as text3
6350
6458
  } from "@clack/prompts";
6351
6459
  import pc8 from "picocolors";
6352
6460
 
6353
6461
  // src/lib/new-module.ts
6354
6462
  import { mkdir as mkdir7, writeFile as writeFile9 } from "fs/promises";
6355
- import { dirname as dirname9, join as join16 } from "path";
6463
+ import { dirname as dirname9, join as join17 } from "path";
6356
6464
  var MODULE_TYPES = [
6357
6465
  "saasaloy:capability",
6358
6466
  "saasaloy:feature"
@@ -6456,7 +6564,7 @@ function moduleFiles(spec) {
6456
6564
  async function writeModule(dir, spec) {
6457
6565
  const files = moduleFiles(spec);
6458
6566
  for (const file of files) {
6459
- const abs = join16(dir, ...file.path.split("/"));
6567
+ const abs = join17(dir, ...file.path.split("/"));
6460
6568
  await mkdir7(dirname9(abs), { recursive: true });
6461
6569
  await writeFile9(abs, file.content, "utf-8");
6462
6570
  }
@@ -6544,7 +6652,7 @@ async function runNew(argv) {
6544
6652
  }
6545
6653
  try {
6546
6654
  const projectRoot = await findProjectRoot();
6547
- if (await pathExists(join17(projectRoot, CONFIG_FILE))) {
6655
+ if (await pathExists(join18(projectRoot, CONFIG_FILE))) {
6548
6656
  cancel6(
6549
6657
  `${CONFIG_FILE} is present at ${pc8.cyan(projectRoot)} \u2014 this is a generated project, and \`new module\` authors a module for a registry. Run it in the registry repo instead. Nothing was written.`
6550
6658
  );
@@ -6572,7 +6680,7 @@ async function runNew(argv) {
6572
6680
  return EXIT_REFUSED;
6573
6681
  }
6574
6682
  const registryDir = resolve7(process.cwd(), REGISTRY_DIR);
6575
- const dir = join17(registryDir, name);
6683
+ const dir = join18(registryDir, name);
6576
6684
  if (await pathExists(dir)) {
6577
6685
  cancel6(
6578
6686
  `${pc8.cyan(`${REGISTRY_DIR}/${name}`)} already exists \u2014 pick another name, or edit it by hand. Nothing was written.`
@@ -6589,7 +6697,7 @@ async function runNew(argv) {
6589
6697
  }
6590
6698
  type = opts.type;
6591
6699
  } else if (isInteractive()) {
6592
- const picked = await select4({
6700
+ const picked = await select3({
6593
6701
  message: "Which tier is this?",
6594
6702
  options: MODULE_TYPES.map((value) => ({
6595
6703
  hint: TYPE_HINTS[value],
@@ -6662,16 +6770,16 @@ async function runNew(argv) {
6662
6770
  }
6663
6771
 
6664
6772
  // src/commands/outdated.ts
6665
- import { cancel as cancel7, intro as intro7, log as log5, note as note7, outro as outro7 } from "@clack/prompts";
6773
+ import { cancel as cancel7, intro as intro7, log as log6, note as note7, outro as outro7 } from "@clack/prompts";
6666
6774
  import pc9 from "picocolors";
6667
6775
 
6668
6776
  // src/lib/updater.ts
6669
6777
  import { mkdir as mkdir8, readFile as readFile18, rm as rmPath2, writeFile as writeFile11 } from "fs/promises";
6670
- import { dirname as dirname11, posix as posix9 } from "path";
6778
+ import { dirname as dirname11, posix as posix7 } from "path";
6671
6779
 
6672
6780
  // src/lib/remover.ts
6673
6781
  import { readdir as readdir5, readFile as readFile17, rm as rmPath, writeFile as writeFile10 } from "fs/promises";
6674
- import { dirname as dirname10, join as join18, relative as relative5, sep as sep3 } from "path";
6782
+ import { dirname as dirname10, join as join19, relative as relative5, sep as sep3 } from "path";
6675
6783
  function classifyTrackedFile(content, manifestHash) {
6676
6784
  if (content === void 0) {
6677
6785
  return "missing";
@@ -6815,7 +6923,7 @@ async function pruneEmptyDirs(root, deletedAbsPaths) {
6815
6923
  async function dropDanglingAliases(root, config) {
6816
6924
  const dropped = [];
6817
6925
  for (const [alias, prefix] of Object.entries(config.aliases)) {
6818
- const prefixAbs = join18(root, ...prefix.split("/"));
6926
+ const prefixAbs = join19(root, ...prefix.split("/"));
6819
6927
  if (!await pathExists(prefixAbs)) {
6820
6928
  delete config.aliases[alias];
6821
6929
  dropped.push(alias);
@@ -7352,9 +7460,9 @@ function classifyUpdate(base, theirs, mine, managedHash, adopted, projectOwned)
7352
7460
  async function planLinks(root, theirs) {
7353
7461
  const links = [];
7354
7462
  for (const skillRel of theirs.item.agent?.skills ?? []) {
7355
- const folderName = posix9.basename(skillRel);
7356
- const path = posix9.join(".claude/skills", folderName);
7357
- const target = posix9.join(".agents/skills", folderName);
7463
+ const folderName = posix7.basename(skillRel);
7464
+ const path = posix7.join(".claude/skills", folderName);
7465
+ const target = posix7.join(".agents/skills", folderName);
7358
7466
  const pathAbs = resolveWithinRoot(root, path);
7359
7467
  const targetAbs = resolveWithinRoot(root, target);
7360
7468
  const state = await classifyLink(pathAbs, targetAbs);
@@ -7708,7 +7816,7 @@ async function runOutdated(argv) {
7708
7816
  const manifest = await loadManifest(root);
7709
7817
  const registryOverride = !!process.env[REGISTRY_ENV];
7710
7818
  if (registryOverride && config.installed.length > 0) {
7711
- log5.warn(
7819
+ log6.warn(
7712
7820
  `${REGISTRY_ENV} is set, so every module reads as local \u2014 unset it to compare against the registry.`
7713
7821
  );
7714
7822
  }
@@ -7789,10 +7897,10 @@ import {
7789
7897
  confirm as confirm2,
7790
7898
  intro as intro8,
7791
7899
  isCancel as isCancel5,
7792
- log as log6,
7900
+ log as log7,
7793
7901
  note as note8,
7794
7902
  outro as outro8,
7795
- select as select5
7903
+ select as select4
7796
7904
  } from "@clack/prompts";
7797
7905
  import pc10 from "picocolors";
7798
7906
  var KNOWN_FLAGS8 = /* @__PURE__ */ new Set([
@@ -7891,12 +7999,12 @@ function summarizeRemovePlan(plan, name, yes) {
7891
7999
  note8(wrapForNote(linkLines.join("\n")), "Skill links");
7892
8000
  }
7893
8001
  if (plan.dependents.length > 0) {
7894
- log6.warn(
8002
+ log7.warn(
7895
8003
  `${pc10.cyan(name)} is still depended on by ${plan.dependents.join(", ")} \u2014 proceeding anyway (--force).`
7896
8004
  );
7897
8005
  }
7898
8006
  for (const missingLock of plan.missingLockEntries) {
7899
- log6.warn(
8007
+ log7.warn(
7900
8008
  `Installed module ${pc10.cyan(missingLock)} has no lock entry \u2014 dependent detection is incomplete for it.`
7901
8009
  );
7902
8010
  }
@@ -7912,7 +8020,7 @@ function summarizeRemovePlan(plan, name, yes) {
7912
8020
  if (p.action !== "drop") {
7913
8021
  continue;
7914
8022
  }
7915
- log6.warn(
8023
+ log7.warn(
7916
8024
  `Config patch on ${pc10.cyan(p.entry.file)} ${pc10.dim(`(${p.entry.patch.kind})`)} is not reversed by \`remove\` \u2014 hand-revert it if needed.`
7917
8025
  );
7918
8026
  }
@@ -7953,7 +8061,7 @@ async function runRemove(argv) {
7953
8061
  );
7954
8062
  return EXIT_REFUSED;
7955
8063
  }
7956
- const picked = await select5({
8064
+ const picked = await select4({
7957
8065
  message: "Pick a module to remove",
7958
8066
  options: config.installed.map((n) => ({ value: n, label: n }))
7959
8067
  });
@@ -7978,7 +8086,7 @@ async function runRemove(argv) {
7978
8086
  }
7979
8087
  summarizeRemovePlan(plan, name, opts.yes);
7980
8088
  for (const warning of plan.warnings) {
7981
- log6.warn(warning);
8089
+ log7.warn(warning);
7982
8090
  }
7983
8091
  if (opts.diff) {
7984
8092
  for (const file of plan.files) {
@@ -8053,24 +8161,28 @@ async function runRemove(argv) {
8053
8161
  throw failedSave.error;
8054
8162
  }
8055
8163
  for (const file of result.deleted) {
8056
- log6.step(`${pc10.red("delete")} ${file.target}`);
8164
+ log7.step(`${pc10.red("delete")} ${file.target}`);
8057
8165
  }
8058
8166
  for (const file of result.missingUntracked) {
8059
- log6.step(
8167
+ log7.step(
8060
8168
  `${pc10.dim("untrack")} ${file.target} ${pc10.dim("(already gone)")}`
8061
8169
  );
8062
8170
  }
8063
8171
  for (const link of result.linksRemoved) {
8064
- log6.step(`${pc10.red("unlink")} ${link.path}`);
8172
+ log7.step(`${pc10.red("unlink")} ${link.path}`);
8065
8173
  }
8066
8174
  for (const link of result.linkConflicts) {
8067
- log6.warn(
8175
+ log7.warn(
8068
8176
  `Skill link ${pc10.cyan(link.path)} left untouched \u2014 not ours to remove.`
8069
8177
  );
8070
8178
  }
8071
8179
  for (const p of result.patchesReversed) {
8072
- log6.step(`${pc10.red("revert")} ${p.file} ${pc10.dim(`(${p.patch.kind})`)}`);
8180
+ log7.step(`${pc10.red("revert")} ${p.file} ${pc10.dim(`(${p.patch.kind})`)}`);
8073
8181
  }
8182
+ await formatPatchedFiles(
8183
+ root,
8184
+ result.patchesReversed.map((p) => p.file)
8185
+ );
8074
8186
  const refusals = new Map(
8075
8187
  result.patchRefusals.map((r) => [r.patch, r.reason])
8076
8188
  );
@@ -8079,15 +8191,15 @@ async function runRemove(argv) {
8079
8191
  continue;
8080
8192
  }
8081
8193
  const reason = refusals.get(p);
8082
- log6.warn(
8194
+ log7.warn(
8083
8195
  reason === void 0 ? `Config patch on ${pc10.cyan(p.file)} ${pc10.dim(`(${p.patch.kind})`)} was already gone \u2014 nothing to revert.` : `Config patch on ${pc10.cyan(p.file)} ${pc10.dim(`(${p.patch.kind})`)} left untouched \u2014 ${reason}.`
8084
8196
  );
8085
8197
  }
8086
8198
  for (const dir of result.prunedDirs) {
8087
- log6.step(`${pc10.dim("prune")} ${dir}`);
8199
+ log7.step(`${pc10.dim("prune")} ${dir}`);
8088
8200
  }
8089
8201
  for (const alias of result.droppedAliases) {
8090
- log6.warn(
8202
+ log7.warn(
8091
8203
  `Alias ${pc10.cyan(alias)} dropped \u2014 its target directory is gone.`
8092
8204
  );
8093
8205
  }
@@ -8128,13 +8240,13 @@ ${pc10.dim("Hand-edited since \u2014 left on disk, now untracked.")}`
8128
8240
 
8129
8241
  // src/commands/update.ts
8130
8242
  import { mkdir as mkdir10, realpath, writeFile as writeFile13 } from "fs/promises";
8131
- import { basename as basename4, dirname as dirname13, isAbsolute as isAbsolute4, join as join20, resolve as resolve8 } from "path";
8243
+ import { basename as basename4, dirname as dirname13, isAbsolute as isAbsolute4, join as join21, resolve as resolve8 } from "path";
8132
8244
  import {
8133
8245
  cancel as cancel9,
8134
8246
  confirm as confirm3,
8135
8247
  intro as intro9,
8136
8248
  isCancel as isCancel6,
8137
- log as log7,
8249
+ log as log8,
8138
8250
  note as note9,
8139
8251
  outro as outro9
8140
8252
  } from "@clack/prompts";
@@ -8149,14 +8261,14 @@ import {
8149
8261
  rm as rmPath3,
8150
8262
  writeFile as writeFile12
8151
8263
  } from "fs/promises";
8152
- import { dirname as dirname12, join as join19 } from "path";
8153
- var BACKUP_DIR = join19(".saasaloy", "backups");
8264
+ import { dirname as dirname12, join as join20 } from "path";
8265
+ var BACKUP_DIR = join20(".saasaloy", "backups");
8154
8266
  var RECORD_FILE = "backup.json";
8155
8267
  var STATE_FILES = [
8156
8268
  "saasaloy.json",
8157
8269
  "saasaloy-lock.json",
8158
8270
  "package.json",
8159
- join19(".saasaloy", "manifest.json")
8271
+ join20(".saasaloy", "manifest.json")
8160
8272
  ];
8161
8273
  function stampName(now) {
8162
8274
  return now.toISOString().replaceAll(/[:.]/g, "-");
@@ -8164,8 +8276,8 @@ function stampName(now) {
8164
8276
  async function writeBackup(args) {
8165
8277
  const { root, targets, cliVersion } = args;
8166
8278
  const createdAt = (args.now ?? /* @__PURE__ */ new Date()).toISOString();
8167
- const rel = join19(BACKUP_DIR, stampName(args.now ?? new Date(createdAt)));
8168
- const dir = join19(root, rel);
8279
+ const rel = join20(BACKUP_DIR, stampName(args.now ?? new Date(createdAt)));
8280
+ const dir = join20(root, rel);
8169
8281
  const files = [];
8170
8282
  const absent = [];
8171
8283
  for (const target of /* @__PURE__ */ new Set([...targets, ...STATE_FILES])) {
@@ -8174,7 +8286,7 @@ async function writeBackup(args) {
8174
8286
  absent.push(target);
8175
8287
  continue;
8176
8288
  }
8177
- const destination = join19(dir, target);
8289
+ const destination = join20(dir, target);
8178
8290
  await mkdir9(dirname12(destination), { recursive: true });
8179
8291
  await copyFile(source, destination);
8180
8292
  files.push(target);
@@ -8187,7 +8299,7 @@ async function writeBackup(args) {
8187
8299
  };
8188
8300
  await mkdir9(dir, { recursive: true });
8189
8301
  await writeFile12(
8190
- join19(dir, RECORD_FILE),
8302
+ join20(dir, RECORD_FILE),
8191
8303
  `${JSON.stringify(record, null, 2)}
8192
8304
  `,
8193
8305
  "utf-8"
@@ -8195,7 +8307,7 @@ async function writeBackup(args) {
8195
8307
  return rel;
8196
8308
  }
8197
8309
  async function listBackups(root) {
8198
- const dir = join19(root, BACKUP_DIR);
8310
+ const dir = join20(root, BACKUP_DIR);
8199
8311
  if (!await pathExists(dir)) {
8200
8312
  return [];
8201
8313
  }
@@ -8210,25 +8322,25 @@ async function restoreLatestBackup(root) {
8210
8322
  `No backup to restore \u2014 nothing under ${BACKUP_DIR}. \`saasaloy update\` writes one before it applies a plan.`
8211
8323
  );
8212
8324
  }
8213
- const rel = join19(BACKUP_DIR, newest);
8214
- const dir = join19(root, rel);
8215
- const raw = await readIfPresent(join19(dir, RECORD_FILE));
8325
+ const rel = join20(BACKUP_DIR, newest);
8326
+ const dir = join20(root, rel);
8327
+ const raw = await readIfPresent(join20(dir, RECORD_FILE));
8216
8328
  if (raw === void 0) {
8217
8329
  throw new RefusalError(
8218
- `${join19(rel, RECORD_FILE)} is missing \u2014 that backup can't be restored safely.`
8330
+ `${join20(rel, RECORD_FILE)} is missing \u2014 that backup can't be restored safely.`
8219
8331
  );
8220
8332
  }
8221
8333
  let record;
8222
8334
  try {
8223
8335
  record = JSON.parse(raw);
8224
8336
  } catch (error) {
8225
- throw new RefusalError(`${join19(rel, RECORD_FILE)} is invalid.`, {
8337
+ throw new RefusalError(`${join20(rel, RECORD_FILE)} is invalid.`, {
8226
8338
  cause: error
8227
8339
  });
8228
8340
  }
8229
8341
  const restored = [];
8230
8342
  for (const target of record.files) {
8231
- const source = join19(dir, target);
8343
+ const source = join20(dir, target);
8232
8344
  const destination = resolveWithinRoot(root, target);
8233
8345
  if (!await pathExists(source)) {
8234
8346
  continue;
@@ -8670,18 +8782,18 @@ function summarizePlan2(plan) {
8670
8782
  lines.push(pc11.dim(`${writing} file(s) to apply, ${merging} needing merge`));
8671
8783
  note9(wrapForNote(lines.join("\n")), "Plan", TUI_ON_STDERR);
8672
8784
  for (const comparison of plan.skipped) {
8673
- log7.info(
8785
+ log8.info(
8674
8786
  `${pc11.cyan(comparison.name)} ${pc11.dim(`\u2014 ${skipReason(comparison)}`)}`,
8675
8787
  TUI_ON_STDERR
8676
8788
  );
8677
8789
  }
8678
8790
  for (const mod of plan.modules) {
8679
8791
  for (const conflict of mod.depConflicts) {
8680
- log7.warn(`Dependency version conflict \u2014 ${conflict}.`, TUI_ON_STDERR);
8792
+ log8.warn(`Dependency version conflict \u2014 ${conflict}.`, TUI_ON_STDERR);
8681
8793
  }
8682
8794
  for (const patch of mod.patches) {
8683
8795
  if (patch.action === "missing") {
8684
- log7.warn(
8796
+ log8.warn(
8685
8797
  `Config patch target ${pc11.cyan(patch.file)} is missing \u2014 ${patch.patch.kind} not applied.`,
8686
8798
  TUI_ON_STDERR
8687
8799
  );
@@ -8753,7 +8865,7 @@ async function canonicalPath(path) {
8753
8865
  return await realpath(path);
8754
8866
  } catch {
8755
8867
  try {
8756
- return join20(await realpath(dirname13(path)), basename4(path));
8868
+ return join21(await realpath(dirname13(path)), basename4(path));
8757
8869
  } catch {
8758
8870
  return path;
8759
8871
  }
@@ -8770,7 +8882,7 @@ async function emitMergePlan(document, out) {
8770
8882
  const target = resolvePlanOut(out);
8771
8883
  await mkdir10(dirname13(target), { recursive: true });
8772
8884
  await writeFile13(target, document, "utf-8");
8773
- log7.success(`Merge plan written to ${pc11.cyan(out)}`, TUI_ON_STDERR);
8885
+ log8.success(`Merge plan written to ${pc11.cyan(out)}`, TUI_ON_STDERR);
8774
8886
  }
8775
8887
  function backupTargets(plan) {
8776
8888
  const targets = /* @__PURE__ */ new Set();
@@ -8827,10 +8939,10 @@ async function runUpdate(argv) {
8827
8939
  try {
8828
8940
  const restored = await restoreLatestBackup(root);
8829
8941
  for (const target of restored.restored) {
8830
- log7.step(`${pc11.green("restore")} ${target}`, TUI_ON_STDERR);
8942
+ log8.step(`${pc11.green("restore")} ${target}`, TUI_ON_STDERR);
8831
8943
  }
8832
8944
  for (const target of restored.removed) {
8833
- log7.step(
8945
+ log8.step(
8834
8946
  `${pc11.red("remove")} ${target} ${pc11.dim("(the update created it)")}`,
8835
8947
  TUI_ON_STDERR
8836
8948
  );
@@ -8897,7 +9009,7 @@ async function runUpdate(argv) {
8897
9009
  if (wantsBase) {
8898
9010
  templateDir = await baseTemplateDir();
8899
9011
  if (project.source === "directory") {
8900
- log7.warn(
9012
+ log8.warn(
8901
9013
  `No \`name\` in ${CONFIG_FILE} and no root package.json name \u2014 falling back to the directory name ${pc11.cyan(project.name)}. Set \`name\` in ${CONFIG_FILE} if that isn't this project's name.`,
8902
9014
  TUI_ON_STDERR
8903
9015
  );
@@ -8947,7 +9059,7 @@ async function runUpdate(argv) {
8947
9059
  return EXIT_REFUSED;
8948
9060
  }
8949
9061
  if (recorded !== cliVersion) {
8950
- log7.info(
9062
+ log8.info(
8951
9063
  `Base recorded at CLI ${pc11.cyan(recorded)}; ${pc11.cyan(cliVersion)} is running.`,
8952
9064
  TUI_ON_STDERR
8953
9065
  );
@@ -8989,7 +9101,7 @@ async function runUpdate(argv) {
8989
9101
  return entry && entry.source !== "local";
8990
9102
  });
8991
9103
  if (fromRemote.length > 0) {
8992
- log7.warn(
9104
+ log8.warn(
8993
9105
  `${REGISTRY_ENV} is set, so ${fromRemote.join(", ")} will be updated from that working copy rather than ${pc11.cyan(lock.modules[fromRemote[0]]?.source ?? "the registry")} \u2014 ${pc11.dim("there is no merge base for a working copy")}.`,
8994
9106
  TUI_ON_STDERR
8995
9107
  );
@@ -9014,7 +9126,7 @@ async function runUpdate(argv) {
9014
9126
  }
9015
9127
  }
9016
9128
  for (const comparison of skipped) {
9017
- log7.info(
9129
+ log8.info(
9018
9130
  `${pc11.cyan(comparison.name)} ${pc11.dim(`\u2014 ${skipReason(comparison, preview)}`)}`,
9019
9131
  TUI_ON_STDERR
9020
9132
  );
@@ -9137,7 +9249,7 @@ async function runUpdate(argv) {
9137
9249
  }
9138
9250
  }
9139
9251
  if (missingLockEntries.size > 0) {
9140
- log7.warn(
9252
+ log8.warn(
9141
9253
  `No lock entry for ${[...missingLockEntries].map((m) => pc11.cyan(m)).join(", ")} \u2014 any conflict they declare can't be checked ${pc11.dim("(re-add them to record it)")}.`,
9142
9254
  TUI_ON_STDERR
9143
9255
  );
@@ -9154,9 +9266,9 @@ async function runUpdate(argv) {
9154
9266
  for (const comparison of skipped) {
9155
9267
  const line = `${pc11.cyan(comparison.name)} ${pc11.dim(`\u2014 ${skipReason(comparison)}`)}`;
9156
9268
  if (comparison.status === "unresolvable") {
9157
- log7.warn(line, TUI_ON_STDERR);
9269
+ log8.warn(line, TUI_ON_STDERR);
9158
9270
  } else {
9159
- log7.info(line, TUI_ON_STDERR);
9271
+ log8.info(line, TUI_ON_STDERR);
9160
9272
  }
9161
9273
  }
9162
9274
  outro9(pc11.yellow("Nothing could be updated."), TUI_ON_STDERR);
@@ -9164,7 +9276,7 @@ async function runUpdate(argv) {
9164
9276
  }
9165
9277
  const pkg = await readRootPackageJson(root);
9166
9278
  if (!pkg) {
9167
- log7.warn(
9279
+ log8.warn(
9168
9280
  "No package.json at the project root \u2014 dependency pins won't be updated.",
9169
9281
  TUI_ON_STDERR
9170
9282
  );
@@ -9224,7 +9336,7 @@ async function runUpdate(argv) {
9224
9336
  targets: backupTargets(plan),
9225
9337
  cliVersion
9226
9338
  });
9227
- log7.info(
9339
+ log8.info(
9228
9340
  `Pre-update copies in ${pc11.cyan(backupDir)} ${pc11.dim("\u2014 `saasaloy update --abort` restores them")}`,
9229
9341
  TUI_ON_STDERR
9230
9342
  );
@@ -9248,6 +9360,11 @@ async function runUpdate(argv) {
9248
9360
  }
9249
9361
  const result = outcome.value;
9250
9362
  reportResult(result);
9363
+ await formatPatchedFiles(
9364
+ root,
9365
+ result.patched.map((p) => p.file),
9366
+ TUI_ON_STDERR
9367
+ );
9251
9368
  const document = renderMergePlan(plan);
9252
9369
  if (document) {
9253
9370
  await emitMergePlan(document, opts.out);
@@ -9297,37 +9414,37 @@ function summarizeOutcome(plan, result) {
9297
9414
  }
9298
9415
  function reportResult(result) {
9299
9416
  for (const name of result.prereqsInstalled) {
9300
- log7.step(
9417
+ log8.step(
9301
9418
  `${pc11.green("install")} ${pc11.cyan(name)} ${pc11.dim("(new prerequisite)")}`,
9302
9419
  TUI_ON_STDERR
9303
9420
  );
9304
9421
  }
9305
9422
  for (const file of result.written) {
9306
- log7.step(`${ACTION_LABEL2[file.action]} ${file.target}`, TUI_ON_STDERR);
9423
+ log8.step(`${ACTION_LABEL2[file.action]} ${file.target}`, TUI_ON_STDERR);
9307
9424
  }
9308
9425
  for (const file of result.deleted) {
9309
- log7.step(`${pc11.red("delete")} ${file.target}`, TUI_ON_STDERR);
9426
+ log8.step(`${pc11.red("delete")} ${file.target}`, TUI_ON_STDERR);
9310
9427
  }
9311
9428
  for (const file of result.untracked) {
9312
- log7.step(
9429
+ log8.step(
9313
9430
  `${pc11.dim("untrack")} ${file.target} ${pc11.dim("(already gone)")}`,
9314
9431
  TUI_ON_STDERR
9315
9432
  );
9316
9433
  }
9317
9434
  for (const patch of result.patched) {
9318
- log7.step(
9435
+ log8.step(
9319
9436
  `${pc11.green("patch")} ${patch.file} ${pc11.dim(`\u2014 ${patch.patch.kind}`)}`,
9320
9437
  TUI_ON_STDERR
9321
9438
  );
9322
9439
  }
9323
9440
  for (const link of result.linkConflicts) {
9324
- log7.warn(
9441
+ log8.warn(
9325
9442
  `Skill link ${pc11.cyan(link.path)} left untouched \u2014 not ours to replace.`,
9326
9443
  TUI_ON_STDERR
9327
9444
  );
9328
9445
  }
9329
9446
  for (const dep of result.depsWritten) {
9330
- log7.step(`${pc11.cyan("dep")} ${dep.name}@${dep.version}`, TUI_ON_STDERR);
9447
+ log8.step(`${pc11.cyan("dep")} ${dep.name}@${dep.version}`, TUI_ON_STDERR);
9331
9448
  }
9332
9449
  if (result.lateDrift.length > 0) {
9333
9450
  const lines = result.lateDrift.map((f) => ` ${pc11.yellow("kept")} ${f.target}`).join("\n");
@@ -9407,8 +9524,8 @@ ${pc12.dim("Set SAASALOY_DEBUG=1 to print the full cause chain behind a failure.
9407
9524
  );
9408
9525
  }
9409
9526
  async function pickCommand(deps) {
9410
- const { registry, select: select6, isCancel: isCancel7 } = deps;
9411
- const picked = await select6({
9527
+ const { registry, select: select5, isCancel: isCancel7 } = deps;
9528
+ const picked = await select5({
9412
9529
  message: "What would you like to do?",
9413
9530
  options: Object.entries(registry).map(([name, command2]) => ({
9414
9531
  value: name,
@@ -9429,7 +9546,7 @@ async function pickCommand(deps) {
9429
9546
  async function main(argv, deps = {}) {
9430
9547
  const {
9431
9548
  registry = COMMANDS,
9432
- select: select6 = clackSelect,
9549
+ select: select5 = clackSelect,
9433
9550
  isCancel: isCancel7 = clackIsCancel
9434
9551
  } = deps;
9435
9552
  const [name, ...rest] = argv;
@@ -9446,7 +9563,7 @@ async function main(argv, deps = {}) {
9446
9563
  printHelp(registry);
9447
9564
  return EXIT_OK;
9448
9565
  }
9449
- return pickCommand({ registry, select: select6, isCancel: isCancel7 });
9566
+ return pickCommand({ registry, select: select5, isCancel: isCancel7 });
9450
9567
  }
9451
9568
  const command = Object.hasOwn(registry, name) ? registry[name] : void 0;
9452
9569
  if (!command) {