@sous-io/sous 0.2.17 → 0.2.18
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/docs/markdown/commands.md +36 -6
- package/docs/markdown/repositories-authoring.md +23 -2
- package/docs/markdown/repositories-consuming.md +44 -2
- package/docs/markdown/repositories-file-formats.md +2 -1
- package/package.json +1 -1
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/src/commands/repo/unlink.ts +333 -20
- package/src/commands/subscription/update.ts +215 -0
- package/src/lib/repos/formats/links-map.ts +5 -3
- package/src/lib/repos/git-clone.ts +71 -0
- package/src/lib/repos/links.ts +2 -1
- package/src/lib/repos/locked-recipes.ts +22 -0
- package/src/lib/repos/resolver.ts +25 -2
- package/src/lib/repos/seed.ts +64 -5
- package/src/lib/repos/store/hash.ts +68 -8
- package/src/lib/repos/subscription-service.ts +744 -20
- package/src/lib/repos/update-plan.ts +234 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sous subscription update [ref]`.
|
|
3
|
+
*
|
|
4
|
+
* Moves the lockfile's pins to the newest versions their ranges allow. With no
|
|
5
|
+
* reference it covers every subscription; a reference narrows it to one
|
|
6
|
+
* repository, one namespace or one recipe:
|
|
7
|
+
*
|
|
8
|
+
* sous subscription update
|
|
9
|
+
* sous subscription update sous-recipes
|
|
10
|
+
* sous subscription update workflow
|
|
11
|
+
* sous subscription update workflow/task-files
|
|
12
|
+
*
|
|
13
|
+
* Every trusted repository's index is fetched fresh first. A pin moves only
|
|
14
|
+
* within the range its subscription (or the recipe depending on it) declares,
|
|
15
|
+
* and dependencies move with the closure. Only the lockfile changes; the
|
|
16
|
+
* subscriptions themselves are never edited. The whole change is printed as a
|
|
17
|
+
* plan and asked about once, and the project is rebuilt afterwards.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { Args, Flags } from "@oclif/core";
|
|
21
|
+
import { BaseCommand } from "../../base-command.js";
|
|
22
|
+
import { buildProjectOutputs } from "../../lib/build-service.js";
|
|
23
|
+
import { ConfigError } from "../../lib/errors.js";
|
|
24
|
+
import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js";
|
|
25
|
+
import { describeUpdateScope } from "../../lib/repos/update-plan.js";
|
|
26
|
+
import { formatAskReport } from "../../lib/vars/ask.js";
|
|
27
|
+
import { collectProvidedAnswers } from "../../lib/vars/index.js";
|
|
28
|
+
import {
|
|
29
|
+
blankLine,
|
|
30
|
+
dryRunNotice,
|
|
31
|
+
footer,
|
|
32
|
+
heading,
|
|
33
|
+
indent,
|
|
34
|
+
log,
|
|
35
|
+
paragraph,
|
|
36
|
+
showCommandVars,
|
|
37
|
+
subheading,
|
|
38
|
+
warning,
|
|
39
|
+
} from "../../utils/formatting.js";
|
|
40
|
+
import { answerFlags, confirmationFlag } from "../../utils/flags.js";
|
|
41
|
+
|
|
42
|
+
export default class SubscriptionUpdate extends BaseCommand {
|
|
43
|
+
static description =
|
|
44
|
+
"Move this project's pinned recipe versions to the newest ones their ranges allow";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The other spelling of the topic. It lives under a hidden topic, so it is
|
|
48
|
+
* typable everywhere without ever reaching the top-level listing.
|
|
49
|
+
*/
|
|
50
|
+
static aliases = ["subscriptions:update"];
|
|
51
|
+
|
|
52
|
+
static examples = [
|
|
53
|
+
"<%= config.bin %> subscription update",
|
|
54
|
+
"<%= config.bin %> subscription update sous-recipes",
|
|
55
|
+
"<%= config.bin %> subscription update workflow/task-files",
|
|
56
|
+
"<%= config.bin %> subscription update --dry-run",
|
|
57
|
+
"<%= config.bin %> subscription update --yes",
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
static args = {
|
|
61
|
+
ref: Args.string({
|
|
62
|
+
description:
|
|
63
|
+
"What to update: a repository, a namespace or a recipe. Leave it out to update everything",
|
|
64
|
+
required: false,
|
|
65
|
+
}),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
static flags = {
|
|
69
|
+
...BaseCommand.baseFlags,
|
|
70
|
+
// One flag answers both questions this command can ask: the plan, and the
|
|
71
|
+
// trust question for a repository a newer version needs.
|
|
72
|
+
yes: confirmationFlag({ extraAliases: ["trust"] }),
|
|
73
|
+
"accept-first": Flags.boolean({
|
|
74
|
+
description:
|
|
75
|
+
"When the reference matches several things, take the first one listed",
|
|
76
|
+
default: false,
|
|
77
|
+
}),
|
|
78
|
+
"dry-run": Flags.boolean({
|
|
79
|
+
description:
|
|
80
|
+
"Print what would change without writing anything or downloading any recipe",
|
|
81
|
+
default: false,
|
|
82
|
+
}),
|
|
83
|
+
"no-build": Flags.boolean({
|
|
84
|
+
description: "Change the lockfile without rebuilding the project",
|
|
85
|
+
default: false,
|
|
86
|
+
}),
|
|
87
|
+
...answerFlags(),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
async run(): Promise<void> {
|
|
91
|
+
const { args, flags } = await this.parse(SubscriptionUpdate);
|
|
92
|
+
const dryRun = flags["dry-run"];
|
|
93
|
+
|
|
94
|
+
showCommandVars({
|
|
95
|
+
Project: this.projectLabel,
|
|
96
|
+
Config: this.configContext.configPath,
|
|
97
|
+
Updating: args.ref ?? "every subscription",
|
|
98
|
+
"Dry Run": dryRun,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
heading("Updating");
|
|
102
|
+
|
|
103
|
+
if (dryRun) {
|
|
104
|
+
dryRunNotice(
|
|
105
|
+
"The indexes are fetched so the plan is current; no recipe is downloaded and " +
|
|
106
|
+
"nothing in this project is written."
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const service = subscriptionServiceFor({
|
|
111
|
+
configContext: this.configContext,
|
|
112
|
+
settings: this.settings,
|
|
113
|
+
shellEnv: this.shellEnv,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const provided = collectProvidedAnswers({
|
|
117
|
+
...(flags.answer === undefined ? {} : { answer: flags.answer }),
|
|
118
|
+
...(flags["answers-file"] === undefined ? {} : { answersFile: flags["answers-file"] }),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const outcome = await service.update({
|
|
122
|
+
...(args.ref === undefined ? {} : { ref: args.ref }),
|
|
123
|
+
yes: flags.yes,
|
|
124
|
+
acceptFirst: flags["accept-first"],
|
|
125
|
+
answers: provided,
|
|
126
|
+
dryRun,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const scope = describeUpdateScope(outcome.scope);
|
|
130
|
+
|
|
131
|
+
if (outcome.nothingToUpdate) {
|
|
132
|
+
footer();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (dryRun) {
|
|
137
|
+
paragraph(
|
|
138
|
+
`Nothing was written. Run the same command without '--dry-run' to update ${scope}.`
|
|
139
|
+
);
|
|
140
|
+
footer();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
blankLine();
|
|
145
|
+
subheading("Lockfile");
|
|
146
|
+
blankLine();
|
|
147
|
+
if (outcome.diff.unchanged) {
|
|
148
|
+
paragraph("Nothing changed.");
|
|
149
|
+
} else {
|
|
150
|
+
for (const line of outcome.diff.lines) log(indent(line));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (outcome.trusted.length > 0) {
|
|
154
|
+
blankLine();
|
|
155
|
+
paragraph(
|
|
156
|
+
`Repositories trusted along the way: ${outcome.trusted.join(", ")}. They are ` +
|
|
157
|
+
`now recorded in this project's config, and your colleagues inherit them.`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (outcome.answers !== undefined) {
|
|
162
|
+
blankLine();
|
|
163
|
+
subheading("Variables");
|
|
164
|
+
for (const line of formatAskReport(outcome.answers)) {
|
|
165
|
+
log(line === "" ? "" : indent(line));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (outcome.cycles.length > 0) {
|
|
170
|
+
warning(
|
|
171
|
+
`Some of these recipes co-subscribe to each other in a circle:\n` +
|
|
172
|
+
outcome.cycles.map((cycle) => ` ${cycle.join(" -> ")}`).join("\n") +
|
|
173
|
+
`\nThat is unusual but not broken, and the lockfile was updated.`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const rebuilding = !flags["no-build"] && !outcome.diff.unchanged;
|
|
178
|
+
|
|
179
|
+
blankLine();
|
|
180
|
+
paragraph(
|
|
181
|
+
`The lockfile now pins the newest versions the ranges allow for ${scope}. ` +
|
|
182
|
+
`This project's subscriptions are exactly as they were.` +
|
|
183
|
+
(rebuilding || outcome.diff.unchanged ? "" : " The project was not rebuilt.")
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
footer();
|
|
187
|
+
|
|
188
|
+
if (rebuilding) await this.rebuildProject();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Rebuilds the project now that the lockfile has moved, so the outputs match
|
|
193
|
+
* the new versions when this command returns. The config is reloaded first,
|
|
194
|
+
* because a repository trusted along the way was written into a managed layer.
|
|
195
|
+
* A build that fails leaves the update in place, because it is already
|
|
196
|
+
* written; the message says so.
|
|
197
|
+
*/
|
|
198
|
+
private async rebuildProject(): Promise<void> {
|
|
199
|
+
await this.reloadDiscoveredConfig();
|
|
200
|
+
|
|
201
|
+
heading("Building the project");
|
|
202
|
+
|
|
203
|
+
const succeeded = await buildProjectOutputs(this.settings, this.configContext);
|
|
204
|
+
|
|
205
|
+
footer();
|
|
206
|
+
|
|
207
|
+
if (!succeeded) {
|
|
208
|
+
throw new ConfigError(
|
|
209
|
+
`The lockfile was updated, but the build that followed it failed, so this ` +
|
|
210
|
+
`project's outputs may not match the new versions yet. The update itself is ` +
|
|
211
|
+
`recorded; fix what the build reported above and run 'sous build' again.`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* A link redirects a repo's resolution away from the store and at a real
|
|
5
5
|
* working copy on disk, which is how a maintainer edits recipes: edits happen
|
|
6
6
|
* in a checkout, never in the store. `sous repo link` writes an entry;
|
|
7
|
-
* `sous repo unlink` removes it and leaves the checkout in place
|
|
7
|
+
* `sous repo unlink` removes it and leaves the checkout in place unless asked
|
|
8
|
+
* to delete one sous cloned.
|
|
8
9
|
*
|
|
9
10
|
* Two maps are read: the project's `.sous/sous.links.json` and the machine-wide
|
|
10
11
|
* `$SOUS_HOME/sous.links.json`, with the project map winning on conflict. The
|
|
@@ -34,8 +35,9 @@ export const repoLinkSchema = z.strictObject({
|
|
|
34
35
|
linkedAt: isoTimestampSchema,
|
|
35
36
|
/**
|
|
36
37
|
* Whether sous cloned the working copy itself ('clone') or was pointed at an
|
|
37
|
-
* existing checkout ('path'). Unlinking
|
|
38
|
-
*
|
|
38
|
+
* existing checkout ('path'). Unlinking leaves either in place unless
|
|
39
|
+
* `--remove` is passed, and even then only a 'clone' is ever deleted: a
|
|
40
|
+
* 'path' checkout belongs to whoever linked it.
|
|
39
41
|
*/
|
|
40
42
|
origin: z.enum(LINK_ORIGINS),
|
|
41
43
|
});
|
|
@@ -227,6 +227,77 @@ export function cloneRepo(
|
|
|
227
227
|
return { depth: 0, fellBackToFullClone: depth > 0 };
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
/** Work in a checkout that exists nowhere else, and would be lost with it. */
|
|
231
|
+
export type UnsavedWork = {
|
|
232
|
+
/** Every changed or untracked path, as `git status --porcelain` prints it. */
|
|
233
|
+
uncommitted: string[];
|
|
234
|
+
/** Every commit on a local branch that no remote has, one line each. */
|
|
235
|
+
unpushed: string[];
|
|
236
|
+
/** Every stash entry, one line each. */
|
|
237
|
+
stashes: string[];
|
|
238
|
+
/**
|
|
239
|
+
* Why git could not be asked, when it could not. Nothing is known about the
|
|
240
|
+
* checkout then, which a caller must treat as possibly holding work.
|
|
241
|
+
*/
|
|
242
|
+
unknown?: string;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* What in a checkout would be lost if it were deleted: uncommitted changes,
|
|
247
|
+
* commits no remote has, and stashes.
|
|
248
|
+
*
|
|
249
|
+
* unsavedWork("/path/to/checkout")
|
|
250
|
+
* // -> { uncommitted: ["M README.md"], unpushed: [], stashes: [] }
|
|
251
|
+
*
|
|
252
|
+
* @param directory - The checkout to inspect.
|
|
253
|
+
* @param options - The git runner to use.
|
|
254
|
+
*/
|
|
255
|
+
export function unsavedWork(directory: string, options: GitOptions = {}): UnsavedWork {
|
|
256
|
+
const runner = options.runner ?? runGit;
|
|
257
|
+
const lines = (text: string): string[] =>
|
|
258
|
+
text
|
|
259
|
+
.split("\n")
|
|
260
|
+
.map((line) => line.trim())
|
|
261
|
+
.filter((line) => line.length > 0);
|
|
262
|
+
|
|
263
|
+
const status = runner(["status", "--porcelain"], { cwd: directory });
|
|
264
|
+
if (status.status !== 0) {
|
|
265
|
+
return {
|
|
266
|
+
uncommitted: [],
|
|
267
|
+
unpushed: [],
|
|
268
|
+
stashes: [],
|
|
269
|
+
unknown:
|
|
270
|
+
`git could not read the checkout at ${directory}` +
|
|
271
|
+
(status.stderr.length > 0 ? `: ${status.stderr}` : "."),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const unpushed = runner(["log", "--branches", "--not", "--remotes", "--oneline"], {
|
|
276
|
+
cwd: directory,
|
|
277
|
+
});
|
|
278
|
+
const stashes = runner(["stash", "list"], { cwd: directory });
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
uncommitted: lines(status.stdout),
|
|
282
|
+
unpushed: unpushed.status === 0 ? lines(unpushed.stdout) : [],
|
|
283
|
+
stashes: stashes.status === 0 ? lines(stashes.stdout) : [],
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* True when an inspection found nothing that would be lost.
|
|
289
|
+
*
|
|
290
|
+
* @param work - What `unsavedWork` found.
|
|
291
|
+
*/
|
|
292
|
+
export function hasNoUnsavedWork(work: UnsavedWork): boolean {
|
|
293
|
+
return (
|
|
294
|
+
work.unknown === undefined &&
|
|
295
|
+
work.uncommitted.length === 0 &&
|
|
296
|
+
work.unpushed.length === 0 &&
|
|
297
|
+
work.stashes.length === 0
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
230
301
|
/**
|
|
231
302
|
* True when two remote URLs name the same repository, ignoring the differences
|
|
232
303
|
* that never change what is fetched: a `.git` suffix, a trailing slash, the
|
package/src/lib/repos/links.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* A link redirects one repository's resolution away from the store and at a
|
|
5
5
|
* real working copy on disk, which is how a maintainer edits recipes: edits
|
|
6
6
|
* happen in a checkout, never in the store. `sous repo link` writes an entry
|
|
7
|
-
* here; `sous repo unlink` removes it and leaves the checkout alone
|
|
7
|
+
* here; `sous repo unlink` removes it and leaves the checkout alone unless
|
|
8
|
+
* `--remove` asks it to delete one sous cloned.
|
|
8
9
|
*
|
|
9
10
|
* Two maps exist. The project's `.sous/sous.links.json` covers one project; the
|
|
10
11
|
* machine-wide `$SOUS_HOME/sous.links.json` covers every project on the machine,
|
|
@@ -252,3 +252,25 @@ export function projectSubscriptionRefs(
|
|
|
252
252
|
}
|
|
253
253
|
return [...refs].sort();
|
|
254
254
|
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Every locked recipe reachable from a set of keys through the lockfile's
|
|
258
|
+
* holder lists: the keys themselves, whatever they hold, and so on.
|
|
259
|
+
*
|
|
260
|
+
* @param lock - The lockfile.
|
|
261
|
+
* @param roots - The keys to start from.
|
|
262
|
+
*/
|
|
263
|
+
export function lockedClosure(lock: Lockfile, roots: string[]): string[] {
|
|
264
|
+
const reached = new Set(roots.filter((key) => Object.hasOwn(lock.recipes, key)));
|
|
265
|
+
const queue = [...reached];
|
|
266
|
+
while (queue.length > 0) {
|
|
267
|
+
const holder = queue.shift()!;
|
|
268
|
+
for (const [key, entry] of Object.entries(lock.recipes)) {
|
|
269
|
+
if (reached.has(key) || !entry.requestedBy.includes(holder)) continue;
|
|
270
|
+
reached.add(key);
|
|
271
|
+
queue.push(key);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return [...reached].sort();
|
|
275
|
+
}
|
|
276
|
+
|
|
@@ -95,6 +95,14 @@ export type ResolveContext = {
|
|
|
95
95
|
loadManifest: RecipeManifestLoader;
|
|
96
96
|
/** Whether prereleases are allowed when a request does not say. Defaults to false. */
|
|
97
97
|
prerelease?: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Versions to hold where they are, keyed by recipe key. A recipe named here
|
|
100
|
+
* resolves to that version whenever it still satisfies every range asked of
|
|
101
|
+
* it, rather than to the newest one that does; a range that no longer allows
|
|
102
|
+
* it wins, and the newest satisfying version is chosen as usual. This is how
|
|
103
|
+
* an update moves only the pins it was asked to move.
|
|
104
|
+
*/
|
|
105
|
+
keep?: Record<string, string>;
|
|
98
106
|
};
|
|
99
107
|
|
|
100
108
|
/** One recipe version the resolver settled on. */
|
|
@@ -608,7 +616,7 @@ function resolveRecipeRef(
|
|
|
608
616
|
(previous?.prerelease ?? false) ||
|
|
609
617
|
(item.prerelease ?? context.prerelease ?? false);
|
|
610
618
|
|
|
611
|
-
const version = pickVersion(key, entry, ranges, prerelease);
|
|
619
|
+
const version = pickVersion(key, entry, ranges, prerelease, context.keep?.[key]);
|
|
612
620
|
const versionEntry = entry.versions[version]!;
|
|
613
621
|
|
|
614
622
|
const requestedBy = [...(previous?.requestedBy ?? [])];
|
|
@@ -645,12 +653,14 @@ function resolveRecipeRef(
|
|
|
645
653
|
* @param entry - The recipe's index entry.
|
|
646
654
|
* @param ranges - Every range that has to hold, with who asked for it.
|
|
647
655
|
* @param prerelease - Whether prereleases may match.
|
|
656
|
+
* @param keep - A version to hold, chosen whenever it is among the candidates.
|
|
648
657
|
*/
|
|
649
658
|
function pickVersion(
|
|
650
659
|
key: string,
|
|
651
660
|
entry: IndexFile["recipes"][string],
|
|
652
661
|
ranges: Array<{ range: string; requestedBy: string }>,
|
|
653
|
-
prerelease: boolean
|
|
662
|
+
prerelease: boolean,
|
|
663
|
+
keep?: string
|
|
654
664
|
): string {
|
|
655
665
|
const published = Object.keys(entry.versions);
|
|
656
666
|
const eligible = prerelease
|
|
@@ -664,6 +674,19 @@ function pickVersion(
|
|
|
664
674
|
);
|
|
665
675
|
}
|
|
666
676
|
|
|
677
|
+
// A held version is kept exactly as long as every range still allows it. It
|
|
678
|
+
// is checked against the published list rather than the eligible one, so a
|
|
679
|
+
// prerelease a subscription once opted into is not moved just for being one.
|
|
680
|
+
if (
|
|
681
|
+
keep !== undefined &&
|
|
682
|
+
Object.hasOwn(entry.versions, keep) &&
|
|
683
|
+
ranges.every(({ range }) =>
|
|
684
|
+
semver.satisfies(keep, range, { includePrerelease: true })
|
|
685
|
+
)
|
|
686
|
+
) {
|
|
687
|
+
return keep;
|
|
688
|
+
}
|
|
689
|
+
|
|
667
690
|
const best = semver.maxSatisfying(candidates, "*", { includePrerelease: prerelease });
|
|
668
691
|
if (best !== null) return best;
|
|
669
692
|
|
package/src/lib/repos/seed.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
type IndexOverlay,
|
|
49
49
|
} from "./providers/index-cache.js";
|
|
50
50
|
import { warning } from "../../utils/formatting.js";
|
|
51
|
+
import { hashDirectorySync } from "./store/hash.js";
|
|
51
52
|
import { identitySegments } from "./identity.js";
|
|
52
53
|
import { ensureIndexCacheDirectory } from "../../utils/sous-directory.js";
|
|
53
54
|
import type { RecipeStoreLike, StoreKey } from "./store/contract.js";
|
|
@@ -204,14 +205,70 @@ export async function seedCoreRecipe(
|
|
|
204
205
|
export type CoreIndexOverlayOptions = {
|
|
205
206
|
/** The packaged version, which is by rule the running sous version. */
|
|
206
207
|
version: string;
|
|
207
|
-
/**
|
|
208
|
-
|
|
208
|
+
/**
|
|
209
|
+
* The content hash of the entry the seed put in the store, or a function
|
|
210
|
+
* that works it out the first time it is needed. The function form is what
|
|
211
|
+
* lets an overlay be installed before anything has been seeded.
|
|
212
|
+
*/
|
|
213
|
+
hash: string | (() => string);
|
|
209
214
|
/** The installed package's root directory. Defaults to the running CLI's own. */
|
|
210
215
|
packageRoot?: string;
|
|
211
216
|
/** Where the one warning this can produce goes. Defaults to the console banner. */
|
|
212
217
|
warn?: (message: string) => void;
|
|
213
218
|
};
|
|
214
219
|
|
|
220
|
+
/**
|
|
221
|
+
* The overlay every index cache a command builds starts with: the packaged core
|
|
222
|
+
* version, hashed from the package itself the first time the official
|
|
223
|
+
* repository's index is read.
|
|
224
|
+
*
|
|
225
|
+
* Seeding installs a precise overlay of its own (carrying the hash of the entry
|
|
226
|
+
* it just wrote), but only the commands that seed get that one. This is what
|
|
227
|
+
* makes the packaged version resolvable everywhere else too: a lockfile rebuild,
|
|
228
|
+
* a browsing command, anything that reads the official index. The hash of the
|
|
229
|
+
* packaged folder is the hash a seeded store entry carries, because the store
|
|
230
|
+
* copies the folder byte for byte.
|
|
231
|
+
*
|
|
232
|
+
* A package whose core recipe cannot be read adds nothing, silently: seeding
|
|
233
|
+
* reports that failure in full, and a read-only command has nothing better to
|
|
234
|
+
* say about it.
|
|
235
|
+
*
|
|
236
|
+
* @param options - The packaged version, and where the package is.
|
|
237
|
+
*/
|
|
238
|
+
export function packagedCoreIndexOverlay(options: {
|
|
239
|
+
/** The packaged version, which is by rule the running sous version. */
|
|
240
|
+
version: string;
|
|
241
|
+
/** The installed package's root directory. Defaults to the running CLI's own. */
|
|
242
|
+
packageRoot?: string;
|
|
243
|
+
/** Where the one warning the overlay can produce goes. */
|
|
244
|
+
warn?: (message: string) => void;
|
|
245
|
+
}): IndexOverlay {
|
|
246
|
+
let hash: string | null | undefined;
|
|
247
|
+
const packagedHash = (): string | null => {
|
|
248
|
+
if (hash === undefined) {
|
|
249
|
+
try {
|
|
250
|
+
hash = hashDirectorySync(packagedCoreRecipeDir(options.packageRoot));
|
|
251
|
+
} catch {
|
|
252
|
+
hash = null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return hash;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const overlay = coreIndexOverlay({
|
|
259
|
+
version: options.version,
|
|
260
|
+
hash: () => packagedHash()!,
|
|
261
|
+
...(options.packageRoot === undefined ? {} : { packageRoot: options.packageRoot }),
|
|
262
|
+
...(options.warn === undefined ? {} : { warn: options.warn }),
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
return (identity, index) => {
|
|
266
|
+
if (identity !== OFFICIAL_REPO_IDENTITY) return index;
|
|
267
|
+
if (packagedHash() === null) return index;
|
|
268
|
+
return overlay(identity, index);
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
215
272
|
/**
|
|
216
273
|
* Builds the overlay that makes the packaged core recipe resolvable whatever
|
|
217
274
|
* the official repository has published so far.
|
|
@@ -236,6 +293,8 @@ export type CoreIndexOverlayOptions = {
|
|
|
236
293
|
*/
|
|
237
294
|
export function coreIndexOverlay(options: CoreIndexOverlayOptions): IndexOverlay {
|
|
238
295
|
let warned = false;
|
|
296
|
+
const packagedHash = (): string =>
|
|
297
|
+
typeof options.hash === "function" ? options.hash() : options.hash;
|
|
239
298
|
|
|
240
299
|
return (identity: string, index: IndexFile): IndexFile => {
|
|
241
300
|
if (identity !== OFFICIAL_REPO_IDENTITY) return index;
|
|
@@ -243,14 +302,14 @@ export function coreIndexOverlay(options: CoreIndexOverlayOptions): IndexOverlay
|
|
|
243
302
|
const published = index.recipes[CORE_RECIPE_KEY]?.versions[options.version];
|
|
244
303
|
|
|
245
304
|
if (published !== undefined) {
|
|
246
|
-
if (published.hash !==
|
|
305
|
+
if (published.hash !== packagedHash() && !warned) {
|
|
247
306
|
warned = true;
|
|
248
307
|
(options.warn ?? warning)(
|
|
249
308
|
`The repository '${OFFICIAL_REPO_NAME}' publishes version ${options.version} of ` +
|
|
250
309
|
`'${CORE_RECIPE_KEY}' with different contents from the copy inside this ` +
|
|
251
310
|
`installation of sous, so sous is using the published one.\n` +
|
|
252
311
|
` Published: ${published.hash}\n` +
|
|
253
|
-
` Packaged: ${
|
|
312
|
+
` Packaged: ${packagedHash()}\n` +
|
|
254
313
|
` Reinstalling sous will bring the two back into line.`
|
|
255
314
|
);
|
|
256
315
|
}
|
|
@@ -276,7 +335,7 @@ export function coreIndexOverlay(options: CoreIndexOverlayOptions): IndexOverlay
|
|
|
276
335
|
versions: {
|
|
277
336
|
...recipe?.versions,
|
|
278
337
|
[options.version]: {
|
|
279
|
-
hash:
|
|
338
|
+
hash: packagedHash(),
|
|
280
339
|
tag: `${CORE_RECIPE_KEY}@${options.version}`,
|
|
281
340
|
prerelease: semver.prerelease(options.version) !== null,
|
|
282
341
|
seeded: true,
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
* recipe folder containing a link.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
import { createHash } from "node:crypto";
|
|
32
|
+
import { createHash, type Hash } from "node:crypto";
|
|
33
|
+
import fsSync from "node:fs";
|
|
33
34
|
import fs from "node:fs/promises";
|
|
34
35
|
import path from "node:path";
|
|
35
36
|
import { STORE_ENTRY_FILENAME } from "../formats/common.js";
|
|
@@ -89,18 +90,77 @@ export async function hashDirectory(dir: string): Promise<string> {
|
|
|
89
90
|
const hash = createHash("sha256");
|
|
90
91
|
|
|
91
92
|
for (const relative of files) {
|
|
92
|
-
|
|
93
|
-
hash.update(Buffer.from(relative, "utf8"));
|
|
94
|
-
hash.update(FIELD_SEPARATOR);
|
|
95
|
-
hash.update(Buffer.from(String(bytes.byteLength), "utf8"));
|
|
96
|
-
hash.update(FIELD_SEPARATOR);
|
|
97
|
-
hash.update(bytes);
|
|
98
|
-
hash.update(FIELD_SEPARATOR);
|
|
93
|
+
updateWithFile(hash, relative, await fs.readFile(path.join(root, ...relative.split("/"))));
|
|
99
94
|
}
|
|
100
95
|
|
|
101
96
|
return `sha256-${hash.digest("hex")}`;
|
|
102
97
|
}
|
|
103
98
|
|
|
99
|
+
/**
|
|
100
|
+
* The same hash as `hashDirectory`, computed synchronously. It exists for the
|
|
101
|
+
* one place that needs a hash inside a synchronous read: folding the packaged
|
|
102
|
+
* core recipe into a cached index as the index is read.
|
|
103
|
+
*
|
|
104
|
+
* hashDirectorySync(dir) === (await hashDirectory(dir))
|
|
105
|
+
* // -> true, for any directory
|
|
106
|
+
*
|
|
107
|
+
* @param dir - Absolute path to the directory to hash.
|
|
108
|
+
*/
|
|
109
|
+
export function hashDirectorySync(dir: string): string {
|
|
110
|
+
const root = path.resolve(dir);
|
|
111
|
+
const files = collectFilesSync(root).sort(bytewiseCompare);
|
|
112
|
+
const hash = createHash("sha256");
|
|
113
|
+
|
|
114
|
+
for (const relative of files) {
|
|
115
|
+
updateWithFile(hash, relative, fsSync.readFileSync(path.join(root, ...relative.split("/"))));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return `sha256-${hash.digest("hex")}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Collects every hashable file under `dir`, synchronously. The rules are exactly
|
|
123
|
+
* those of `collectFiles`; the two differ only in how they wait.
|
|
124
|
+
*
|
|
125
|
+
* @param dir - The directory to walk.
|
|
126
|
+
* @param prefix - The relative path of `dir` within the tree being hashed.
|
|
127
|
+
*/
|
|
128
|
+
function collectFilesSync(dir: string, prefix = ""): string[] {
|
|
129
|
+
const entries = fsSync.readdirSync(dir, { withFileTypes: true });
|
|
130
|
+
const found: string[] = [];
|
|
131
|
+
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
if (entry.name === GIT_DIR_NAME) continue;
|
|
134
|
+
if (entry.name === STORE_ENTRY_FILENAME) continue;
|
|
135
|
+
if (entry.isSymbolicLink()) continue;
|
|
136
|
+
|
|
137
|
+
const relative = prefix.length > 0 ? `${prefix}/${entry.name}` : entry.name;
|
|
138
|
+
const absolute = path.join(dir, entry.name);
|
|
139
|
+
|
|
140
|
+
if (entry.isDirectory()) found.push(...collectFilesSync(absolute, relative));
|
|
141
|
+
else if (entry.isFile()) found.push(relative);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return found;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Feeds one file's canonical record into a hash: its path, its byte length and
|
|
149
|
+
* its bytes, each followed by the separator.
|
|
150
|
+
*
|
|
151
|
+
* @param hash - The hash being built.
|
|
152
|
+
* @param relative - The file's path relative to the tree root, posix separators.
|
|
153
|
+
* @param bytes - The file's contents.
|
|
154
|
+
*/
|
|
155
|
+
function updateWithFile(hash: Hash, relative: string, bytes: Buffer): void {
|
|
156
|
+
hash.update(Buffer.from(relative, "utf8"));
|
|
157
|
+
hash.update(FIELD_SEPARATOR);
|
|
158
|
+
hash.update(Buffer.from(String(bytes.byteLength), "utf8"));
|
|
159
|
+
hash.update(FIELD_SEPARATOR);
|
|
160
|
+
hash.update(bytes);
|
|
161
|
+
hash.update(FIELD_SEPARATOR);
|
|
162
|
+
}
|
|
163
|
+
|
|
104
164
|
/**
|
|
105
165
|
* Compares two content hashes. Both are canonical lowercase strings, so this is
|
|
106
166
|
* an exact comparison; it exists so callers read as intent rather than as
|