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 {
|
|
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.
|
|
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
|
-
|
|
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(
|
|
3929
|
-
{
|
|
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 {
|
|
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 {
|
|
3977
|
-
|
|
3978
|
-
|
|
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
|
|
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 =
|
|
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: [
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
6473
|
+
const theirsContent = await readFile18(ref.abs, "utf-8");
|
|
6422
6474
|
const baseRef = baseFiles?.get(ref.target);
|
|
6423
|
-
const baseContent = baseRef ? await
|
|
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
|
|
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
|
|
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
|
|
7376
|
-
import { basename as basename4, dirname as
|
|
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
|
|
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
|
|
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
|
|
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
|
|
7861
|
-
await
|
|
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
|
|
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);
|