saasaloy 0.1.2 → 0.3.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 (28) hide show
  1. package/README.md +5 -4
  2. package/dist/index.js +735 -63
  3. package/dist/index.js.map +1 -1
  4. package/package.json +7 -7
  5. package/schemas/manifest.schema.json +6 -1
  6. package/schemas/registry-item.schema.json +47 -2
  7. package/schemas/saasaloy.schema.json +5 -0
  8. package/templates/base/AGENTS.md +8 -1
  9. package/templates/base/DESIGN.md +4 -0
  10. package/templates/base/_agents/skills/saasaloy-design/SKILL.md +1 -1
  11. package/templates/base/_gitignore +4 -1
  12. package/templates/base/_saasaloy-base.json +10 -0
  13. package/templates/base/apps/web/package.json +1 -1
  14. package/templates/base/package.json +4 -4
  15. package/templates/base/packages/ui/package.json +3 -3
  16. package/templates/base/packages/ui/src/blocks/footer.tsx +5 -1
  17. package/templates/base/packages/ui/src/components/avatar.tsx +107 -0
  18. package/templates/base/packages/ui/src/components/collapsible.tsx +19 -0
  19. package/templates/base/packages/ui/src/components/dropdown-menu.tsx +270 -0
  20. package/templates/base/packages/ui/src/components/scroll-area.tsx +52 -0
  21. package/templates/base/packages/ui/src/components/sheet.tsx +132 -0
  22. package/templates/base/packages/ui/src/components/table.tsx +114 -0
  23. package/templates/base/packages/ui/src/components/tabs.tsx +81 -0
  24. package/templates/base/packages/ui/src/components/tooltip.tsx +64 -0
  25. package/templates/base/packages/ui/src/lib/sentinel.ts +1 -1
  26. package/templates/base/packages/ui/src/lib/theme.ts +124 -1
  27. package/templates/base/packages/ui/src/styles/globals.css +28 -1
  28. package/templates/base/stylelint.config.js +8 -0
package/dist/index.js CHANGED
@@ -30,6 +30,7 @@ import { dirname as dirname3, posix as posix2 } from "path";
30
30
  var EXIT_OK = 0;
31
31
  var EXIT_FAILURE = 1;
32
32
  var EXIT_REFUSED = 2;
33
+ var EXIT_MERGE_PENDING = 3;
33
34
  var RefusalError = class extends Error {
34
35
  name = "RefusalError";
35
36
  };
@@ -489,7 +490,7 @@ function insertChainedRoute(source, patch) {
489
490
  return source;
490
491
  }
491
492
  if (!(patch.import.name in mod.imports)) {
492
- mod.imports.$add({
493
+ mod.imports.$prepend({
493
494
  from: patch.import.from,
494
495
  imported: patch.import.name,
495
496
  local: patch.import.name
@@ -863,6 +864,193 @@ function toDiff(before, after, filename) {
863
864
  return patch;
864
865
  }
865
866
 
867
+ // src/lib/patch/drizzle-column.ts
868
+ import { generateCode as generateCode2, parseModule as parseModule3 } from "magicast";
869
+ var COLUMNS_ARG = 1;
870
+ function insertDrizzleColumn(source, patch) {
871
+ const mod = parseModule3(source);
872
+ const columns = columnsObject(mod, patch);
873
+ if (!columns) {
874
+ return source;
875
+ }
876
+ if (indexOfColumn(columns, patch.column) !== -1) {
877
+ return source;
878
+ }
879
+ parseExpressionNode(patch.value);
880
+ if (patch.import && foreignBinding(mod.imports, patch.import)) {
881
+ return source;
882
+ }
883
+ const withColumn = spliceColumn(source, columns, patch);
884
+ if (!withColumn) {
885
+ return source;
886
+ }
887
+ return addImport(withColumn, patch);
888
+ }
889
+ function removeDrizzleColumn(source, patch) {
890
+ const mod = parseModule3(source);
891
+ const columns = columnsObject(mod, patch);
892
+ const at = columns ? indexOfColumn(columns, patch.column) : -1;
893
+ if (!columns || at === -1) {
894
+ return source;
895
+ }
896
+ if (valueDrift(columns, at, patch)) {
897
+ return source;
898
+ }
899
+ if (patch.import && removeBindingConflict2(mod.imports, patch.import)) {
900
+ return source;
901
+ }
902
+ const withoutColumn = cutColumn(source, columns, at);
903
+ if (!withoutColumn) {
904
+ return source;
905
+ }
906
+ return dropImport(withoutColumn, patch);
907
+ }
908
+ function spliceColumn(source, columns, patch) {
909
+ const last = columns.properties.at(-1);
910
+ const end = offset(last, "end");
911
+ if (end === void 0) {
912
+ return void 0;
913
+ }
914
+ const indent = indentOf(source, offset(last, "start") ?? end);
915
+ const comma = source.slice(end).trimStart().startsWith(",");
916
+ const at = comma ? end + source.slice(end).indexOf(",") + 1 : end;
917
+ const line = `${indent}${patch.column}: ${patch.value}`;
918
+ return comma ? `${source.slice(0, at)}
919
+ ${line},${source.slice(at)}` : `${source.slice(0, at)},
920
+ ${line}${source.slice(at)}`;
921
+ }
922
+ function cutColumn(source, columns, at) {
923
+ const property = columns.properties[at];
924
+ const start = offset(property, "start");
925
+ const end = offset(property, "end");
926
+ if (start === void 0 || end === void 0) {
927
+ return void 0;
928
+ }
929
+ const from = source.lastIndexOf("\n", start) + 1;
930
+ const after = source.indexOf("\n", end);
931
+ const to = after === -1 ? source.length : after + 1;
932
+ const head = source.slice(from, start);
933
+ const tail = source.slice(end, to);
934
+ if (head.trim() !== "" || tail.replace(/^,/u, "").trim() !== "") {
935
+ return void 0;
936
+ }
937
+ return source.slice(0, from) + source.slice(to);
938
+ }
939
+ function indentOf(source, at) {
940
+ const from = source.lastIndexOf("\n", at) + 1;
941
+ return /^[\t ]*/u.exec(source.slice(from, at))?.[0] ?? "";
942
+ }
943
+ function offset(node, end) {
944
+ const value = node?.[end];
945
+ return typeof value === "number" ? value : void 0;
946
+ }
947
+ function addImport(source, patch) {
948
+ if (!patch.import) {
949
+ return source;
950
+ }
951
+ const mod = parseModule3(source);
952
+ if (patch.import.name in mod.imports) {
953
+ return source;
954
+ }
955
+ mod.imports.$prepend({
956
+ from: patch.import.from,
957
+ imported: patch.import.name,
958
+ local: patch.import.name
959
+ });
960
+ return print(source, mod);
961
+ }
962
+ function dropImport(source, patch) {
963
+ if (!patch.import) {
964
+ return source;
965
+ }
966
+ const mod = parseModule3(source);
967
+ const program = mod.$ast;
968
+ if (!(patch.import.name in mod.imports) || isReferenced(program, patch.import.name)) {
969
+ return source;
970
+ }
971
+ delete mod.imports[patch.import.name];
972
+ return print(source, mod);
973
+ }
974
+ function drizzleColumnInsertRefusal(source, patch) {
975
+ const mod = parseModule3(source);
976
+ if (!mod.exports[patch.exportName]) {
977
+ return `drizzle-column could not find an export named ${JSON.stringify(patch.exportName)}`;
978
+ }
979
+ const columns = columnsObject(mod, patch);
980
+ if (!columns) {
981
+ return `${JSON.stringify(patch.exportName)} is not a Drizzle table call with a columns object as its second argument`;
982
+ }
983
+ const at = indexOfColumn(columns, patch.column);
984
+ if (at !== -1 && valueDrift(columns, at, patch)) {
985
+ return `${JSON.stringify(patch.column)} on ${JSON.stringify(patch.exportName)} holds ${printed(columns, at)}, not the ${patch.value} this patch adds`;
986
+ }
987
+ if (at === -1 && patch.import) {
988
+ return insertBindingConflict2(mod.imports, patch);
989
+ }
990
+ return void 0;
991
+ }
992
+ function drizzleColumnRemoveRefusal(source, patch) {
993
+ const mod = parseModule3(source);
994
+ const columns = columnsObject(mod, patch);
995
+ const at = columns ? indexOfColumn(columns, patch.column) : -1;
996
+ if (!columns || at === -1) {
997
+ return void 0;
998
+ }
999
+ return valueDrift(columns, at, patch) ?? (patch.import ? removeBindingConflict2(mod.imports, patch.import) : void 0);
1000
+ }
1001
+ function columnsObject(mod, patch) {
1002
+ const { exports } = mod;
1003
+ const node = exports[patch.exportName]?.$args?.[COLUMNS_ARG]?.$ast;
1004
+ return node?.type === "ObjectExpression" ? node : void 0;
1005
+ }
1006
+ function indexOfColumn(columns, column) {
1007
+ return columns.properties.findIndex(
1008
+ (property) => property.key?.name === column || property.key?.value === column
1009
+ );
1010
+ }
1011
+ function parseExpressionNode(code) {
1012
+ const declaration = parseModule3(`const __drizzleColumn = ${code};`).$ast;
1013
+ const init = declaration.body.length === 1 ? declaration.body[0]?.declarations?.[0]?.init : void 0;
1014
+ if (!init) {
1015
+ throw new TypeError(
1016
+ `drizzle-column value is not a single expression: ${JSON.stringify(code)}`
1017
+ );
1018
+ }
1019
+ return init;
1020
+ }
1021
+ function printed(columns, at) {
1022
+ return generateCode2(columns.properties[at]?.value).code;
1023
+ }
1024
+ function valueDrift(columns, at, patch) {
1025
+ if (collapse(printed(columns, at)) === collapse(patch.value)) {
1026
+ return void 0;
1027
+ }
1028
+ return `${JSON.stringify(patch.column)} on ${JSON.stringify(patch.exportName)} reads ${printed(columns, at)} now, not the ${patch.value} that was applied`;
1029
+ }
1030
+ function collapse(code) {
1031
+ return code.replaceAll(/\s+/gu, "");
1032
+ }
1033
+ function insertBindingConflict2(imports, patch) {
1034
+ const held = patch.import ? foreignBinding(imports, patch.import) : void 0;
1035
+ if (!held || !patch.import) {
1036
+ return void 0;
1037
+ }
1038
+ return `${describeBinding(patch.import.name, held)}, so ${JSON.stringify(patch.column)} would read the wrong builder`;
1039
+ }
1040
+ function removeBindingConflict2(imports, want) {
1041
+ const held = foreignBinding(imports, want);
1042
+ if (!held) {
1043
+ return void 0;
1044
+ }
1045
+ return `${describeBinding(want.name, held)} now, not from ${JSON.stringify(want.from)}, so the column and its import are not the ones that were applied`;
1046
+ }
1047
+ function print(source, mod) {
1048
+ return keepTerminator(
1049
+ source,
1050
+ generateCode2(mod, { format: { objectCurlySpacing: true } }).code
1051
+ );
1052
+ }
1053
+
866
1054
  // src/lib/patch/jsonc.ts
867
1055
  import {
868
1056
  applyEdits,
@@ -1137,9 +1325,9 @@ function upsertPackageJsonScript(source, patch) {
1137
1325
  }
1138
1326
 
1139
1327
  // src/lib/patch/ts-module.ts
1140
- import { builders as builders2, generateCode as generateCode2, parseModule as parseModule3 } from "magicast";
1328
+ import { builders as builders2, generateCode as generateCode3, parseModule as parseModule4 } from "magicast";
1141
1329
  function insertIntoPluginArray(source, patch) {
1142
- const mod = parseModule3(source);
1330
+ const mod = parseModule4(source);
1143
1331
  const exported = mod.exports[patch.exportName];
1144
1332
  const callArg = exported?.$args?.[0];
1145
1333
  if (!callArg) {
@@ -1150,7 +1338,7 @@ function insertIntoPluginArray(source, patch) {
1150
1338
  return source;
1151
1339
  }
1152
1340
  if (!(patch.import.name in mod.imports)) {
1153
- mod.imports.$add({
1341
+ mod.imports.$prepend({
1154
1342
  from: patch.import.from,
1155
1343
  imported: patch.import.name,
1156
1344
  local: patch.import.name
@@ -1162,10 +1350,10 @@ function insertIntoPluginArray(source, patch) {
1162
1350
  } else {
1163
1351
  callArg[patch.arrayProp] = [newCall];
1164
1352
  }
1165
- return print(source, mod);
1353
+ return print2(source, mod);
1166
1354
  }
1167
1355
  function removeFromPluginArray(source, patch) {
1168
- const mod = parseModule3(source);
1356
+ const mod = parseModule4(source);
1169
1357
  const exported = mod.exports[patch.exportName];
1170
1358
  const callArg = exported?.$args?.[0];
1171
1359
  if (!callArg) {
@@ -1182,7 +1370,7 @@ function removeFromPluginArray(source, patch) {
1182
1370
  if (argumentDrift(array[index], patch)) {
1183
1371
  return source;
1184
1372
  }
1185
- if (removeBindingConflict2(mod.imports, patch)) {
1373
+ if (removeBindingConflict3(mod.imports, patch)) {
1186
1374
  return source;
1187
1375
  }
1188
1376
  array.splice(index, 1);
@@ -1190,10 +1378,10 @@ function removeFromPluginArray(source, patch) {
1190
1378
  if (patch.import.name in mod.imports && !isReferenced(program, patch.import.name)) {
1191
1379
  delete mod.imports[patch.import.name];
1192
1380
  }
1193
- return print(source, mod);
1381
+ return print2(source, mod);
1194
1382
  }
1195
1383
  function pluginArrayRemoveRefusal(source, patch) {
1196
- const mod = parseModule3(source);
1384
+ const mod = parseModule4(source);
1197
1385
  const callArg = mod.exports[patch.exportName]?.$args?.[0];
1198
1386
  if (!callArg) {
1199
1387
  return void 0;
@@ -1206,12 +1394,12 @@ function pluginArrayRemoveRefusal(source, patch) {
1206
1394
  if (index === -1) {
1207
1395
  return void 0;
1208
1396
  }
1209
- return argumentDrift(array[index], patch) ?? removeBindingConflict2(mod.imports, patch);
1397
+ return argumentDrift(array[index], patch) ?? removeBindingConflict3(mod.imports, patch);
1210
1398
  }
1211
- function print(source, mod) {
1399
+ function print2(source, mod) {
1212
1400
  return keepTerminator(
1213
1401
  source,
1214
- generateCode2(mod, { format: { objectCurlySpacing: true } }).code
1402
+ generateCode3(mod, { format: { objectCurlySpacing: true } }).code
1215
1403
  );
1216
1404
  }
1217
1405
  function findCallIndex(array, call) {
@@ -1229,7 +1417,7 @@ function argumentDrift(element, patch) {
1229
1417
  }
1230
1418
  return `${patch.call}() in ${JSON.stringify(patch.arrayProp)} takes arguments now, not the bare call that was applied`;
1231
1419
  }
1232
- function removeBindingConflict2(imports, patch) {
1420
+ function removeBindingConflict3(imports, patch) {
1233
1421
  const held = foreignBinding(imports, patch.import);
1234
1422
  if (!held) {
1235
1423
  return void 0;
@@ -1244,6 +1432,7 @@ function isFunctionCall(value) {
1244
1432
  var PATCH_KIND_KEYS = {
1245
1433
  "chained-route": true,
1246
1434
  "const-array": true,
1435
+ "drizzle-column": true,
1247
1436
  "package-json-dependency": true,
1248
1437
  "package-json-script": true,
1249
1438
  "plugin-array": true,
@@ -1280,6 +1469,9 @@ function applyCodemod(source, patch) {
1280
1469
  case "const-array": {
1281
1470
  return insertIntoConstArray(source, patch);
1282
1471
  }
1472
+ case "drizzle-column": {
1473
+ return insertDrizzleColumn(source, patch);
1474
+ }
1283
1475
  case "wrangler-binding": {
1284
1476
  return upsertWranglerBinding(source, patch);
1285
1477
  }
@@ -1304,6 +1496,7 @@ function applyCodemod(source, patch) {
1304
1496
  var INVERSES = {
1305
1497
  "chained-route": removeChainedRoute,
1306
1498
  "const-array": removeFromConstArray,
1499
+ "drizzle-column": removeDrizzleColumn,
1307
1500
  "plugin-array": removeFromPluginArray,
1308
1501
  "wrangler-binding": removeWranglerBinding
1309
1502
  };
@@ -1327,10 +1520,12 @@ function reversePatch(source, patch, filename) {
1327
1520
  var REFUSALS = {
1328
1521
  "chained-route": chainedRouteInsertRefusal,
1329
1522
  "const-array": constArrayInsertRefusal,
1523
+ "drizzle-column": drizzleColumnInsertRefusal,
1330
1524
  "package-json-script": packageJsonScriptRefusal
1331
1525
  };
1332
1526
  var REVERSAL_REFUSALS = {
1333
1527
  "chained-route": chainedRouteRemoveRefusal,
1528
+ "drizzle-column": drizzleColumnRemoveRefusal,
1334
1529
  "plugin-array": pluginArrayRemoveRefusal,
1335
1530
  "wrangler-binding": wranglerBindingRemoveRefusal
1336
1531
  };
@@ -3676,7 +3871,7 @@ import pc3 from "picocolors";
3676
3871
  import { createHash as createHash2 } from "crypto";
3677
3872
  import { mkdtemp as mkdtemp2, readdir as readdir3, readFile as readFile12, rm as rm2 } from "fs/promises";
3678
3873
  import { tmpdir as tmpdir2 } from "os";
3679
- import { basename, join as join10, posix as posix6 } from "path";
3874
+ import { join as join10, posix as posix6 } from "path";
3680
3875
 
3681
3876
  // src/lib/scaffold.ts
3682
3877
  import { mkdir as mkdir5, readdir as readdir2, readFile as readFile11, writeFile as writeFile7 } from "fs/promises";
@@ -3686,7 +3881,7 @@ import { fileURLToPath as fileURLToPath3 } from "url";
3686
3881
  // package.json
3687
3882
  var package_default = {
3688
3883
  name: "saasaloy",
3689
- version: "0.1.2",
3884
+ version: "0.3.0",
3690
3885
  description: "Composable SaaS accelerator kit.",
3691
3886
  keywords: [
3692
3887
  "saas",
@@ -3737,19 +3932,19 @@ var package_default = {
3737
3932
  },
3738
3933
  devDependencies: {
3739
3934
  "@release-it/conventional-changelog": "12.0.0",
3740
- "@types/node": "26.1.1",
3741
- "@vitest/coverage-v8": "4.1.10",
3935
+ "@types/node": "26.4.1",
3936
+ "@vitest/coverage-v8": "5.0.0",
3742
3937
  "release-it": "21.0.2",
3743
3938
  tsup: "8.5.1",
3744
- vitest: "4.1.10"
3939
+ vitest: "5.0.0"
3745
3940
  },
3746
3941
  dependencies: {
3747
3942
  "@clack/prompts": "1.7.0",
3748
- ajv: "8.17.1",
3943
+ ajv: "8.20.0",
3749
3944
  diff: "9.0.0",
3750
- giget: "3.3.0",
3945
+ giget: "3.3.1",
3751
3946
  "jsonc-parser": "3.3.1",
3752
- magicast: "0.5.3",
3947
+ magicast: "0.5.4",
3753
3948
  picocolors: "1.1.1"
3754
3949
  }
3755
3950
  };
@@ -3826,10 +4021,20 @@ var BASE_MODULE = "base";
3826
4021
  var DEFAULT_BASE_NAME = "web";
3827
4022
  var BASE_NO_MERGE_BASE = "the template ships with the CLI; the previous version isn't on disk";
3828
4023
  var BASE_INTENT = "the base template changed; keep local edits, take the upstream change";
4024
+ function compileGlob(pattern) {
4025
+ const source = pattern.split("/").map(
4026
+ (segment) => segment === "**" ? "[^\0]*" : segment.replaceAll(/[.+^${}()|[\]\\]/g, String.raw`\$&`).replaceAll("*", "[^/]*")
4027
+ ).join("/").replaceAll("[^\0]*/", "(?:[^\0]*/)?");
4028
+ return new RegExp(`^${source}$`);
4029
+ }
4030
+ function ownedMatcher(declaration) {
4031
+ const patterns = declaration.ownedFiles.map(compileGlob);
4032
+ return (target) => patterns.some((re) => re.test(target));
4033
+ }
3829
4034
  async function readBaseDeclaration(templateDir) {
3830
4035
  const raw = await readIfPresent(join10(templateDir, BASE_DECLARATION));
3831
4036
  if (raw === void 0) {
3832
- return { seedFiles: [] };
4037
+ return { seedFiles: [], ownedFiles: [] };
3833
4038
  }
3834
4039
  const parsed = JSON.parse(raw);
3835
4040
  const seedFiles = parsed.seedFiles;
@@ -3838,7 +4043,16 @@ async function readBaseDeclaration(templateDir) {
3838
4043
  `${BASE_DECLARATION} must declare "seedFiles" as a list of project-relative paths.`
3839
4044
  );
3840
4045
  }
3841
- return { seedFiles };
4046
+ const ownedFiles = parsed.ownedFiles ?? [];
4047
+ if (!Array.isArray(ownedFiles) || !ownedFiles.every((entry) => typeof entry === "string" && entry !== "")) {
4048
+ throw new Error(
4049
+ `${BASE_DECLARATION} must declare "ownedFiles" as a list of project-relative globs.`
4050
+ );
4051
+ }
4052
+ return {
4053
+ seedFiles,
4054
+ ownedFiles
4055
+ };
3842
4056
  }
3843
4057
  async function listTemplateFiles(templateDir) {
3844
4058
  const out = [];
@@ -3900,6 +4114,7 @@ function recordBaseFiles(manifest, files) {
3900
4114
  manifest.managed[file.target] = {
3901
4115
  module: BASE_MODULE,
3902
4116
  hash: file.hash,
4117
+ ...file.adopted ? { adopted: true } : {},
3903
4118
  from: file.from
3904
4119
  };
3905
4120
  }
@@ -3920,13 +4135,15 @@ async function missingBaseTargets(root, manifest, templateDir) {
3920
4135
  }
3921
4136
  return missing.toSorted((a, b) => a < b ? -1 : 1);
3922
4137
  }
3923
- async function renderTemplate(root, templateDir) {
4138
+ async function renderTemplate(root, templateDir, projectName) {
3924
4139
  const dir = await mkdtemp2(join10(tmpdir2(), "saasaloy-base-render-"));
3925
4140
  const files = await copyTemplate(
3926
4141
  templateDir,
3927
4142
  dir,
3928
- templateVars(basename(root)),
3929
- { keepNames: true }
4143
+ templateVars(projectName),
4144
+ {
4145
+ keepNames: true
4146
+ }
3930
4147
  );
3931
4148
  return {
3932
4149
  dir,
@@ -3935,9 +4152,16 @@ async function renderTemplate(root, templateDir) {
3935
4152
  };
3936
4153
  }
3937
4154
  async function adoptBase(args) {
3938
- const { root, cliVersion, manifest, lock, dryRun = false } = args;
4155
+ const {
4156
+ root,
4157
+ cliVersion,
4158
+ projectName,
4159
+ manifest,
4160
+ lock,
4161
+ dryRun = false
4162
+ } = args;
3939
4163
  const templateDir = args.templateDir ?? await baseTemplateDir();
3940
- const render = await renderTemplate(root, templateDir);
4164
+ const render = await renderTemplate(root, templateDir, projectName);
3941
4165
  try {
3942
4166
  const files = [];
3943
4167
  const adopted = [];
@@ -3952,7 +4176,8 @@ async function adoptBase(args) {
3952
4176
  files.push({
3953
4177
  target: file.target,
3954
4178
  from: file.from,
3955
- hash: hashContent(mine)
4179
+ hash: hashContent(mine),
4180
+ adopted: true
3956
4181
  });
3957
4182
  }
3958
4183
  }
@@ -3973,9 +4198,18 @@ async function adoptBase(args) {
3973
4198
  }
3974
4199
  }
3975
4200
  async function baseUpdateInput(args) {
3976
- const { root, manifest, lock, templateDir, cliVersion, comparison } = args;
3977
- const render = await renderTemplate(root, templateDir);
3978
- const seed = new Set((await readBaseDeclaration(templateDir)).seedFiles);
4201
+ const {
4202
+ root,
4203
+ manifest,
4204
+ lock,
4205
+ templateDir,
4206
+ cliVersion,
4207
+ projectName,
4208
+ comparison
4209
+ } = args;
4210
+ const render = await renderTemplate(root, templateDir, projectName);
4211
+ const declaration = await readBaseDeclaration(templateDir);
4212
+ const seed = new Set(declaration.seedFiles);
3979
4213
  const shipped = render.files.filter((file) => !seed.has(file.target));
3980
4214
  const targets = new Set(shipped.map((file) => file.target));
3981
4215
  return {
@@ -4000,6 +4234,7 @@ async function baseUpdateInput(args) {
4000
4234
  noMergeBase: BASE_NO_MERGE_BASE,
4001
4235
  intent: [],
4002
4236
  ignoreTargets: seed,
4237
+ isOwned: ownedMatcher(declaration),
4003
4238
  reapplyPatches: manifest.patches.filter(
4004
4239
  (patch) => targets.has(patch.file)
4005
4240
  ),
@@ -4014,8 +4249,10 @@ async function baseUpdateInput(args) {
4014
4249
  }
4015
4250
 
4016
4251
  // src/lib/doctor.ts
4252
+ import { findNodeAtLocation as findNodeAtLocation4, getNodeValue as getNodeValue4, parseTree as parseTree4 } from "jsonc-parser";
4253
+ import { parseModule as parseModule5 } from "magicast";
4017
4254
  import { readFile as readFile13 } from "fs/promises";
4018
- import { basename as basename2, dirname as dirname6, join as join11, posix as posix7, resolve as resolve3 } from "path";
4255
+ import { basename, dirname as dirname6, join as join11, posix as posix7, resolve as resolve3 } from "path";
4019
4256
  var SKILL_PREFIX = "saasaloy-";
4020
4257
  var PINNED_DEP = /^(@[^/@]+\/)?[^/@]+@\d+\.\d+\.\d+(-[\w.-]+)?(\+[\w.-]+)?$/;
4021
4258
  function finding(module, where, message) {
@@ -4069,7 +4306,7 @@ async function readDescriptor(dir) {
4069
4306
  }
4070
4307
  async function checkModule(input) {
4071
4308
  const { aliases, dir, siblings } = input;
4072
- const module = basename2(dir);
4309
+ const module = basename(dir);
4073
4310
  const findings = [];
4074
4311
  const report = () => ({ dir, findings, module });
4075
4312
  const file = join11(dir, "registry-item.json");
@@ -4270,10 +4507,204 @@ function checkProject(args) {
4270
4507
  )
4271
4508
  );
4272
4509
  }
4510
+ var KV_INDEX_FILE = "packages/kv/src/index.ts";
4511
+ var WRANGLER_FILE = "apps/api/wrangler.jsonc";
4512
+ var CLOUDFLARE_KV_MODULE = "kv-cloudflare";
4513
+ function checkPolicyBindings(args) {
4514
+ const bound = new Set(rateLimitBindingNames(args.wrangler));
4515
+ const findings = [];
4516
+ for (const entry of registeredPolicies(args)) {
4517
+ if (entry.name === void 0) {
4518
+ findings.push(
4519
+ finding(
4520
+ "ratelimit",
4521
+ `/policies/${String(entry.index)}`,
4522
+ `the policy at index ${String(entry.index)} of the \`policies\` array in ${KV_INDEX_FILE} has no name this check can read, so its RL_<NAME> binding in ${WRANGLER_FILE} was not checked. Write it as \`definePolicy({ name: "..." })\`, or as a factory exported from a file under packages/kv/src/policies/.`
4523
+ )
4524
+ );
4525
+ continue;
4526
+ }
4527
+ if (!bound.has(bindingFor(entry.name))) {
4528
+ findings.push(
4529
+ finding(
4530
+ "ratelimit",
4531
+ `/policies/${entry.name}`,
4532
+ `policy "${entry.name}" has no ${bindingFor(entry.name)} entry in ${WRANGLER_FILE} \u2014 consume({ policy: "${entry.name}" }) throws not_supported on the first request. Add the binding, or drop the policy from ${KV_INDEX_FILE}.`
4533
+ )
4534
+ );
4535
+ }
4536
+ }
4537
+ return findings;
4538
+ }
4539
+ function bindingFor(policy) {
4540
+ return `RL_${policy.toUpperCase()}`;
4541
+ }
4542
+ function rateLimitBindingNames(wrangler) {
4543
+ const root = parseTree4(wrangler);
4544
+ if (!root) {
4545
+ return [];
4546
+ }
4547
+ const node = findNodeAtLocation4(root, ["ratelimits"]);
4548
+ if (node?.type !== "array") {
4549
+ return [];
4550
+ }
4551
+ const names = [];
4552
+ for (const child of node.children ?? []) {
4553
+ const value = getNodeValue4(child);
4554
+ const name = asRecord(value).name;
4555
+ if (typeof name === "string") {
4556
+ names.push(name);
4557
+ }
4558
+ }
4559
+ return names;
4560
+ }
4561
+ function registeredPolicies(args) {
4562
+ let mod;
4563
+ try {
4564
+ mod = parseModule5(args.index);
4565
+ } catch {
4566
+ return [];
4567
+ }
4568
+ const callArg = mod.exports.kv?.$args?.[0];
4569
+ const array = callArg?.policies;
4570
+ if (!Array.isArray(array)) {
4571
+ return [];
4572
+ }
4573
+ const imports = mod.imports;
4574
+ const policies = [];
4575
+ for (let i = 0; i < array.length; i++) {
4576
+ const element = array[i];
4577
+ policies.push({ index: i, name: policyNameOf(element, args, imports) });
4578
+ }
4579
+ return policies;
4580
+ }
4581
+ function policyNameOf(element, args, imports) {
4582
+ const callee = calleeName(element);
4583
+ if (callee === void 0) {
4584
+ return void 0;
4585
+ }
4586
+ const inline = inlineNameArgument(element);
4587
+ if (inline !== void 0) {
4588
+ return inline;
4589
+ }
4590
+ const from = imports[callee]?.from;
4591
+ const source = typeof from === "string" ? args.policySources[from] : void 0;
4592
+ return source === void 0 ? void 0 : policyNameIn(source, callee);
4593
+ }
4594
+ function inlineNameArgument(element) {
4595
+ const args = element.$args;
4596
+ if (!Array.isArray(args)) {
4597
+ return void 0;
4598
+ }
4599
+ const name = asRecord(args[0]).name;
4600
+ return typeof name === "string" ? name : void 0;
4601
+ }
4602
+ function calleeName(element) {
4603
+ if (typeof element !== "object" || element === null) {
4604
+ return void 0;
4605
+ }
4606
+ const record = element;
4607
+ return record.$type === "function-call" && typeof record.$callee === "string" ? record.$callee : void 0;
4608
+ }
4609
+ function policyNameIn(source, callee) {
4610
+ let program;
4611
+ try {
4612
+ program = parseModule5(source).$ast;
4613
+ } catch {
4614
+ return void 0;
4615
+ }
4616
+ const factory = findNode(
4617
+ program,
4618
+ (node) => (node.type === "FunctionDeclaration" || node.type === "VariableDeclarator") && asRecord(node.id).name === callee
4619
+ );
4620
+ if (!factory) {
4621
+ return void 0;
4622
+ }
4623
+ const call = findNode(
4624
+ factory,
4625
+ (node) => node.type === "CallExpression" && asRecord(node.callee).name === "definePolicy"
4626
+ );
4627
+ const argument = asArray(call?.arguments)[0];
4628
+ const property = asArray(asRecord(argument).properties).find(
4629
+ (entry) => asRecord(asRecord(entry).key).name === "name"
4630
+ );
4631
+ const value = asRecord(asRecord(property).value).value;
4632
+ return typeof value === "string" ? value : void 0;
4633
+ }
4634
+ function findNode(root, accept) {
4635
+ if (Array.isArray(root)) {
4636
+ for (const child of root) {
4637
+ const found = findNode(child, accept);
4638
+ if (found) {
4639
+ return found;
4640
+ }
4641
+ }
4642
+ return void 0;
4643
+ }
4644
+ if (typeof root !== "object" || root === null) {
4645
+ return void 0;
4646
+ }
4647
+ const node = root;
4648
+ if (typeof node.type === "string") {
4649
+ const typed = node;
4650
+ if (accept(typed)) {
4651
+ return typed;
4652
+ }
4653
+ }
4654
+ for (const [key, value] of Object.entries(node)) {
4655
+ if (key === "loc" || key === "comments" || key === "original") {
4656
+ continue;
4657
+ }
4658
+ const found = findNode(value, accept);
4659
+ if (found) {
4660
+ return found;
4661
+ }
4662
+ }
4663
+ return void 0;
4664
+ }
4665
+ async function readPolicyState(root, installed) {
4666
+ if (!installed.includes(CLOUDFLARE_KV_MODULE)) {
4667
+ return void 0;
4668
+ }
4669
+ const index = await readIfPresent(resolveWithinRoot(root, KV_INDEX_FILE));
4670
+ const wrangler = await readIfPresent(resolveWithinRoot(root, WRANGLER_FILE));
4671
+ if (index === void 0 || wrangler === void 0) {
4672
+ return void 0;
4673
+ }
4674
+ const policySources = {};
4675
+ for (const specifier of relativeImportsIn(index)) {
4676
+ const source = await readIfPresent(
4677
+ resolveWithinRoot(
4678
+ root,
4679
+ posix7.join(posix7.dirname(KV_INDEX_FILE), `${specifier}.ts`)
4680
+ )
4681
+ );
4682
+ if (source !== void 0) {
4683
+ policySources[specifier] = source;
4684
+ }
4685
+ }
4686
+ return { index, policySources, wrangler };
4687
+ }
4688
+ function relativeImportsIn(index) {
4689
+ let mod;
4690
+ try {
4691
+ mod = parseModule5(index);
4692
+ } catch {
4693
+ return [];
4694
+ }
4695
+ const imports = mod.imports;
4696
+ const specifiers = /* @__PURE__ */ new Set();
4697
+ for (const held of Object.values(imports)) {
4698
+ if (typeof held?.from === "string" && held.from.startsWith(".")) {
4699
+ specifiers.add(held.from);
4700
+ }
4701
+ }
4702
+ return [...specifiers];
4703
+ }
4273
4704
  async function resolveDoctorTarget(path) {
4274
4705
  const dir = resolve3(path);
4275
4706
  if (await pathExists(join11(dir, "registry-item.json"))) {
4276
- return { names: [basename2(dir)], registryDir: dirname6(dir) };
4707
+ return { names: [basename(dir)], registryDir: dirname6(dir) };
4277
4708
  }
4278
4709
  return { names: await registryModuleNames(dir), registryDir: dir };
4279
4710
  }
@@ -4415,9 +4846,11 @@ async function reportProject(path) {
4415
4846
  seedFiles: (await readBaseDeclaration(await baseTemplateDir())).seedFiles
4416
4847
  });
4417
4848
  note2(wrapForNote(renderBase(base)), "Base");
4849
+ const policyState = await readPolicyState(path, config.installed);
4418
4850
  const findings = [
4419
4851
  ...checkProject({ config, manifest }),
4420
- ...checkPartialInstalls({ installed: config.installed, manifest })
4852
+ ...checkPartialInstalls({ installed: config.installed, manifest }),
4853
+ ...policyState ? checkPolicyBindings(policyState) : []
4421
4854
  ];
4422
4855
  if (findings.length === 0) {
4423
4856
  const count = config.installed.length;
@@ -5122,8 +5555,8 @@ ${entry.declaration.description}`,
5122
5555
 
5123
5556
  // src/commands/init.ts
5124
5557
  import { spawn } from "child_process";
5125
- import { readdir as readdir4 } from "fs/promises";
5126
- import { basename as basename3, join as join14, resolve as resolve6 } from "path";
5558
+ import { readdir as readdir4, readFile as readFile16 } from "fs/promises";
5559
+ import { basename as basename2, join as join14, resolve as resolve6 } from "path";
5127
5560
  import {
5128
5561
  cancel as cancel4,
5129
5562
  intro as intro4,
@@ -5315,7 +5748,7 @@ async function runInit(argv) {
5315
5748
  if (!trimmed) {
5316
5749
  return "Enter a project name (or `.` for the current directory).";
5317
5750
  }
5318
- const name = basename3(resolve6(process.cwd(), trimmed));
5751
+ const name = basename2(resolve6(process.cwd(), trimmed));
5319
5752
  if (!NAME_PATTERN.test(name)) {
5320
5753
  return "Use lowercase letters, digits, and hyphens (e.g. my-app).";
5321
5754
  }
@@ -5329,7 +5762,7 @@ async function runInit(argv) {
5329
5762
  nameArg = answer.trim();
5330
5763
  }
5331
5764
  const target = resolve6(process.cwd(), nameArg);
5332
- const projectName = basename3(target);
5765
+ const projectName = basename2(target);
5333
5766
  if (!NAME_PATTERN.test(projectName)) {
5334
5767
  cancel4(
5335
5768
  `Invalid project name "${projectName}". Use lowercase letters, digits, and hyphens (e.g. my-app).`
@@ -5356,6 +5789,17 @@ async function runInit(argv) {
5356
5789
  s.stop(
5357
5790
  `Scaffolded ${pc6.cyan(projectName)} ${pc6.dim("(apps/web \xB7 packages/ui \xB7 packages/tsconfig)")}`
5358
5791
  );
5792
+ try {
5793
+ const config = await loadConfig(target);
5794
+ await saveConfig(target, { ...config, name: projectName });
5795
+ const entry = written.find((file) => file.target === CONFIG_FILE);
5796
+ if (entry) {
5797
+ entry.hash = hashContent(await readFile16(entry.path, "utf-8"));
5798
+ }
5799
+ } catch (error) {
5800
+ cancel4(`Couldn't record the project name: ${errorMessage(error)}`);
5801
+ return EXIT_FAILURE;
5802
+ }
5359
5803
  try {
5360
5804
  const manifest = await loadManifest(target);
5361
5805
  const lock = await loadLock(target);
@@ -5919,11 +6363,11 @@ import { cancel as cancel7, intro as intro7, log as log5, note as note7, outro a
5919
6363
  import pc9 from "picocolors";
5920
6364
 
5921
6365
  // src/lib/updater.ts
5922
- import { mkdir as mkdir8, readFile as readFile17, rm as rmPath2, writeFile as writeFile11 } from "fs/promises";
6366
+ import { mkdir as mkdir8, readFile as readFile18, rm as rmPath2, writeFile as writeFile11 } from "fs/promises";
5923
6367
  import { dirname as dirname11, posix as posix9 } from "path";
5924
6368
 
5925
6369
  // src/lib/remover.ts
5926
- import { readdir as readdir5, readFile as readFile16, rm as rmPath, writeFile as writeFile10 } from "fs/promises";
6370
+ import { readdir as readdir5, readFile as readFile17, rm as rmPath, writeFile as writeFile10 } from "fs/promises";
5927
6371
  import { dirname as dirname10, join as join18, relative as relative5, sep as sep3 } from "path";
5928
6372
  function classifyTrackedFile(content, manifestHash) {
5929
6373
  if (content === void 0) {
@@ -5948,7 +6392,7 @@ async function buildRemovePlan(args) {
5948
6392
  continue;
5949
6393
  }
5950
6394
  const targetAbs = resolveWithinRoot(root, target);
5951
- const oldContent = await pathExists(targetAbs) ? await readFile16(targetAbs, "utf-8") : void 0;
6395
+ const oldContent = await pathExists(targetAbs) ? await readFile17(targetAbs, "utf-8") : void 0;
5952
6396
  files.push({
5953
6397
  module: name,
5954
6398
  target,
@@ -6015,7 +6459,7 @@ async function previewPatchRemoval(root, entry) {
6015
6459
  return { entry, action: "gone", diff: "" };
6016
6460
  }
6017
6461
  await assertNoSymlinkPath(root, fileAbs);
6018
- const source = await readFile16(fileAbs, "utf-8");
6462
+ const source = await readFile17(fileAbs, "utf-8");
6019
6463
  const attempt = tryReversePatch(source, entry);
6020
6464
  if (!attempt.ok) {
6021
6465
  return { entry, action: "refused", diff: "", reason: attempt.reason };
@@ -6083,7 +6527,7 @@ async function recheckFile(file) {
6083
6527
  if (file.oldContent === void 0) {
6084
6528
  return "changed";
6085
6529
  }
6086
- const current = await readFile16(file.targetAbs, "utf-8");
6530
+ const current = await readFile17(file.targetAbs, "utf-8");
6087
6531
  return hashContent(current) === hashContent(file.oldContent) ? "unchanged" : "changed";
6088
6532
  }
6089
6533
  async function executeRemovePlan(plan, args) {
@@ -6135,7 +6579,7 @@ async function executeRemovePlan(plan, args) {
6135
6579
  let reversed = false;
6136
6580
  if (await pathExists(fileAbs)) {
6137
6581
  await assertNoSymlinkPath(root, fileAbs);
6138
- const source = await readFile16(fileAbs, "utf-8");
6582
+ const source = await readFile17(fileAbs, "utf-8");
6139
6583
  const attempt = tryReversePatch(source, entry);
6140
6584
  if (!attempt.ok) {
6141
6585
  patchRefusals.push({ patch: entry, reason: attempt.reason });
@@ -6418,18 +6862,21 @@ async function planOneModule(args) {
6418
6862
  const baseFiles = base ? await listModuleFiles(base, aliasView, installedSet) : void 0;
6419
6863
  const files = [];
6420
6864
  for (const ref of theirsFiles.values()) {
6421
- const theirsContent = await readFile17(ref.abs, "utf-8");
6865
+ const theirsContent = await readFile18(ref.abs, "utf-8");
6422
6866
  const baseRef = baseFiles?.get(ref.target);
6423
- const baseContent = baseRef ? await readFile17(baseRef.abs, "utf-8") : void 0;
6867
+ const baseContent = baseRef ? await readFile18(baseRef.abs, "utf-8") : void 0;
6424
6868
  const targetAbs = resolveWithinRoot(root, ref.target);
6425
6869
  const managed = manifest.managed[ref.target];
6426
6870
  const owned = managed?.module === name ? managed : void 0;
6427
6871
  const mine = await readIfPresent(targetAbs);
6872
+ const projectOwned = input.isOwned?.(ref.target) ?? false;
6428
6873
  const action = classifyUpdate(
6429
6874
  baseContent,
6430
6875
  theirsContent,
6431
6876
  mine,
6432
- owned?.hash
6877
+ owned?.hash,
6878
+ owned?.adopted ?? false,
6879
+ projectOwned
6433
6880
  );
6434
6881
  const patchedBy = patchersOf(manifest, ref.target);
6435
6882
  files.push({
@@ -6438,6 +6885,8 @@ async function planOneModule(args) {
6438
6885
  target: ref.target,
6439
6886
  targetAbs,
6440
6887
  action,
6888
+ ...projectOwned ? { projectOwned: true } : {},
6889
+ ...owned?.adopted ? { adopted: true } : {},
6441
6890
  base: baseContent,
6442
6891
  theirs: theirsContent,
6443
6892
  mine,
@@ -6453,13 +6902,14 @@ async function planOneModule(args) {
6453
6902
  const targetAbs = resolveWithinRoot(root, target);
6454
6903
  const mine = await readIfPresent(targetAbs);
6455
6904
  const baseRef = baseFiles?.get(target);
6905
+ const keep = mine !== void 0 && ((entry.adopted ?? false) || (input.isOwned?.(target) ?? false));
6456
6906
  removals.push({
6457
6907
  module: name,
6458
6908
  from: entry.from ?? baseRef?.from ?? target,
6459
6909
  target,
6460
6910
  targetAbs,
6461
- action: DELETE_ACTION[classifyTrackedFile(mine, entry.hash)],
6462
- base: baseRef ? await readFile17(baseRef.abs, "utf-8") : void 0,
6911
+ action: keep ? "delete-drift" : DELETE_ACTION[classifyTrackedFile(mine, entry.hash)],
6912
+ base: baseRef ? await readFile18(baseRef.abs, "utf-8") : void 0,
6463
6913
  mine
6464
6914
  });
6465
6915
  }
@@ -6580,7 +7030,7 @@ function newEnvVars(base, theirs) {
6580
7030
  }
6581
7031
  return out;
6582
7032
  }
6583
- function classifyUpdate(base, theirs, mine, managedHash) {
7033
+ function classifyUpdate(base, theirs, mine, managedHash, adopted, projectOwned) {
6584
7034
  if (base !== void 0 && base === theirs) {
6585
7035
  return "skip";
6586
7036
  }
@@ -6593,6 +7043,9 @@ function classifyUpdate(base, theirs, mine, managedHash) {
6593
7043
  if (managedHash === void 0) {
6594
7044
  return "conflict";
6595
7045
  }
7046
+ if (adopted || projectOwned) {
7047
+ return "drift";
7048
+ }
6596
7049
  return hashContent(mine) === managedHash ? "overwrite" : "drift";
6597
7050
  }
6598
7051
  async function planLinks(root, theirs) {
@@ -6748,7 +7201,7 @@ async function executeUpdatePlan(plan, args) {
6748
7201
  clean = false;
6749
7202
  continue;
6750
7203
  }
6751
- const source = await readFile17(p.fileAbs, "utf-8");
7204
+ const source = await readFile18(p.fileAbs, "utf-8");
6752
7205
  const { content, changed } = applyPatch(source, p.patch, p.file);
6753
7206
  if (!changed) {
6754
7207
  continue;
@@ -7372,8 +7825,8 @@ ${pc10.dim("Hand-edited since \u2014 left on disk, now untracked.")}`
7372
7825
  }
7373
7826
 
7374
7827
  // src/commands/update.ts
7375
- import { mkdir as mkdir9, realpath, writeFile as writeFile12 } from "fs/promises";
7376
- import { basename as basename4, dirname as dirname12, isAbsolute as isAbsolute4, join as join19, resolve as resolve8 } from "path";
7828
+ import { mkdir as mkdir10, realpath, writeFile as writeFile13 } from "fs/promises";
7829
+ import { basename as basename4, dirname as dirname13, isAbsolute as isAbsolute4, join as join20, resolve as resolve8 } from "path";
7377
7830
  import {
7378
7831
  cancel as cancel9,
7379
7832
  confirm as confirm3,
@@ -7385,6 +7838,145 @@ import {
7385
7838
  } from "@clack/prompts";
7386
7839
  import pc11 from "picocolors";
7387
7840
 
7841
+ // src/lib/backup.ts
7842
+ import {
7843
+ copyFile,
7844
+ mkdir as mkdir9,
7845
+ readdir as readdir6,
7846
+ readFile as readFile19,
7847
+ rm as rmPath3,
7848
+ writeFile as writeFile12
7849
+ } from "fs/promises";
7850
+ import { dirname as dirname12, join as join19 } from "path";
7851
+ var BACKUP_DIR = join19(".saasaloy", "backups");
7852
+ var RECORD_FILE = "backup.json";
7853
+ var STATE_FILES = [
7854
+ "saasaloy.json",
7855
+ "saasaloy-lock.json",
7856
+ "package.json",
7857
+ join19(".saasaloy", "manifest.json")
7858
+ ];
7859
+ function stampName(now) {
7860
+ return now.toISOString().replaceAll(/[:.]/g, "-");
7861
+ }
7862
+ async function writeBackup(args) {
7863
+ const { root, targets, cliVersion } = args;
7864
+ const createdAt = (args.now ?? /* @__PURE__ */ new Date()).toISOString();
7865
+ const rel = join19(BACKUP_DIR, stampName(args.now ?? new Date(createdAt)));
7866
+ const dir = join19(root, rel);
7867
+ const files = [];
7868
+ const absent = [];
7869
+ for (const target of /* @__PURE__ */ new Set([...targets, ...STATE_FILES])) {
7870
+ const source = resolveWithinRoot(root, target);
7871
+ if (!await pathExists(source)) {
7872
+ absent.push(target);
7873
+ continue;
7874
+ }
7875
+ const destination = join19(dir, target);
7876
+ await mkdir9(dirname12(destination), { recursive: true });
7877
+ await copyFile(source, destination);
7878
+ files.push(target);
7879
+ }
7880
+ const record = {
7881
+ createdAt,
7882
+ cliVersion,
7883
+ files: files.toSorted(),
7884
+ absent: absent.toSorted()
7885
+ };
7886
+ await mkdir9(dir, { recursive: true });
7887
+ await writeFile12(
7888
+ join19(dir, RECORD_FILE),
7889
+ `${JSON.stringify(record, null, 2)}
7890
+ `,
7891
+ "utf-8"
7892
+ );
7893
+ return rel;
7894
+ }
7895
+ async function listBackups(root) {
7896
+ const dir = join19(root, BACKUP_DIR);
7897
+ if (!await pathExists(dir)) {
7898
+ return [];
7899
+ }
7900
+ const entries = await readdir6(dir, { withFileTypes: true });
7901
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).toSorted();
7902
+ }
7903
+ async function restoreLatestBackup(root) {
7904
+ const backups = await listBackups(root);
7905
+ const newest = backups.at(-1);
7906
+ if (!newest) {
7907
+ throw new RefusalError(
7908
+ `No backup to restore \u2014 nothing under ${BACKUP_DIR}. \`saasaloy update\` writes one before it applies a plan.`
7909
+ );
7910
+ }
7911
+ const rel = join19(BACKUP_DIR, newest);
7912
+ const dir = join19(root, rel);
7913
+ const raw = await readIfPresent(join19(dir, RECORD_FILE));
7914
+ if (raw === void 0) {
7915
+ throw new RefusalError(
7916
+ `${join19(rel, RECORD_FILE)} is missing \u2014 that backup can't be restored safely.`
7917
+ );
7918
+ }
7919
+ let record;
7920
+ try {
7921
+ record = JSON.parse(raw);
7922
+ } catch (error) {
7923
+ throw new RefusalError(`${join19(rel, RECORD_FILE)} is invalid.`, {
7924
+ cause: error
7925
+ });
7926
+ }
7927
+ const restored = [];
7928
+ for (const target of record.files) {
7929
+ const source = join19(dir, target);
7930
+ const destination = resolveWithinRoot(root, target);
7931
+ if (!await pathExists(source)) {
7932
+ continue;
7933
+ }
7934
+ await mkdir9(dirname12(destination), { recursive: true });
7935
+ await copyFile(source, destination);
7936
+ restored.push(target);
7937
+ }
7938
+ const removed = [];
7939
+ for (const target of record.absent) {
7940
+ const destination = resolveWithinRoot(root, target);
7941
+ if (await pathExists(destination)) {
7942
+ await rmPath3(destination, { force: true });
7943
+ removed.push(target);
7944
+ }
7945
+ }
7946
+ return { from: rel, restored, removed };
7947
+ }
7948
+
7949
+ // src/lib/git-state.ts
7950
+ import { execFile } from "child_process";
7951
+ async function workingTreeState(root) {
7952
+ return new Promise((resolve9) => {
7953
+ execFile(
7954
+ "git",
7955
+ // `-- .` scopes the answer to the project. Without it a project nested inside a
7956
+ // larger repository reports every unrelated change in that repository as its own.
7957
+ ["status", "--porcelain", "--untracked-files=no", "--", "."],
7958
+ { cwd: root },
7959
+ (error, stdout) => {
7960
+ resolve9(error ? "unknown" : stdout.trim() === "" ? "clean" : "dirty");
7961
+ }
7962
+ );
7963
+ });
7964
+ }
7965
+
7966
+ // src/lib/project-name.ts
7967
+ import { basename as basename3 } from "path";
7968
+ async function resolveProjectName(root, config) {
7969
+ if (config.name) {
7970
+ return { name: config.name, source: "config" };
7971
+ }
7972
+ const pkg = await readRootPackageJson(root);
7973
+ const pkgName = typeof pkg?.name === "string" ? pkg.name : void 0;
7974
+ if (pkgName) {
7975
+ return { name: pkgName, source: "package.json" };
7976
+ }
7977
+ return { name: basename3(root), source: "directory" };
7978
+ }
7979
+
7388
7980
  // src/lib/merge-plan.ts
7389
7981
  function renderMergePlan(plan) {
7390
7982
  const modules = plan.modules.filter((m) => m.needsMerge);
@@ -7499,6 +8091,17 @@ function renderFile(file) {
7499
8091
  ""
7500
8092
  );
7501
8093
  }
8094
+ if (file.projectOwned) {
8095
+ out.push(
8096
+ "This file is yours: the template ships it once and never writes it again. The template's copy moved, so the change is shown below for you to take what you want from it. Nothing here was written.",
8097
+ ""
8098
+ );
8099
+ } else if (file.adopted) {
8100
+ out.push(
8101
+ "Saasaloy adopted this file rather than writing it, so the hash it records came off your disk and proves nothing about who wrote these bytes. It is treated as edited and was left alone.",
8102
+ ""
8103
+ );
8104
+ }
7502
8105
  if (file.patchedBy && file.patchedBy.length > 0) {
7503
8106
  out.push(
7504
8107
  `Note: \`${file.patchedBy.join("`, `")}\` also applied a config patch to this file. Part of the difference below is that patch rather than an edit anyone made by hand \u2014 keep those lines when you merge.`,
@@ -7611,11 +8214,13 @@ var KNOWN_FLAGS9 = /* @__PURE__ */ new Set([
7611
8214
  "--diff",
7612
8215
  "--yes",
7613
8216
  "-y",
8217
+ "--force",
8218
+ "--abort",
7614
8219
  "--help",
7615
8220
  "-h"
7616
8221
  ]);
7617
8222
  var VALUE_FLAGS2 = /* @__PURE__ */ new Set(["--ref", "--out"]);
7618
- var USAGE9 = "saasaloy update [<module>|base] [--ref <ref>] [--out <path>] [--dry-run] [--diff] [--yes]";
8223
+ var USAGE9 = "saasaloy update [<module>|base] [--ref <ref>] [--out <path>] [--dry-run] [--diff] [--force] [--yes] | saasaloy update --abort";
7619
8224
  var HELP9 = {
7620
8225
  name: "update",
7621
8226
  describe: DESCRIPTIONS.update,
@@ -7625,6 +8230,8 @@ var HELP9 = {
7625
8230
  "--out <path>": "write the merge plan to a file instead of stdout",
7626
8231
  "--dry-run": "show the plan and write nothing",
7627
8232
  "--diff": "show a per-file diff and write nothing",
8233
+ "--force": "apply even with uncommitted changes in the working tree",
8234
+ "--abort": "restore the files the last update replaced, then stop",
7628
8235
  "-y, --yes": "skip the confirmation prompt"
7629
8236
  }
7630
8237
  };
@@ -7664,6 +8271,8 @@ function parseArgs9(argv) {
7664
8271
  dryRun: argv.includes("--dry-run"),
7665
8272
  diff: argv.includes("--diff"),
7666
8273
  yes: argv.includes("--yes") || argv.includes("-y"),
8274
+ force: argv.includes("--force"),
8275
+ abort: argv.includes("--abort"),
7667
8276
  unknown
7668
8277
  };
7669
8278
  }
@@ -7824,13 +8433,13 @@ function skipReason(comparison, preview = false) {
7824
8433
  }
7825
8434
  }
7826
8435
  }
7827
- var STATE_FILES = [CONFIG_FILE, LOCK_FILE, MANIFEST_FILE];
8436
+ var STATE_FILES2 = [CONFIG_FILE, LOCK_FILE, MANIFEST_FILE];
7828
8437
  async function stateFileRefusal(root, out) {
7829
8438
  if (!out) {
7830
8439
  return void 0;
7831
8440
  }
7832
8441
  const target = await canonicalPath(resolvePlanOut(out));
7833
- for (const file of STATE_FILES) {
8442
+ for (const file of STATE_FILES2) {
7834
8443
  if (await canonicalPath(resolve8(root, file)) === target) {
7835
8444
  return `Refusing to write the merge plan to ${pc11.cyan(out)} \u2014 that is Saasaloy's own ${pc11.cyan(file)}. Pick another path.`;
7836
8445
  }
@@ -7842,7 +8451,7 @@ async function canonicalPath(path) {
7842
8451
  return await realpath(path);
7843
8452
  } catch {
7844
8453
  try {
7845
- return join19(await realpath(dirname12(path)), basename4(path));
8454
+ return join20(await realpath(dirname13(path)), basename4(path));
7846
8455
  } catch {
7847
8456
  return path;
7848
8457
  }
@@ -7857,10 +8466,24 @@ async function emitMergePlan(document, out) {
7857
8466
  return;
7858
8467
  }
7859
8468
  const target = resolvePlanOut(out);
7860
- await mkdir9(dirname12(target), { recursive: true });
7861
- await writeFile12(target, document, "utf-8");
8469
+ await mkdir10(dirname13(target), { recursive: true });
8470
+ await writeFile13(target, document, "utf-8");
7862
8471
  log7.success(`Merge plan written to ${pc11.cyan(out)}`, TUI_ON_STDERR);
7863
8472
  }
8473
+ function backupTargets(plan) {
8474
+ const targets = /* @__PURE__ */ new Set();
8475
+ for (const mod of plan.modules) {
8476
+ for (const file of [...mod.files, ...mod.removals]) {
8477
+ if (!QUIET.has(file.action) && file.action !== "skip") {
8478
+ targets.add(file.target);
8479
+ }
8480
+ }
8481
+ for (const patch of mod.patches) {
8482
+ targets.add(patch.file);
8483
+ }
8484
+ }
8485
+ return [...targets].toSorted();
8486
+ }
7864
8487
  function splitSlug2(slug) {
7865
8488
  const [owner, repo, ...rest] = slug.split("/");
7866
8489
  if (!owner || !repo || rest.length > 0) {
@@ -7898,12 +8521,43 @@ async function runUpdate(argv) {
7898
8521
  cancel9(formatFailure(error), TUI_ON_STDERR);
7899
8522
  return exitCodeFor(error);
7900
8523
  }
8524
+ if (opts.abort) {
8525
+ try {
8526
+ const restored = await restoreLatestBackup(root);
8527
+ for (const target of restored.restored) {
8528
+ log7.step(`${pc11.green("restore")} ${target}`, TUI_ON_STDERR);
8529
+ }
8530
+ for (const target of restored.removed) {
8531
+ log7.step(
8532
+ `${pc11.red("remove")} ${target} ${pc11.dim("(the update created it)")}`,
8533
+ TUI_ON_STDERR
8534
+ );
8535
+ }
8536
+ outro9(
8537
+ pc11.green(
8538
+ `Restored ${restored.restored.length} file${restored.restored.length === 1 ? "" : "s"} from ${restored.from}.`
8539
+ ),
8540
+ TUI_ON_STDERR
8541
+ );
8542
+ return EXIT_OK;
8543
+ } catch (error) {
8544
+ cancel9(formatFailure(error), TUI_ON_STDERR);
8545
+ return exitCodeFor(error);
8546
+ }
8547
+ }
7901
8548
  const outRefusal = await stateFileRefusal(root, opts.out);
7902
8549
  if (outRefusal) {
7903
8550
  cancel9(outRefusal, TUI_ON_STDERR);
7904
8551
  return EXIT_REFUSED;
7905
8552
  }
7906
8553
  const preview = opts.dryRun || opts.diff;
8554
+ if (!preview && !opts.force && await workingTreeState(root) === "dirty") {
8555
+ cancel9(
8556
+ "The working tree has uncommitted changes. Commit or stash them first, so `git diff` shows what this update did \u2014 or re-run with `--force`. Nothing was written.",
8557
+ TUI_ON_STDERR
8558
+ );
8559
+ return EXIT_REFUSED;
8560
+ }
7907
8561
  if (!opts.yes && !preview && !process.stdin.isTTY) {
7908
8562
  cancel9(
7909
8563
  "No terminal to confirm in \u2014 re-run with `--yes` to apply, or `--dry-run` to preview.",
@@ -7937,12 +8591,20 @@ async function runUpdate(argv) {
7937
8591
  const cliVersion = await readVersion();
7938
8592
  let baseComparison;
7939
8593
  let templateDir;
8594
+ const project = await resolveProjectName(root, config);
7940
8595
  if (wantsBase) {
7941
8596
  templateDir = await baseTemplateDir();
8597
+ if (project.source === "directory") {
8598
+ log7.warn(
8599
+ `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.`,
8600
+ TUI_ON_STDERR
8601
+ );
8602
+ }
7942
8603
  if (!isBaseTracked(lock, manifest)) {
7943
8604
  const adoption = await adoptBase({
7944
8605
  root,
7945
8606
  cliVersion,
8607
+ projectName: project.name,
7946
8608
  manifest,
7947
8609
  lock,
7948
8610
  templateDir,
@@ -7950,7 +8612,7 @@ async function runUpdate(argv) {
7950
8612
  });
7951
8613
  const count = adoption.adopted.length;
7952
8614
  const lines = [
7953
- `${preview ? "Would adopt" : "Adopted"} ${count} base file${count === 1 ? "" : "s"} at CLI ${pc11.cyan(cliVersion)}, each at the hash it has on disk \u2014 your edits are the baseline, not drift.`
8615
+ `${preview ? "Would adopt" : "Adopted"} ${count} base file${count === 1 ? "" : "s"} at CLI ${pc11.cyan(cliVersion)}, each at the hash it has on disk. Those bytes may be yours, so the next update treats every one of them as edited and offers a merge rather than a write.`
7954
8616
  ];
7955
8617
  if (adoption.absent.length > 0) {
7956
8618
  lines.push(
@@ -8066,6 +8728,7 @@ async function runUpdate(argv) {
8066
8728
  lock,
8067
8729
  templateDir,
8068
8730
  cliVersion,
8731
+ projectName: project.name,
8069
8732
  comparison: baseComparison
8070
8733
  });
8071
8734
  cleanups.push(handle.cleanup);
@@ -8250,6 +8913,15 @@ async function runUpdate(argv) {
8250
8913
  return EXIT_OK;
8251
8914
  }
8252
8915
  }
8916
+ const backupDir = await writeBackup({
8917
+ root,
8918
+ targets: backupTargets(plan),
8919
+ cliVersion
8920
+ });
8921
+ log7.info(
8922
+ `Pre-update copies in ${pc11.cyan(backupDir)} ${pc11.dim("\u2014 `saasaloy update --abort` restores them")}`,
8923
+ TUI_ON_STDERR
8924
+ );
8253
8925
  const outcome = await executeUpdatePlan(plan, {
8254
8926
  root,
8255
8927
  config,
@@ -8288,7 +8960,7 @@ async function runUpdate(argv) {
8288
8960
  );
8289
8961
  }
8290
8962
  outro9(summarizeOutcome(plan, result), TUI_ON_STDERR);
8291
- return EXIT_OK;
8963
+ return plan.needsMerge ? EXIT_MERGE_PENDING : EXIT_OK;
8292
8964
  } catch (error) {
8293
8965
  cancel9(formatFailure(error), TUI_ON_STDERR);
8294
8966
  return exitCodeFor(error);