saasaloy 0.1.2 → 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
  };
@@ -3676,7 +3677,7 @@ import pc3 from "picocolors";
3676
3677
  import { createHash as createHash2 } from "crypto";
3677
3678
  import { mkdtemp as mkdtemp2, readdir as readdir3, readFile as readFile12, rm as rm2 } from "fs/promises";
3678
3679
  import { tmpdir as tmpdir2 } from "os";
3679
- import { basename, join as join10, posix as posix6 } from "path";
3680
+ import { join as join10, posix as posix6 } from "path";
3680
3681
 
3681
3682
  // src/lib/scaffold.ts
3682
3683
  import { mkdir as mkdir5, readdir as readdir2, readFile as readFile11, writeFile as writeFile7 } from "fs/promises";
@@ -3686,7 +3687,7 @@ import { fileURLToPath as fileURLToPath3 } from "url";
3686
3687
  // package.json
3687
3688
  var package_default = {
3688
3689
  name: "saasaloy",
3689
- version: "0.1.2",
3690
+ version: "0.2.0",
3690
3691
  description: "Composable SaaS accelerator kit.",
3691
3692
  keywords: [
3692
3693
  "saas",
@@ -3826,10 +3827,20 @@ var BASE_MODULE = "base";
3826
3827
  var DEFAULT_BASE_NAME = "web";
3827
3828
  var BASE_NO_MERGE_BASE = "the template ships with the CLI; the previous version isn't on disk";
3828
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
+ }
3829
3840
  async function readBaseDeclaration(templateDir) {
3830
3841
  const raw = await readIfPresent(join10(templateDir, BASE_DECLARATION));
3831
3842
  if (raw === void 0) {
3832
- return { seedFiles: [] };
3843
+ return { seedFiles: [], ownedFiles: [] };
3833
3844
  }
3834
3845
  const parsed = JSON.parse(raw);
3835
3846
  const seedFiles = parsed.seedFiles;
@@ -3838,7 +3849,16 @@ async function readBaseDeclaration(templateDir) {
3838
3849
  `${BASE_DECLARATION} must declare "seedFiles" as a list of project-relative paths.`
3839
3850
  );
3840
3851
  }
3841
- 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
+ };
3842
3862
  }
3843
3863
  async function listTemplateFiles(templateDir) {
3844
3864
  const out = [];
@@ -3900,6 +3920,7 @@ function recordBaseFiles(manifest, files) {
3900
3920
  manifest.managed[file.target] = {
3901
3921
  module: BASE_MODULE,
3902
3922
  hash: file.hash,
3923
+ ...file.adopted ? { adopted: true } : {},
3903
3924
  from: file.from
3904
3925
  };
3905
3926
  }
@@ -3920,13 +3941,15 @@ async function missingBaseTargets(root, manifest, templateDir) {
3920
3941
  }
3921
3942
  return missing.toSorted((a, b) => a < b ? -1 : 1);
3922
3943
  }
3923
- async function renderTemplate(root, templateDir) {
3944
+ async function renderTemplate(root, templateDir, projectName) {
3924
3945
  const dir = await mkdtemp2(join10(tmpdir2(), "saasaloy-base-render-"));
3925
3946
  const files = await copyTemplate(
3926
3947
  templateDir,
3927
3948
  dir,
3928
- templateVars(basename(root)),
3929
- { keepNames: true }
3949
+ templateVars(projectName),
3950
+ {
3951
+ keepNames: true
3952
+ }
3930
3953
  );
3931
3954
  return {
3932
3955
  dir,
@@ -3935,9 +3958,16 @@ async function renderTemplate(root, templateDir) {
3935
3958
  };
3936
3959
  }
3937
3960
  async function adoptBase(args) {
3938
- 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;
3939
3969
  const templateDir = args.templateDir ?? await baseTemplateDir();
3940
- const render = await renderTemplate(root, templateDir);
3970
+ const render = await renderTemplate(root, templateDir, projectName);
3941
3971
  try {
3942
3972
  const files = [];
3943
3973
  const adopted = [];
@@ -3952,7 +3982,8 @@ async function adoptBase(args) {
3952
3982
  files.push({
3953
3983
  target: file.target,
3954
3984
  from: file.from,
3955
- hash: hashContent(mine)
3985
+ hash: hashContent(mine),
3986
+ adopted: true
3956
3987
  });
3957
3988
  }
3958
3989
  }
@@ -3973,9 +4004,18 @@ async function adoptBase(args) {
3973
4004
  }
3974
4005
  }
3975
4006
  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);
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);
3979
4019
  const shipped = render.files.filter((file) => !seed.has(file.target));
3980
4020
  const targets = new Set(shipped.map((file) => file.target));
3981
4021
  return {
@@ -4000,6 +4040,7 @@ async function baseUpdateInput(args) {
4000
4040
  noMergeBase: BASE_NO_MERGE_BASE,
4001
4041
  intent: [],
4002
4042
  ignoreTargets: seed,
4043
+ isOwned: ownedMatcher(declaration),
4003
4044
  reapplyPatches: manifest.patches.filter(
4004
4045
  (patch) => targets.has(patch.file)
4005
4046
  ),
@@ -4015,7 +4056,7 @@ async function baseUpdateInput(args) {
4015
4056
 
4016
4057
  // src/lib/doctor.ts
4017
4058
  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";
4059
+ import { basename, dirname as dirname6, join as join11, posix as posix7, resolve as resolve3 } from "path";
4019
4060
  var SKILL_PREFIX = "saasaloy-";
4020
4061
  var PINNED_DEP = /^(@[^/@]+\/)?[^/@]+@\d+\.\d+\.\d+(-[\w.-]+)?(\+[\w.-]+)?$/;
4021
4062
  function finding(module, where, message) {
@@ -4069,7 +4110,7 @@ async function readDescriptor(dir) {
4069
4110
  }
4070
4111
  async function checkModule(input) {
4071
4112
  const { aliases, dir, siblings } = input;
4072
- const module = basename2(dir);
4113
+ const module = basename(dir);
4073
4114
  const findings = [];
4074
4115
  const report = () => ({ dir, findings, module });
4075
4116
  const file = join11(dir, "registry-item.json");
@@ -4273,7 +4314,7 @@ function checkProject(args) {
4273
4314
  async function resolveDoctorTarget(path) {
4274
4315
  const dir = resolve3(path);
4275
4316
  if (await pathExists(join11(dir, "registry-item.json"))) {
4276
- return { names: [basename2(dir)], registryDir: dirname6(dir) };
4317
+ return { names: [basename(dir)], registryDir: dirname6(dir) };
4277
4318
  }
4278
4319
  return { names: await registryModuleNames(dir), registryDir: dir };
4279
4320
  }
@@ -5122,8 +5163,8 @@ ${entry.declaration.description}`,
5122
5163
 
5123
5164
  // src/commands/init.ts
5124
5165
  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";
5166
+ import { readdir as readdir4, readFile as readFile16 } from "fs/promises";
5167
+ import { basename as basename2, join as join14, resolve as resolve6 } from "path";
5127
5168
  import {
5128
5169
  cancel as cancel4,
5129
5170
  intro as intro4,
@@ -5315,7 +5356,7 @@ async function runInit(argv) {
5315
5356
  if (!trimmed) {
5316
5357
  return "Enter a project name (or `.` for the current directory).";
5317
5358
  }
5318
- const name = basename3(resolve6(process.cwd(), trimmed));
5359
+ const name = basename2(resolve6(process.cwd(), trimmed));
5319
5360
  if (!NAME_PATTERN.test(name)) {
5320
5361
  return "Use lowercase letters, digits, and hyphens (e.g. my-app).";
5321
5362
  }
@@ -5329,7 +5370,7 @@ async function runInit(argv) {
5329
5370
  nameArg = answer.trim();
5330
5371
  }
5331
5372
  const target = resolve6(process.cwd(), nameArg);
5332
- const projectName = basename3(target);
5373
+ const projectName = basename2(target);
5333
5374
  if (!NAME_PATTERN.test(projectName)) {
5334
5375
  cancel4(
5335
5376
  `Invalid project name "${projectName}". Use lowercase letters, digits, and hyphens (e.g. my-app).`
@@ -5356,6 +5397,17 @@ async function runInit(argv) {
5356
5397
  s.stop(
5357
5398
  `Scaffolded ${pc6.cyan(projectName)} ${pc6.dim("(apps/web \xB7 packages/ui \xB7 packages/tsconfig)")}`
5358
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
+ }
5359
5411
  try {
5360
5412
  const manifest = await loadManifest(target);
5361
5413
  const lock = await loadLock(target);
@@ -5919,11 +5971,11 @@ import { cancel as cancel7, intro as intro7, log as log5, note as note7, outro a
5919
5971
  import pc9 from "picocolors";
5920
5972
 
5921
5973
  // src/lib/updater.ts
5922
- 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";
5923
5975
  import { dirname as dirname11, posix as posix9 } from "path";
5924
5976
 
5925
5977
  // src/lib/remover.ts
5926
- 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";
5927
5979
  import { dirname as dirname10, join as join18, relative as relative5, sep as sep3 } from "path";
5928
5980
  function classifyTrackedFile(content, manifestHash) {
5929
5981
  if (content === void 0) {
@@ -5948,7 +6000,7 @@ async function buildRemovePlan(args) {
5948
6000
  continue;
5949
6001
  }
5950
6002
  const targetAbs = resolveWithinRoot(root, target);
5951
- 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;
5952
6004
  files.push({
5953
6005
  module: name,
5954
6006
  target,
@@ -6015,7 +6067,7 @@ async function previewPatchRemoval(root, entry) {
6015
6067
  return { entry, action: "gone", diff: "" };
6016
6068
  }
6017
6069
  await assertNoSymlinkPath(root, fileAbs);
6018
- const source = await readFile16(fileAbs, "utf-8");
6070
+ const source = await readFile17(fileAbs, "utf-8");
6019
6071
  const attempt = tryReversePatch(source, entry);
6020
6072
  if (!attempt.ok) {
6021
6073
  return { entry, action: "refused", diff: "", reason: attempt.reason };
@@ -6083,7 +6135,7 @@ async function recheckFile(file) {
6083
6135
  if (file.oldContent === void 0) {
6084
6136
  return "changed";
6085
6137
  }
6086
- const current = await readFile16(file.targetAbs, "utf-8");
6138
+ const current = await readFile17(file.targetAbs, "utf-8");
6087
6139
  return hashContent(current) === hashContent(file.oldContent) ? "unchanged" : "changed";
6088
6140
  }
6089
6141
  async function executeRemovePlan(plan, args) {
@@ -6135,7 +6187,7 @@ async function executeRemovePlan(plan, args) {
6135
6187
  let reversed = false;
6136
6188
  if (await pathExists(fileAbs)) {
6137
6189
  await assertNoSymlinkPath(root, fileAbs);
6138
- const source = await readFile16(fileAbs, "utf-8");
6190
+ const source = await readFile17(fileAbs, "utf-8");
6139
6191
  const attempt = tryReversePatch(source, entry);
6140
6192
  if (!attempt.ok) {
6141
6193
  patchRefusals.push({ patch: entry, reason: attempt.reason });
@@ -6418,18 +6470,21 @@ async function planOneModule(args) {
6418
6470
  const baseFiles = base ? await listModuleFiles(base, aliasView, installedSet) : void 0;
6419
6471
  const files = [];
6420
6472
  for (const ref of theirsFiles.values()) {
6421
- const theirsContent = await readFile17(ref.abs, "utf-8");
6473
+ const theirsContent = await readFile18(ref.abs, "utf-8");
6422
6474
  const baseRef = baseFiles?.get(ref.target);
6423
- const baseContent = baseRef ? await readFile17(baseRef.abs, "utf-8") : void 0;
6475
+ const baseContent = baseRef ? await readFile18(baseRef.abs, "utf-8") : void 0;
6424
6476
  const targetAbs = resolveWithinRoot(root, ref.target);
6425
6477
  const managed = manifest.managed[ref.target];
6426
6478
  const owned = managed?.module === name ? managed : void 0;
6427
6479
  const mine = await readIfPresent(targetAbs);
6480
+ const projectOwned = input.isOwned?.(ref.target) ?? false;
6428
6481
  const action = classifyUpdate(
6429
6482
  baseContent,
6430
6483
  theirsContent,
6431
6484
  mine,
6432
- owned?.hash
6485
+ owned?.hash,
6486
+ owned?.adopted ?? false,
6487
+ projectOwned
6433
6488
  );
6434
6489
  const patchedBy = patchersOf(manifest, ref.target);
6435
6490
  files.push({
@@ -6438,6 +6493,8 @@ async function planOneModule(args) {
6438
6493
  target: ref.target,
6439
6494
  targetAbs,
6440
6495
  action,
6496
+ ...projectOwned ? { projectOwned: true } : {},
6497
+ ...owned?.adopted ? { adopted: true } : {},
6441
6498
  base: baseContent,
6442
6499
  theirs: theirsContent,
6443
6500
  mine,
@@ -6453,13 +6510,14 @@ async function planOneModule(args) {
6453
6510
  const targetAbs = resolveWithinRoot(root, target);
6454
6511
  const mine = await readIfPresent(targetAbs);
6455
6512
  const baseRef = baseFiles?.get(target);
6513
+ const keep = mine !== void 0 && ((entry.adopted ?? false) || (input.isOwned?.(target) ?? false));
6456
6514
  removals.push({
6457
6515
  module: name,
6458
6516
  from: entry.from ?? baseRef?.from ?? target,
6459
6517
  target,
6460
6518
  targetAbs,
6461
- action: DELETE_ACTION[classifyTrackedFile(mine, entry.hash)],
6462
- 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,
6463
6521
  mine
6464
6522
  });
6465
6523
  }
@@ -6580,7 +6638,7 @@ function newEnvVars(base, theirs) {
6580
6638
  }
6581
6639
  return out;
6582
6640
  }
6583
- function classifyUpdate(base, theirs, mine, managedHash) {
6641
+ function classifyUpdate(base, theirs, mine, managedHash, adopted, projectOwned) {
6584
6642
  if (base !== void 0 && base === theirs) {
6585
6643
  return "skip";
6586
6644
  }
@@ -6593,6 +6651,9 @@ function classifyUpdate(base, theirs, mine, managedHash) {
6593
6651
  if (managedHash === void 0) {
6594
6652
  return "conflict";
6595
6653
  }
6654
+ if (adopted || projectOwned) {
6655
+ return "drift";
6656
+ }
6596
6657
  return hashContent(mine) === managedHash ? "overwrite" : "drift";
6597
6658
  }
6598
6659
  async function planLinks(root, theirs) {
@@ -6748,7 +6809,7 @@ async function executeUpdatePlan(plan, args) {
6748
6809
  clean = false;
6749
6810
  continue;
6750
6811
  }
6751
- const source = await readFile17(p.fileAbs, "utf-8");
6812
+ const source = await readFile18(p.fileAbs, "utf-8");
6752
6813
  const { content, changed } = applyPatch(source, p.patch, p.file);
6753
6814
  if (!changed) {
6754
6815
  continue;
@@ -7372,8 +7433,8 @@ ${pc10.dim("Hand-edited since \u2014 left on disk, now untracked.")}`
7372
7433
  }
7373
7434
 
7374
7435
  // 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";
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";
7377
7438
  import {
7378
7439
  cancel as cancel9,
7379
7440
  confirm as confirm3,
@@ -7385,6 +7446,145 @@ import {
7385
7446
  } from "@clack/prompts";
7386
7447
  import pc11 from "picocolors";
7387
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
+
7388
7588
  // src/lib/merge-plan.ts
7389
7589
  function renderMergePlan(plan) {
7390
7590
  const modules = plan.modules.filter((m) => m.needsMerge);
@@ -7499,6 +7699,17 @@ function renderFile(file) {
7499
7699
  ""
7500
7700
  );
7501
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
+ }
7502
7713
  if (file.patchedBy && file.patchedBy.length > 0) {
7503
7714
  out.push(
7504
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.`,
@@ -7611,11 +7822,13 @@ var KNOWN_FLAGS9 = /* @__PURE__ */ new Set([
7611
7822
  "--diff",
7612
7823
  "--yes",
7613
7824
  "-y",
7825
+ "--force",
7826
+ "--abort",
7614
7827
  "--help",
7615
7828
  "-h"
7616
7829
  ]);
7617
7830
  var VALUE_FLAGS2 = /* @__PURE__ */ new Set(["--ref", "--out"]);
7618
- 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";
7619
7832
  var HELP9 = {
7620
7833
  name: "update",
7621
7834
  describe: DESCRIPTIONS.update,
@@ -7625,6 +7838,8 @@ var HELP9 = {
7625
7838
  "--out <path>": "write the merge plan to a file instead of stdout",
7626
7839
  "--dry-run": "show the plan and write nothing",
7627
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",
7628
7843
  "-y, --yes": "skip the confirmation prompt"
7629
7844
  }
7630
7845
  };
@@ -7664,6 +7879,8 @@ function parseArgs9(argv) {
7664
7879
  dryRun: argv.includes("--dry-run"),
7665
7880
  diff: argv.includes("--diff"),
7666
7881
  yes: argv.includes("--yes") || argv.includes("-y"),
7882
+ force: argv.includes("--force"),
7883
+ abort: argv.includes("--abort"),
7667
7884
  unknown
7668
7885
  };
7669
7886
  }
@@ -7824,13 +8041,13 @@ function skipReason(comparison, preview = false) {
7824
8041
  }
7825
8042
  }
7826
8043
  }
7827
- var STATE_FILES = [CONFIG_FILE, LOCK_FILE, MANIFEST_FILE];
8044
+ var STATE_FILES2 = [CONFIG_FILE, LOCK_FILE, MANIFEST_FILE];
7828
8045
  async function stateFileRefusal(root, out) {
7829
8046
  if (!out) {
7830
8047
  return void 0;
7831
8048
  }
7832
8049
  const target = await canonicalPath(resolvePlanOut(out));
7833
- for (const file of STATE_FILES) {
8050
+ for (const file of STATE_FILES2) {
7834
8051
  if (await canonicalPath(resolve8(root, file)) === target) {
7835
8052
  return `Refusing to write the merge plan to ${pc11.cyan(out)} \u2014 that is Saasaloy's own ${pc11.cyan(file)}. Pick another path.`;
7836
8053
  }
@@ -7842,7 +8059,7 @@ async function canonicalPath(path) {
7842
8059
  return await realpath(path);
7843
8060
  } catch {
7844
8061
  try {
7845
- return join19(await realpath(dirname12(path)), basename4(path));
8062
+ return join20(await realpath(dirname13(path)), basename4(path));
7846
8063
  } catch {
7847
8064
  return path;
7848
8065
  }
@@ -7857,10 +8074,24 @@ async function emitMergePlan(document, out) {
7857
8074
  return;
7858
8075
  }
7859
8076
  const target = resolvePlanOut(out);
7860
- await mkdir9(dirname12(target), { recursive: true });
7861
- await writeFile12(target, document, "utf-8");
8077
+ await mkdir10(dirname13(target), { recursive: true });
8078
+ await writeFile13(target, document, "utf-8");
7862
8079
  log7.success(`Merge plan written to ${pc11.cyan(out)}`, TUI_ON_STDERR);
7863
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
+ }
7864
8095
  function splitSlug2(slug) {
7865
8096
  const [owner, repo, ...rest] = slug.split("/");
7866
8097
  if (!owner || !repo || rest.length > 0) {
@@ -7898,12 +8129,43 @@ async function runUpdate(argv) {
7898
8129
  cancel9(formatFailure(error), TUI_ON_STDERR);
7899
8130
  return exitCodeFor(error);
7900
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
+ }
7901
8156
  const outRefusal = await stateFileRefusal(root, opts.out);
7902
8157
  if (outRefusal) {
7903
8158
  cancel9(outRefusal, TUI_ON_STDERR);
7904
8159
  return EXIT_REFUSED;
7905
8160
  }
7906
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
+ }
7907
8169
  if (!opts.yes && !preview && !process.stdin.isTTY) {
7908
8170
  cancel9(
7909
8171
  "No terminal to confirm in \u2014 re-run with `--yes` to apply, or `--dry-run` to preview.",
@@ -7937,12 +8199,20 @@ async function runUpdate(argv) {
7937
8199
  const cliVersion = await readVersion();
7938
8200
  let baseComparison;
7939
8201
  let templateDir;
8202
+ const project = await resolveProjectName(root, config);
7940
8203
  if (wantsBase) {
7941
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
+ }
7942
8211
  if (!isBaseTracked(lock, manifest)) {
7943
8212
  const adoption = await adoptBase({
7944
8213
  root,
7945
8214
  cliVersion,
8215
+ projectName: project.name,
7946
8216
  manifest,
7947
8217
  lock,
7948
8218
  templateDir,
@@ -7950,7 +8220,7 @@ async function runUpdate(argv) {
7950
8220
  });
7951
8221
  const count = adoption.adopted.length;
7952
8222
  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.`
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.`
7954
8224
  ];
7955
8225
  if (adoption.absent.length > 0) {
7956
8226
  lines.push(
@@ -8066,6 +8336,7 @@ async function runUpdate(argv) {
8066
8336
  lock,
8067
8337
  templateDir,
8068
8338
  cliVersion,
8339
+ projectName: project.name,
8069
8340
  comparison: baseComparison
8070
8341
  });
8071
8342
  cleanups.push(handle.cleanup);
@@ -8250,6 +8521,15 @@ async function runUpdate(argv) {
8250
8521
  return EXIT_OK;
8251
8522
  }
8252
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
+ );
8253
8533
  const outcome = await executeUpdatePlan(plan, {
8254
8534
  root,
8255
8535
  config,
@@ -8288,7 +8568,7 @@ async function runUpdate(argv) {
8288
8568
  );
8289
8569
  }
8290
8570
  outro9(summarizeOutcome(plan, result), TUI_ON_STDERR);
8291
- return EXIT_OK;
8571
+ return plan.needsMerge ? EXIT_MERGE_PENDING : EXIT_OK;
8292
8572
  } catch (error) {
8293
8573
  cancel9(formatFailure(error), TUI_ON_STDERR);
8294
8574
  return exitCodeFor(error);