saasaloy 0.1.1 → 0.2.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.
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
  };
@@ -877,7 +878,8 @@ function upsertWranglerBinding(source, patch) {
877
878
  if (!root) {
878
879
  return source;
879
880
  }
880
- const arrayNode = findNodeAtLocation(root, [patch.bindingType]);
881
+ const bindingPath = splitBindingType(patch.bindingType);
882
+ const arrayNode = findNodeAtLocation(root, bindingPath);
881
883
  const formattingOptions = inferFormatting(source);
882
884
  if (arrayNode?.type === "array") {
883
885
  const existing = (arrayNode.children ?? []).map(
@@ -892,7 +894,7 @@ function upsertWranglerBinding(source, patch) {
892
894
  }
893
895
  const edits2 = modify(
894
896
  source,
895
- [patch.bindingType, existing.length],
897
+ [...bindingPath, existing.length],
896
898
  patch.entry,
897
899
  {
898
900
  formattingOptions,
@@ -901,7 +903,7 @@ function upsertWranglerBinding(source, patch) {
901
903
  );
902
904
  return applyEdits(source, edits2);
903
905
  }
904
- const edits = modify(source, [patch.bindingType], [patch.entry], {
906
+ const edits = modify(source, bindingPath, [patch.entry], {
905
907
  formattingOptions
906
908
  });
907
909
  return applyEdits(source, edits);
@@ -911,7 +913,8 @@ function removeWranglerBinding(source, patch) {
911
913
  if (!root) {
912
914
  return source;
913
915
  }
914
- const arrayNode = findNodeAtLocation(root, [patch.bindingType]);
916
+ const bindingPath = splitBindingType(patch.bindingType);
917
+ const arrayNode = findNodeAtLocation(root, bindingPath);
915
918
  if (arrayNode?.type !== "array") {
916
919
  return source;
917
920
  }
@@ -925,7 +928,7 @@ function removeWranglerBinding(source, patch) {
925
928
  if (matchWranglerBinding(source, patch)) {
926
929
  return source;
927
930
  }
928
- const path = existing.length === 1 ? [patch.bindingType] : [patch.bindingType, index];
931
+ const path = existing.length === 1 ? emptiedAncestorPath(root, bindingPath) : [...bindingPath, index];
929
932
  const edits = modify(source, path, void 0, {
930
933
  formattingOptions: inferFormatting(source)
931
934
  });
@@ -938,6 +941,20 @@ function wranglerBindingRemoveRefusal(source, patch) {
938
941
  }
939
942
  return `${match.key} holds ${JSON.stringify(match.current)} now, not the entry that was applied, so it is not ours to delete`;
940
943
  }
944
+ function splitBindingType(bindingType) {
945
+ return bindingType.split(".");
946
+ }
947
+ function emptiedAncestorPath(root, bindingPath) {
948
+ let cut = bindingPath.length;
949
+ for (let depth = bindingPath.length - 1; depth >= 1; depth -= 1) {
950
+ const parent = findNodeAtLocation(root, bindingPath.slice(0, depth));
951
+ if (parent?.type !== "object" || (parent.children?.length ?? 0) !== 1) {
952
+ break;
953
+ }
954
+ cut = depth;
955
+ }
956
+ return bindingPath.slice(0, cut);
957
+ }
941
958
  function findEntryIndex(existing, patch) {
942
959
  const { entry } = patch;
943
960
  if (typeof entry === "string") {
@@ -957,7 +974,10 @@ function matchWranglerBinding(source, patch) {
957
974
  if (!root) {
958
975
  return void 0;
959
976
  }
960
- const arrayNode = findNodeAtLocation(root, [patch.bindingType]);
977
+ const arrayNode = findNodeAtLocation(
978
+ root,
979
+ splitBindingType(patch.bindingType)
980
+ );
961
981
  if (arrayNode?.type !== "array") {
962
982
  return void 0;
963
983
  }
@@ -3657,7 +3677,7 @@ import pc3 from "picocolors";
3657
3677
  import { createHash as createHash2 } from "crypto";
3658
3678
  import { mkdtemp as mkdtemp2, readdir as readdir3, readFile as readFile12, rm as rm2 } from "fs/promises";
3659
3679
  import { tmpdir as tmpdir2 } from "os";
3660
- import { basename, join as join10, posix as posix6 } from "path";
3680
+ import { join as join10, posix as posix6 } from "path";
3661
3681
 
3662
3682
  // src/lib/scaffold.ts
3663
3683
  import { mkdir as mkdir5, readdir as readdir2, readFile as readFile11, writeFile as writeFile7 } from "fs/promises";
@@ -3667,7 +3687,7 @@ import { fileURLToPath as fileURLToPath3 } from "url";
3667
3687
  // package.json
3668
3688
  var package_default = {
3669
3689
  name: "saasaloy",
3670
- version: "0.1.1",
3690
+ version: "0.2.0",
3671
3691
  description: "Composable SaaS accelerator kit.",
3672
3692
  keywords: [
3673
3693
  "saas",
@@ -3807,10 +3827,20 @@ var BASE_MODULE = "base";
3807
3827
  var DEFAULT_BASE_NAME = "web";
3808
3828
  var BASE_NO_MERGE_BASE = "the template ships with the CLI; the previous version isn't on disk";
3809
3829
  var BASE_INTENT = "the base template changed; keep local edits, take the upstream change";
3830
+ function compileGlob(pattern) {
3831
+ const source = pattern.split("/").map(
3832
+ (segment) => segment === "**" ? "[^\0]*" : segment.replaceAll(/[.+^${}()|[\]\\]/g, String.raw`\$&`).replaceAll("*", "[^/]*")
3833
+ ).join("/").replaceAll("[^\0]*/", "(?:[^\0]*/)?");
3834
+ return new RegExp(`^${source}$`);
3835
+ }
3836
+ function ownedMatcher(declaration) {
3837
+ const patterns = declaration.ownedFiles.map(compileGlob);
3838
+ return (target) => patterns.some((re) => re.test(target));
3839
+ }
3810
3840
  async function readBaseDeclaration(templateDir) {
3811
3841
  const raw = await readIfPresent(join10(templateDir, BASE_DECLARATION));
3812
3842
  if (raw === void 0) {
3813
- return { seedFiles: [] };
3843
+ return { seedFiles: [], ownedFiles: [] };
3814
3844
  }
3815
3845
  const parsed = JSON.parse(raw);
3816
3846
  const seedFiles = parsed.seedFiles;
@@ -3819,7 +3849,16 @@ async function readBaseDeclaration(templateDir) {
3819
3849
  `${BASE_DECLARATION} must declare "seedFiles" as a list of project-relative paths.`
3820
3850
  );
3821
3851
  }
3822
- return { seedFiles };
3852
+ const ownedFiles = parsed.ownedFiles ?? [];
3853
+ if (!Array.isArray(ownedFiles) || !ownedFiles.every((entry) => typeof entry === "string" && entry !== "")) {
3854
+ throw new Error(
3855
+ `${BASE_DECLARATION} must declare "ownedFiles" as a list of project-relative globs.`
3856
+ );
3857
+ }
3858
+ return {
3859
+ seedFiles,
3860
+ ownedFiles
3861
+ };
3823
3862
  }
3824
3863
  async function listTemplateFiles(templateDir) {
3825
3864
  const out = [];
@@ -3881,6 +3920,7 @@ function recordBaseFiles(manifest, files) {
3881
3920
  manifest.managed[file.target] = {
3882
3921
  module: BASE_MODULE,
3883
3922
  hash: file.hash,
3923
+ ...file.adopted ? { adopted: true } : {},
3884
3924
  from: file.from
3885
3925
  };
3886
3926
  }
@@ -3888,13 +3928,28 @@ function recordBaseFiles(manifest, files) {
3888
3928
  function isBaseTracked(lock, manifest) {
3889
3929
  return lock.base !== void 0 && Object.keys(baseEntries(manifest)).length > 0;
3890
3930
  }
3891
- async function renderTemplate(root, templateDir) {
3931
+ async function missingBaseTargets(root, manifest, templateDir) {
3932
+ const seed = new Set((await readBaseDeclaration(templateDir)).seedFiles);
3933
+ const missing = [];
3934
+ for (const target of Object.keys(baseEntries(manifest))) {
3935
+ if (seed.has(target)) {
3936
+ continue;
3937
+ }
3938
+ if (await readIfPresent(resolveWithinRoot(root, target)) === void 0) {
3939
+ missing.push(target);
3940
+ }
3941
+ }
3942
+ return missing.toSorted((a, b) => a < b ? -1 : 1);
3943
+ }
3944
+ async function renderTemplate(root, templateDir, projectName) {
3892
3945
  const dir = await mkdtemp2(join10(tmpdir2(), "saasaloy-base-render-"));
3893
3946
  const files = await copyTemplate(
3894
3947
  templateDir,
3895
3948
  dir,
3896
- templateVars(basename(root)),
3897
- { keepNames: true }
3949
+ templateVars(projectName),
3950
+ {
3951
+ keepNames: true
3952
+ }
3898
3953
  );
3899
3954
  return {
3900
3955
  dir,
@@ -3903,9 +3958,16 @@ async function renderTemplate(root, templateDir) {
3903
3958
  };
3904
3959
  }
3905
3960
  async function adoptBase(args) {
3906
- const { root, cliVersion, manifest, lock, dryRun = false } = args;
3961
+ const {
3962
+ root,
3963
+ cliVersion,
3964
+ projectName,
3965
+ manifest,
3966
+ lock,
3967
+ dryRun = false
3968
+ } = args;
3907
3969
  const templateDir = args.templateDir ?? await baseTemplateDir();
3908
- const render = await renderTemplate(root, templateDir);
3970
+ const render = await renderTemplate(root, templateDir, projectName);
3909
3971
  try {
3910
3972
  const files = [];
3911
3973
  const adopted = [];
@@ -3920,7 +3982,8 @@ async function adoptBase(args) {
3920
3982
  files.push({
3921
3983
  target: file.target,
3922
3984
  from: file.from,
3923
- hash: hashContent(mine)
3985
+ hash: hashContent(mine),
3986
+ adopted: true
3924
3987
  });
3925
3988
  }
3926
3989
  }
@@ -3941,9 +4004,18 @@ async function adoptBase(args) {
3941
4004
  }
3942
4005
  }
3943
4006
  async function baseUpdateInput(args) {
3944
- const { root, manifest, lock, templateDir, cliVersion, comparison } = args;
3945
- const render = await renderTemplate(root, templateDir);
3946
- const seed = new Set((await readBaseDeclaration(templateDir)).seedFiles);
4007
+ const {
4008
+ root,
4009
+ manifest,
4010
+ lock,
4011
+ templateDir,
4012
+ cliVersion,
4013
+ projectName,
4014
+ comparison
4015
+ } = args;
4016
+ const render = await renderTemplate(root, templateDir, projectName);
4017
+ const declaration = await readBaseDeclaration(templateDir);
4018
+ const seed = new Set(declaration.seedFiles);
3947
4019
  const shipped = render.files.filter((file) => !seed.has(file.target));
3948
4020
  const targets = new Set(shipped.map((file) => file.target));
3949
4021
  return {
@@ -3968,6 +4040,7 @@ async function baseUpdateInput(args) {
3968
4040
  noMergeBase: BASE_NO_MERGE_BASE,
3969
4041
  intent: [],
3970
4042
  ignoreTargets: seed,
4043
+ isOwned: ownedMatcher(declaration),
3971
4044
  reapplyPatches: manifest.patches.filter(
3972
4045
  (patch) => targets.has(patch.file)
3973
4046
  ),
@@ -3983,7 +4056,7 @@ async function baseUpdateInput(args) {
3983
4056
 
3984
4057
  // src/lib/doctor.ts
3985
4058
  import { readFile as readFile13 } from "fs/promises";
3986
- import { basename as basename2, dirname as dirname6, join as join11, posix as posix7, resolve as resolve3 } from "path";
4059
+ import { basename, dirname as dirname6, join as join11, posix as posix7, resolve as resolve3 } from "path";
3987
4060
  var SKILL_PREFIX = "saasaloy-";
3988
4061
  var PINNED_DEP = /^(@[^/@]+\/)?[^/@]+@\d+\.\d+\.\d+(-[\w.-]+)?(\+[\w.-]+)?$/;
3989
4062
  function finding(module, where, message) {
@@ -4037,7 +4110,7 @@ async function readDescriptor(dir) {
4037
4110
  }
4038
4111
  async function checkModule(input) {
4039
4112
  const { aliases, dir, siblings } = input;
4040
- const module = basename2(dir);
4113
+ const module = basename(dir);
4041
4114
  const findings = [];
4042
4115
  const report = () => ({ dir, findings, module });
4043
4116
  const file = join11(dir, "registry-item.json");
@@ -4241,7 +4314,7 @@ function checkProject(args) {
4241
4314
  async function resolveDoctorTarget(path) {
4242
4315
  const dir = resolve3(path);
4243
4316
  if (await pathExists(join11(dir, "registry-item.json"))) {
4244
- return { names: [basename2(dir)], registryDir: dirname6(dir) };
4317
+ return { names: [basename(dir)], registryDir: dirname6(dir) };
4245
4318
  }
4246
4319
  return { names: await registryModuleNames(dir), registryDir: dir };
4247
4320
  }
@@ -4326,7 +4399,7 @@ var HELP2 = {
4326
4399
  usage: USAGE2,
4327
4400
  flags: {}
4328
4401
  };
4329
- var DEFAULT_PATH = "modules";
4402
+ var DEFAULT_PATH = ".";
4330
4403
  function parseArgs2(argv) {
4331
4404
  const positional = [];
4332
4405
  const unknown = [];
@@ -4425,9 +4498,8 @@ async function runDoctor(argv) {
4425
4498
  const path = opts.path ?? DEFAULT_PATH;
4426
4499
  try {
4427
4500
  if (!await pathExists(path)) {
4428
- const hint = opts.path === void 0 && await pathExists(CONFIG_FILE) ? ` Run \`saasaloy doctor .\` to check this project instead.` : "";
4429
4501
  cancel2(
4430
- `No such path: ${path} \u2014 point \`doctor\` at a module folder or at a directory of them.${hint}`
4502
+ `No such path: ${path} \u2014 point \`doctor\` at a module folder or at a directory of them.`
4431
4503
  );
4432
4504
  return EXIT_REFUSED;
4433
4505
  }
@@ -5091,8 +5163,8 @@ ${entry.declaration.description}`,
5091
5163
 
5092
5164
  // src/commands/init.ts
5093
5165
  import { spawn } from "child_process";
5094
- import { readdir as readdir4 } from "fs/promises";
5095
- import { basename as basename3, join as join14, resolve as resolve6 } from "path";
5166
+ import { readdir as readdir4, readFile as readFile16 } from "fs/promises";
5167
+ import { basename as basename2, join as join14, resolve as resolve6 } from "path";
5096
5168
  import {
5097
5169
  cancel as cancel4,
5098
5170
  intro as intro4,
@@ -5284,7 +5356,7 @@ async function runInit(argv) {
5284
5356
  if (!trimmed) {
5285
5357
  return "Enter a project name (or `.` for the current directory).";
5286
5358
  }
5287
- const name = basename3(resolve6(process.cwd(), trimmed));
5359
+ const name = basename2(resolve6(process.cwd(), trimmed));
5288
5360
  if (!NAME_PATTERN.test(name)) {
5289
5361
  return "Use lowercase letters, digits, and hyphens (e.g. my-app).";
5290
5362
  }
@@ -5298,7 +5370,7 @@ async function runInit(argv) {
5298
5370
  nameArg = answer.trim();
5299
5371
  }
5300
5372
  const target = resolve6(process.cwd(), nameArg);
5301
- const projectName = basename3(target);
5373
+ const projectName = basename2(target);
5302
5374
  if (!NAME_PATTERN.test(projectName)) {
5303
5375
  cancel4(
5304
5376
  `Invalid project name "${projectName}". Use lowercase letters, digits, and hyphens (e.g. my-app).`
@@ -5325,6 +5397,17 @@ async function runInit(argv) {
5325
5397
  s.stop(
5326
5398
  `Scaffolded ${pc6.cyan(projectName)} ${pc6.dim("(apps/web \xB7 packages/ui \xB7 packages/tsconfig)")}`
5327
5399
  );
5400
+ try {
5401
+ const config = await loadConfig(target);
5402
+ await saveConfig(target, { ...config, name: projectName });
5403
+ const entry = written.find((file) => file.target === CONFIG_FILE);
5404
+ if (entry) {
5405
+ entry.hash = hashContent(await readFile16(entry.path, "utf-8"));
5406
+ }
5407
+ } catch (error) {
5408
+ cancel4(`Couldn't record the project name: ${errorMessage(error)}`);
5409
+ return EXIT_FAILURE;
5410
+ }
5328
5411
  try {
5329
5412
  const manifest = await loadManifest(target);
5330
5413
  const lock = await loadLock(target);
@@ -5888,11 +5971,11 @@ import { cancel as cancel7, intro as intro7, log as log5, note as note7, outro a
5888
5971
  import pc9 from "picocolors";
5889
5972
 
5890
5973
  // src/lib/updater.ts
5891
- import { mkdir as mkdir8, readFile as readFile17, rm as rmPath2, writeFile as writeFile11 } from "fs/promises";
5974
+ import { mkdir as mkdir8, readFile as readFile18, rm as rmPath2, writeFile as writeFile11 } from "fs/promises";
5892
5975
  import { dirname as dirname11, posix as posix9 } from "path";
5893
5976
 
5894
5977
  // src/lib/remover.ts
5895
- import { readdir as readdir5, readFile as readFile16, rm as rmPath, writeFile as writeFile10 } from "fs/promises";
5978
+ import { readdir as readdir5, readFile as readFile17, rm as rmPath, writeFile as writeFile10 } from "fs/promises";
5896
5979
  import { dirname as dirname10, join as join18, relative as relative5, sep as sep3 } from "path";
5897
5980
  function classifyTrackedFile(content, manifestHash) {
5898
5981
  if (content === void 0) {
@@ -5917,7 +6000,7 @@ async function buildRemovePlan(args) {
5917
6000
  continue;
5918
6001
  }
5919
6002
  const targetAbs = resolveWithinRoot(root, target);
5920
- const oldContent = await pathExists(targetAbs) ? await readFile16(targetAbs, "utf-8") : void 0;
6003
+ const oldContent = await pathExists(targetAbs) ? await readFile17(targetAbs, "utf-8") : void 0;
5921
6004
  files.push({
5922
6005
  module: name,
5923
6006
  target,
@@ -5984,7 +6067,7 @@ async function previewPatchRemoval(root, entry) {
5984
6067
  return { entry, action: "gone", diff: "" };
5985
6068
  }
5986
6069
  await assertNoSymlinkPath(root, fileAbs);
5987
- const source = await readFile16(fileAbs, "utf-8");
6070
+ const source = await readFile17(fileAbs, "utf-8");
5988
6071
  const attempt = tryReversePatch(source, entry);
5989
6072
  if (!attempt.ok) {
5990
6073
  return { entry, action: "refused", diff: "", reason: attempt.reason };
@@ -6052,7 +6135,7 @@ async function recheckFile(file) {
6052
6135
  if (file.oldContent === void 0) {
6053
6136
  return "changed";
6054
6137
  }
6055
- const current = await readFile16(file.targetAbs, "utf-8");
6138
+ const current = await readFile17(file.targetAbs, "utf-8");
6056
6139
  return hashContent(current) === hashContent(file.oldContent) ? "unchanged" : "changed";
6057
6140
  }
6058
6141
  async function executeRemovePlan(plan, args) {
@@ -6104,7 +6187,7 @@ async function executeRemovePlan(plan, args) {
6104
6187
  let reversed = false;
6105
6188
  if (await pathExists(fileAbs)) {
6106
6189
  await assertNoSymlinkPath(root, fileAbs);
6107
- const source = await readFile16(fileAbs, "utf-8");
6190
+ const source = await readFile17(fileAbs, "utf-8");
6108
6191
  const attempt = tryReversePatch(source, entry);
6109
6192
  if (!attempt.ok) {
6110
6193
  patchRefusals.push({ patch: entry, reason: attempt.reason });
@@ -6244,6 +6327,7 @@ function baseLabel(version2, hash) {
6244
6327
  }
6245
6328
  function compareBase(args) {
6246
6329
  const { lock, manifest, runningHash, runningVersion } = args;
6330
+ const missing = args.missingTargets ?? [];
6247
6331
  const latest = baseLabel(runningVersion, runningHash);
6248
6332
  if (!isBaseTracked(lock, manifest) || !lock.base) {
6249
6333
  return {
@@ -6256,13 +6340,18 @@ function compareBase(args) {
6256
6340
  detail: "not recorded \u2014 run `saasaloy update` to adopt the base at this CLI"
6257
6341
  };
6258
6342
  }
6343
+ const moved = lock.base.templateHash !== runningHash;
6259
6344
  return {
6260
6345
  name: BASE_MODULE,
6261
6346
  source: BASE_SOURCE,
6262
6347
  ref: BASE_REF,
6263
6348
  current: baseLabel(lock.base.cliVersion, lock.base.templateHash),
6264
6349
  latest,
6265
- status: lock.base.templateHash === runningHash ? "current" : "outdated"
6350
+ status: moved || missing.length > 0 ? "outdated" : "current",
6351
+ // Say why, or an unmoved hash beside `outdated` reads as a bug.
6352
+ ...!moved && missing.length > 0 ? {
6353
+ detail: `${missing.length} tracked file${missing.length === 1 ? " is" : "s are"} missing from disk`
6354
+ } : {}
6266
6355
  };
6267
6356
  }
6268
6357
  function recordRefRewrites(lock, comparisons) {
@@ -6381,18 +6470,21 @@ async function planOneModule(args) {
6381
6470
  const baseFiles = base ? await listModuleFiles(base, aliasView, installedSet) : void 0;
6382
6471
  const files = [];
6383
6472
  for (const ref of theirsFiles.values()) {
6384
- const theirsContent = await readFile17(ref.abs, "utf-8");
6473
+ const theirsContent = await readFile18(ref.abs, "utf-8");
6385
6474
  const baseRef = baseFiles?.get(ref.target);
6386
- const baseContent = baseRef ? await readFile17(baseRef.abs, "utf-8") : void 0;
6475
+ const baseContent = baseRef ? await readFile18(baseRef.abs, "utf-8") : void 0;
6387
6476
  const targetAbs = resolveWithinRoot(root, ref.target);
6388
6477
  const managed = manifest.managed[ref.target];
6389
6478
  const owned = managed?.module === name ? managed : void 0;
6390
6479
  const mine = await readIfPresent(targetAbs);
6480
+ const projectOwned = input.isOwned?.(ref.target) ?? false;
6391
6481
  const action = classifyUpdate(
6392
6482
  baseContent,
6393
6483
  theirsContent,
6394
6484
  mine,
6395
- owned?.hash
6485
+ owned?.hash,
6486
+ owned?.adopted ?? false,
6487
+ projectOwned
6396
6488
  );
6397
6489
  const patchedBy = patchersOf(manifest, ref.target);
6398
6490
  files.push({
@@ -6401,6 +6493,8 @@ async function planOneModule(args) {
6401
6493
  target: ref.target,
6402
6494
  targetAbs,
6403
6495
  action,
6496
+ ...projectOwned ? { projectOwned: true } : {},
6497
+ ...owned?.adopted ? { adopted: true } : {},
6404
6498
  base: baseContent,
6405
6499
  theirs: theirsContent,
6406
6500
  mine,
@@ -6416,13 +6510,14 @@ async function planOneModule(args) {
6416
6510
  const targetAbs = resolveWithinRoot(root, target);
6417
6511
  const mine = await readIfPresent(targetAbs);
6418
6512
  const baseRef = baseFiles?.get(target);
6513
+ const keep = mine !== void 0 && ((entry.adopted ?? false) || (input.isOwned?.(target) ?? false));
6419
6514
  removals.push({
6420
6515
  module: name,
6421
6516
  from: entry.from ?? baseRef?.from ?? target,
6422
6517
  target,
6423
6518
  targetAbs,
6424
- action: DELETE_ACTION[classifyTrackedFile(mine, entry.hash)],
6425
- base: baseRef ? await readFile17(baseRef.abs, "utf-8") : void 0,
6519
+ action: keep ? "delete-drift" : DELETE_ACTION[classifyTrackedFile(mine, entry.hash)],
6520
+ base: baseRef ? await readFile18(baseRef.abs, "utf-8") : void 0,
6426
6521
  mine
6427
6522
  });
6428
6523
  }
@@ -6543,7 +6638,7 @@ function newEnvVars(base, theirs) {
6543
6638
  }
6544
6639
  return out;
6545
6640
  }
6546
- function classifyUpdate(base, theirs, mine, managedHash) {
6641
+ function classifyUpdate(base, theirs, mine, managedHash, adopted, projectOwned) {
6547
6642
  if (base !== void 0 && base === theirs) {
6548
6643
  return "skip";
6549
6644
  }
@@ -6556,6 +6651,9 @@ function classifyUpdate(base, theirs, mine, managedHash) {
6556
6651
  if (managedHash === void 0) {
6557
6652
  return "conflict";
6558
6653
  }
6654
+ if (adopted || projectOwned) {
6655
+ return "drift";
6656
+ }
6559
6657
  return hashContent(mine) === managedHash ? "overwrite" : "drift";
6560
6658
  }
6561
6659
  async function planLinks(root, theirs) {
@@ -6711,7 +6809,7 @@ async function executeUpdatePlan(plan, args) {
6711
6809
  clean = false;
6712
6810
  continue;
6713
6811
  }
6714
- const source = await readFile17(p.fileAbs, "utf-8");
6812
+ const source = await readFile18(p.fileAbs, "utf-8");
6715
6813
  const { content, changed } = applyPatch(source, p.patch, p.file);
6716
6814
  if (!changed) {
6717
6815
  continue;
@@ -6923,11 +7021,13 @@ async function runOutdated(argv) {
6923
7021
  `${REGISTRY_ENV} is set, so every module reads as local \u2014 unset it to compare against the registry.`
6924
7022
  );
6925
7023
  }
7024
+ const templateDir = await baseTemplateDir();
6926
7025
  const baseRow = compareBase({
6927
7026
  lock,
6928
7027
  manifest,
6929
- runningHash: await templateHash(await baseTemplateDir()),
6930
- runningVersion: await readVersion()
7028
+ runningHash: await templateHash(templateDir),
7029
+ runningVersion: await readVersion(),
7030
+ missingTargets: await missingBaseTargets(root, manifest, templateDir)
6931
7031
  });
6932
7032
  const resolvers = /* @__PURE__ */ new Map();
6933
7033
  const remote = (slug, ref) => {
@@ -7333,8 +7433,8 @@ ${pc10.dim("Hand-edited since \u2014 left on disk, now untracked.")}`
7333
7433
  }
7334
7434
 
7335
7435
  // src/commands/update.ts
7336
- import { mkdir as mkdir9, realpath, writeFile as writeFile12 } from "fs/promises";
7337
- import { basename as basename4, dirname as dirname12, isAbsolute as isAbsolute4, join as join19, resolve as resolve8 } from "path";
7436
+ import { mkdir as mkdir10, realpath, writeFile as writeFile13 } from "fs/promises";
7437
+ import { basename as basename4, dirname as dirname13, isAbsolute as isAbsolute4, join as join20, resolve as resolve8 } from "path";
7338
7438
  import {
7339
7439
  cancel as cancel9,
7340
7440
  confirm as confirm3,
@@ -7346,6 +7446,145 @@ import {
7346
7446
  } from "@clack/prompts";
7347
7447
  import pc11 from "picocolors";
7348
7448
 
7449
+ // src/lib/backup.ts
7450
+ import {
7451
+ copyFile,
7452
+ mkdir as mkdir9,
7453
+ readdir as readdir6,
7454
+ readFile as readFile19,
7455
+ rm as rmPath3,
7456
+ writeFile as writeFile12
7457
+ } from "fs/promises";
7458
+ import { dirname as dirname12, join as join19 } from "path";
7459
+ var BACKUP_DIR = join19(".saasaloy", "backups");
7460
+ var RECORD_FILE = "backup.json";
7461
+ var STATE_FILES = [
7462
+ "saasaloy.json",
7463
+ "saasaloy-lock.json",
7464
+ "package.json",
7465
+ join19(".saasaloy", "manifest.json")
7466
+ ];
7467
+ function stampName(now) {
7468
+ return now.toISOString().replaceAll(/[:.]/g, "-");
7469
+ }
7470
+ async function writeBackup(args) {
7471
+ const { root, targets, cliVersion } = args;
7472
+ const createdAt = (args.now ?? /* @__PURE__ */ new Date()).toISOString();
7473
+ const rel = join19(BACKUP_DIR, stampName(args.now ?? new Date(createdAt)));
7474
+ const dir = join19(root, rel);
7475
+ const files = [];
7476
+ const absent = [];
7477
+ for (const target of /* @__PURE__ */ new Set([...targets, ...STATE_FILES])) {
7478
+ const source = resolveWithinRoot(root, target);
7479
+ if (!await pathExists(source)) {
7480
+ absent.push(target);
7481
+ continue;
7482
+ }
7483
+ const destination = join19(dir, target);
7484
+ await mkdir9(dirname12(destination), { recursive: true });
7485
+ await copyFile(source, destination);
7486
+ files.push(target);
7487
+ }
7488
+ const record = {
7489
+ createdAt,
7490
+ cliVersion,
7491
+ files: files.toSorted(),
7492
+ absent: absent.toSorted()
7493
+ };
7494
+ await mkdir9(dir, { recursive: true });
7495
+ await writeFile12(
7496
+ join19(dir, RECORD_FILE),
7497
+ `${JSON.stringify(record, null, 2)}
7498
+ `,
7499
+ "utf-8"
7500
+ );
7501
+ return rel;
7502
+ }
7503
+ async function listBackups(root) {
7504
+ const dir = join19(root, BACKUP_DIR);
7505
+ if (!await pathExists(dir)) {
7506
+ return [];
7507
+ }
7508
+ const entries = await readdir6(dir, { withFileTypes: true });
7509
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).toSorted();
7510
+ }
7511
+ async function restoreLatestBackup(root) {
7512
+ const backups = await listBackups(root);
7513
+ const newest = backups.at(-1);
7514
+ if (!newest) {
7515
+ throw new RefusalError(
7516
+ `No backup to restore \u2014 nothing under ${BACKUP_DIR}. \`saasaloy update\` writes one before it applies a plan.`
7517
+ );
7518
+ }
7519
+ const rel = join19(BACKUP_DIR, newest);
7520
+ const dir = join19(root, rel);
7521
+ const raw = await readIfPresent(join19(dir, RECORD_FILE));
7522
+ if (raw === void 0) {
7523
+ throw new RefusalError(
7524
+ `${join19(rel, RECORD_FILE)} is missing \u2014 that backup can't be restored safely.`
7525
+ );
7526
+ }
7527
+ let record;
7528
+ try {
7529
+ record = JSON.parse(raw);
7530
+ } catch (error) {
7531
+ throw new RefusalError(`${join19(rel, RECORD_FILE)} is invalid.`, {
7532
+ cause: error
7533
+ });
7534
+ }
7535
+ const restored = [];
7536
+ for (const target of record.files) {
7537
+ const source = join19(dir, target);
7538
+ const destination = resolveWithinRoot(root, target);
7539
+ if (!await pathExists(source)) {
7540
+ continue;
7541
+ }
7542
+ await mkdir9(dirname12(destination), { recursive: true });
7543
+ await copyFile(source, destination);
7544
+ restored.push(target);
7545
+ }
7546
+ const removed = [];
7547
+ for (const target of record.absent) {
7548
+ const destination = resolveWithinRoot(root, target);
7549
+ if (await pathExists(destination)) {
7550
+ await rmPath3(destination, { force: true });
7551
+ removed.push(target);
7552
+ }
7553
+ }
7554
+ return { from: rel, restored, removed };
7555
+ }
7556
+
7557
+ // src/lib/git-state.ts
7558
+ import { execFile } from "child_process";
7559
+ async function workingTreeState(root) {
7560
+ return new Promise((resolve9) => {
7561
+ execFile(
7562
+ "git",
7563
+ // `-- .` scopes the answer to the project. Without it a project nested inside a
7564
+ // larger repository reports every unrelated change in that repository as its own.
7565
+ ["status", "--porcelain", "--untracked-files=no", "--", "."],
7566
+ { cwd: root },
7567
+ (error, stdout) => {
7568
+ resolve9(error ? "unknown" : stdout.trim() === "" ? "clean" : "dirty");
7569
+ }
7570
+ );
7571
+ });
7572
+ }
7573
+
7574
+ // src/lib/project-name.ts
7575
+ import { basename as basename3 } from "path";
7576
+ async function resolveProjectName(root, config) {
7577
+ if (config.name) {
7578
+ return { name: config.name, source: "config" };
7579
+ }
7580
+ const pkg = await readRootPackageJson(root);
7581
+ const pkgName = typeof pkg?.name === "string" ? pkg.name : void 0;
7582
+ if (pkgName) {
7583
+ return { name: pkgName, source: "package.json" };
7584
+ }
7585
+ return { name: basename3(root), source: "directory" };
7586
+ }
7587
+
7349
7588
  // src/lib/merge-plan.ts
7350
7589
  function renderMergePlan(plan) {
7351
7590
  const modules = plan.modules.filter((m) => m.needsMerge);
@@ -7460,6 +7699,17 @@ function renderFile(file) {
7460
7699
  ""
7461
7700
  );
7462
7701
  }
7702
+ if (file.projectOwned) {
7703
+ out.push(
7704
+ "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.",
7705
+ ""
7706
+ );
7707
+ } else if (file.adopted) {
7708
+ out.push(
7709
+ "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.",
7710
+ ""
7711
+ );
7712
+ }
7463
7713
  if (file.patchedBy && file.patchedBy.length > 0) {
7464
7714
  out.push(
7465
7715
  `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.`,
@@ -7572,11 +7822,13 @@ var KNOWN_FLAGS9 = /* @__PURE__ */ new Set([
7572
7822
  "--diff",
7573
7823
  "--yes",
7574
7824
  "-y",
7825
+ "--force",
7826
+ "--abort",
7575
7827
  "--help",
7576
7828
  "-h"
7577
7829
  ]);
7578
7830
  var VALUE_FLAGS2 = /* @__PURE__ */ new Set(["--ref", "--out"]);
7579
- var USAGE9 = "saasaloy update [<module>|base] [--ref <ref>] [--out <path>] [--dry-run] [--diff] [--yes]";
7831
+ var USAGE9 = "saasaloy update [<module>|base] [--ref <ref>] [--out <path>] [--dry-run] [--diff] [--force] [--yes] | saasaloy update --abort";
7580
7832
  var HELP9 = {
7581
7833
  name: "update",
7582
7834
  describe: DESCRIPTIONS.update,
@@ -7586,6 +7838,8 @@ var HELP9 = {
7586
7838
  "--out <path>": "write the merge plan to a file instead of stdout",
7587
7839
  "--dry-run": "show the plan and write nothing",
7588
7840
  "--diff": "show a per-file diff and write nothing",
7841
+ "--force": "apply even with uncommitted changes in the working tree",
7842
+ "--abort": "restore the files the last update replaced, then stop",
7589
7843
  "-y, --yes": "skip the confirmation prompt"
7590
7844
  }
7591
7845
  };
@@ -7625,6 +7879,8 @@ function parseArgs9(argv) {
7625
7879
  dryRun: argv.includes("--dry-run"),
7626
7880
  diff: argv.includes("--diff"),
7627
7881
  yes: argv.includes("--yes") || argv.includes("-y"),
7882
+ force: argv.includes("--force"),
7883
+ abort: argv.includes("--abort"),
7628
7884
  unknown
7629
7885
  };
7630
7886
  }
@@ -7785,13 +8041,13 @@ function skipReason(comparison, preview = false) {
7785
8041
  }
7786
8042
  }
7787
8043
  }
7788
- var STATE_FILES = [CONFIG_FILE, LOCK_FILE, MANIFEST_FILE];
8044
+ var STATE_FILES2 = [CONFIG_FILE, LOCK_FILE, MANIFEST_FILE];
7789
8045
  async function stateFileRefusal(root, out) {
7790
8046
  if (!out) {
7791
8047
  return void 0;
7792
8048
  }
7793
8049
  const target = await canonicalPath(resolvePlanOut(out));
7794
- for (const file of STATE_FILES) {
8050
+ for (const file of STATE_FILES2) {
7795
8051
  if (await canonicalPath(resolve8(root, file)) === target) {
7796
8052
  return `Refusing to write the merge plan to ${pc11.cyan(out)} \u2014 that is Saasaloy's own ${pc11.cyan(file)}. Pick another path.`;
7797
8053
  }
@@ -7803,7 +8059,7 @@ async function canonicalPath(path) {
7803
8059
  return await realpath(path);
7804
8060
  } catch {
7805
8061
  try {
7806
- return join19(await realpath(dirname12(path)), basename4(path));
8062
+ return join20(await realpath(dirname13(path)), basename4(path));
7807
8063
  } catch {
7808
8064
  return path;
7809
8065
  }
@@ -7818,10 +8074,24 @@ async function emitMergePlan(document, out) {
7818
8074
  return;
7819
8075
  }
7820
8076
  const target = resolvePlanOut(out);
7821
- await mkdir9(dirname12(target), { recursive: true });
7822
- await writeFile12(target, document, "utf-8");
8077
+ await mkdir10(dirname13(target), { recursive: true });
8078
+ await writeFile13(target, document, "utf-8");
7823
8079
  log7.success(`Merge plan written to ${pc11.cyan(out)}`, TUI_ON_STDERR);
7824
8080
  }
8081
+ function backupTargets(plan) {
8082
+ const targets = /* @__PURE__ */ new Set();
8083
+ for (const mod of plan.modules) {
8084
+ for (const file of [...mod.files, ...mod.removals]) {
8085
+ if (!QUIET.has(file.action) && file.action !== "skip") {
8086
+ targets.add(file.target);
8087
+ }
8088
+ }
8089
+ for (const patch of mod.patches) {
8090
+ targets.add(patch.file);
8091
+ }
8092
+ }
8093
+ return [...targets].toSorted();
8094
+ }
7825
8095
  function splitSlug2(slug) {
7826
8096
  const [owner, repo, ...rest] = slug.split("/");
7827
8097
  if (!owner || !repo || rest.length > 0) {
@@ -7859,12 +8129,43 @@ async function runUpdate(argv) {
7859
8129
  cancel9(formatFailure(error), TUI_ON_STDERR);
7860
8130
  return exitCodeFor(error);
7861
8131
  }
8132
+ if (opts.abort) {
8133
+ try {
8134
+ const restored = await restoreLatestBackup(root);
8135
+ for (const target of restored.restored) {
8136
+ log7.step(`${pc11.green("restore")} ${target}`, TUI_ON_STDERR);
8137
+ }
8138
+ for (const target of restored.removed) {
8139
+ log7.step(
8140
+ `${pc11.red("remove")} ${target} ${pc11.dim("(the update created it)")}`,
8141
+ TUI_ON_STDERR
8142
+ );
8143
+ }
8144
+ outro9(
8145
+ pc11.green(
8146
+ `Restored ${restored.restored.length} file${restored.restored.length === 1 ? "" : "s"} from ${restored.from}.`
8147
+ ),
8148
+ TUI_ON_STDERR
8149
+ );
8150
+ return EXIT_OK;
8151
+ } catch (error) {
8152
+ cancel9(formatFailure(error), TUI_ON_STDERR);
8153
+ return exitCodeFor(error);
8154
+ }
8155
+ }
7862
8156
  const outRefusal = await stateFileRefusal(root, opts.out);
7863
8157
  if (outRefusal) {
7864
8158
  cancel9(outRefusal, TUI_ON_STDERR);
7865
8159
  return EXIT_REFUSED;
7866
8160
  }
7867
8161
  const preview = opts.dryRun || opts.diff;
8162
+ if (!preview && !opts.force && await workingTreeState(root) === "dirty") {
8163
+ cancel9(
8164
+ "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.",
8165
+ TUI_ON_STDERR
8166
+ );
8167
+ return EXIT_REFUSED;
8168
+ }
7868
8169
  if (!opts.yes && !preview && !process.stdin.isTTY) {
7869
8170
  cancel9(
7870
8171
  "No terminal to confirm in \u2014 re-run with `--yes` to apply, or `--dry-run` to preview.",
@@ -7898,12 +8199,20 @@ async function runUpdate(argv) {
7898
8199
  const cliVersion = await readVersion();
7899
8200
  let baseComparison;
7900
8201
  let templateDir;
8202
+ const project = await resolveProjectName(root, config);
7901
8203
  if (wantsBase) {
7902
8204
  templateDir = await baseTemplateDir();
8205
+ if (project.source === "directory") {
8206
+ log7.warn(
8207
+ `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.`,
8208
+ TUI_ON_STDERR
8209
+ );
8210
+ }
7903
8211
  if (!isBaseTracked(lock, manifest)) {
7904
8212
  const adoption = await adoptBase({
7905
8213
  root,
7906
8214
  cliVersion,
8215
+ projectName: project.name,
7907
8216
  manifest,
7908
8217
  lock,
7909
8218
  templateDir,
@@ -7911,7 +8220,7 @@ async function runUpdate(argv) {
7911
8220
  });
7912
8221
  const count = adoption.adopted.length;
7913
8222
  const lines = [
7914
- `${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.`
8223
+ `${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.`
7915
8224
  ];
7916
8225
  if (adoption.absent.length > 0) {
7917
8226
  lines.push(
@@ -7953,7 +8262,8 @@ async function runUpdate(argv) {
7953
8262
  lock,
7954
8263
  manifest,
7955
8264
  runningHash: await templateHash(templateDir),
7956
- runningVersion: cliVersion
8265
+ runningVersion: cliVersion,
8266
+ missingTargets: await missingBaseTargets(root, manifest, templateDir)
7957
8267
  });
7958
8268
  }
7959
8269
  if (targets.length === 0 && !baseComparison) {
@@ -8026,6 +8336,7 @@ async function runUpdate(argv) {
8026
8336
  lock,
8027
8337
  templateDir,
8028
8338
  cliVersion,
8339
+ projectName: project.name,
8029
8340
  comparison: baseComparison
8030
8341
  });
8031
8342
  cleanups.push(handle.cleanup);
@@ -8210,6 +8521,15 @@ async function runUpdate(argv) {
8210
8521
  return EXIT_OK;
8211
8522
  }
8212
8523
  }
8524
+ const backupDir = await writeBackup({
8525
+ root,
8526
+ targets: backupTargets(plan),
8527
+ cliVersion
8528
+ });
8529
+ log7.info(
8530
+ `Pre-update copies in ${pc11.cyan(backupDir)} ${pc11.dim("\u2014 `saasaloy update --abort` restores them")}`,
8531
+ TUI_ON_STDERR
8532
+ );
8213
8533
  const outcome = await executeUpdatePlan(plan, {
8214
8534
  root,
8215
8535
  config,
@@ -8248,7 +8568,7 @@ async function runUpdate(argv) {
8248
8568
  );
8249
8569
  }
8250
8570
  outro9(summarizeOutcome(plan, result), TUI_ON_STDERR);
8251
- return EXIT_OK;
8571
+ return plan.needsMerge ? EXIT_MERGE_PENDING : EXIT_OK;
8252
8572
  } catch (error) {
8253
8573
  cancel9(formatFailure(error), TUI_ON_STDERR);
8254
8574
  return exitCodeFor(error);