@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,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an update is about to do, written out before it asks.
|
|
3
|
+
*
|
|
4
|
+
* `sous subscription update` (and `sous repo unlink --update`, which runs the
|
|
5
|
+
* same code) works out the whole change first: which pins move, which
|
|
6
|
+
* dependencies arrive or leave, which repositories a newer version needs that
|
|
7
|
+
* the project does not trust yet, and which questions the new versions ask.
|
|
8
|
+
* This module turns those facts into the lines printed above the one question.
|
|
9
|
+
* It is pure: it formats what it is handed and decides nothing.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { LockDiff } from "./lock-service.js";
|
|
13
|
+
import type { MissingRepo } from "./resolver.js";
|
|
14
|
+
import { formatQuestionPlan, type PlannedVariable } from "../vars/index.js";
|
|
15
|
+
import { BULLET, palette, wrapColumns, wrapText } from "../../utils/formatting.js";
|
|
16
|
+
|
|
17
|
+
/** What an update narrowed itself to. */
|
|
18
|
+
export type UpdateScope =
|
|
19
|
+
| { kind: "all" }
|
|
20
|
+
| { kind: "repository"; repo: string }
|
|
21
|
+
| { kind: "namespace"; repo: string; namespace: string }
|
|
22
|
+
| { kind: "recipe"; repo: string; key: string };
|
|
23
|
+
|
|
24
|
+
/** Everything the plan says, gathered by the subscription service. */
|
|
25
|
+
export type UpdatePlanFacts = {
|
|
26
|
+
/** What the update covers. */
|
|
27
|
+
scope: UpdateScope;
|
|
28
|
+
/** What would change in the lockfile. */
|
|
29
|
+
diff: LockDiff;
|
|
30
|
+
/** Repositories a newer version needs that the project does not trust yet. */
|
|
31
|
+
missingRepos: MissingRepo[];
|
|
32
|
+
/** The questions the new versions ask that nothing answers yet. */
|
|
33
|
+
questions: PlannedVariable[];
|
|
34
|
+
/** Recipes whose files are not on this machine, so their questions are unknown. */
|
|
35
|
+
unreadable: string[];
|
|
36
|
+
/** Repositories whose index could not be fetched, so their pins stay where they are. */
|
|
37
|
+
unreachable: Array<{ repo: string; reason: string }>;
|
|
38
|
+
/** Linked repositories whose pins move, which builds keep bypassing. */
|
|
39
|
+
linked: string[];
|
|
40
|
+
/** Subscriptions sous provides itself, held at the version this sous ships. */
|
|
41
|
+
builtIn: Array<{ key: string; version?: string }>;
|
|
42
|
+
/** Switched-off subscriptions the update left alone. */
|
|
43
|
+
switchedOff: string[];
|
|
44
|
+
/** Subscriptions that could not be resolved, each with the reason. */
|
|
45
|
+
failed: Array<{ key: string; reason: string }>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The scope in words, for a sentence that has to say what was updated.
|
|
50
|
+
*
|
|
51
|
+
* describeUpdateScope({ kind: "repository", repo: "sous-recipes" })
|
|
52
|
+
* // -> "the recipes this project takes from the repository 'sous-recipes'"
|
|
53
|
+
*
|
|
54
|
+
* @param scope - What the update covers.
|
|
55
|
+
*/
|
|
56
|
+
export function describeUpdateScope(scope: UpdateScope): string {
|
|
57
|
+
switch (scope.kind) {
|
|
58
|
+
case "all":
|
|
59
|
+
return "every subscription in this project";
|
|
60
|
+
case "repository":
|
|
61
|
+
return `the recipes this project takes from the repository '${scope.repo}'`;
|
|
62
|
+
case "namespace":
|
|
63
|
+
return `the recipes in the namespace '${scope.repo}:${scope.namespace}'`;
|
|
64
|
+
case "recipe":
|
|
65
|
+
return `the recipe '${scope.repo}:${scope.key}'`;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* True when the plan has nothing to do: no pin moves, nothing arrives or
|
|
71
|
+
* leaves, and no new repository is needed.
|
|
72
|
+
*
|
|
73
|
+
* @param facts - What the service worked out.
|
|
74
|
+
*/
|
|
75
|
+
export function isEmptyUpdate(facts: Pick<UpdatePlanFacts, "diff" | "missingRepos">): boolean {
|
|
76
|
+
return facts.diff.unchanged && facts.missingRepos.length === 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The plan, as lines ready to be indented and printed. Pin changes come first,
|
|
81
|
+
* then the repositories that need trusting, then the questions, then the notes
|
|
82
|
+
* about what the update deliberately left alone.
|
|
83
|
+
*
|
|
84
|
+
* @param facts - What the service worked out.
|
|
85
|
+
* @param options - The width to wrap to, for tests.
|
|
86
|
+
*/
|
|
87
|
+
export function formatUpdatePlan(
|
|
88
|
+
facts: UpdatePlanFacts,
|
|
89
|
+
options: { width?: number } = {}
|
|
90
|
+
): string[] {
|
|
91
|
+
const width = (options.width ?? wrapColumns()) - 4;
|
|
92
|
+
const lines: string[] = [""];
|
|
93
|
+
|
|
94
|
+
/** One sentence, wrapped. */
|
|
95
|
+
const sentence = (text: string, paint = (line: string): string => line): string[] =>
|
|
96
|
+
wrapText(text, width).map(paint);
|
|
97
|
+
|
|
98
|
+
/** One bullet, wrapped so its continuation hangs under the text. */
|
|
99
|
+
const bullet = (text: string): string[] =>
|
|
100
|
+
wrapText(` ${BULLET} ${text}`, width, { hangingIndent: 2 });
|
|
101
|
+
|
|
102
|
+
if (isEmptyUpdate(facts)) {
|
|
103
|
+
lines.push(
|
|
104
|
+
...sentence(
|
|
105
|
+
`Nothing to update: every pin in ${describeUpdateScope(facts.scope)} is ` +
|
|
106
|
+
`already the newest published version its range allows.`
|
|
107
|
+
)
|
|
108
|
+
);
|
|
109
|
+
} else {
|
|
110
|
+
lines.push(...sentence(`Updating ${describeUpdateScope(facts.scope)} changes the lockfile:`));
|
|
111
|
+
lines.push("");
|
|
112
|
+
if (facts.diff.unchanged) {
|
|
113
|
+
lines.push(
|
|
114
|
+
...bullet("No pin moves until the repositories below are trusted and read.")
|
|
115
|
+
);
|
|
116
|
+
} else {
|
|
117
|
+
for (const change of facts.diff.lines) lines.push(...bullet(change));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (facts.missingRepos.length > 0) {
|
|
122
|
+
lines.push("");
|
|
123
|
+
lines.push(
|
|
124
|
+
...sentence(
|
|
125
|
+
`A newer version needs ${
|
|
126
|
+
facts.missingRepos.length === 1 ? "a repository" : "repositories"
|
|
127
|
+
} this project ${palette.highlight("does not trust yet")}. You are asked about ` +
|
|
128
|
+
`each one by name before anything is fetched from it:`,
|
|
129
|
+
palette.warning
|
|
130
|
+
)
|
|
131
|
+
);
|
|
132
|
+
lines.push("");
|
|
133
|
+
for (const missing of facts.missingRepos) {
|
|
134
|
+
const where = missing.url === undefined ? "" : ` at ${missing.url}`;
|
|
135
|
+
const needers = missing.requiredBy.map((entry) => entry.requestedBy).join(", ");
|
|
136
|
+
lines.push(...bullet(`${missing.name}${where}, needed by ${needers}`));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (facts.questions.length > 0 || facts.unreadable.length > 0) {
|
|
141
|
+
lines.push("");
|
|
142
|
+
lines.push(...sentence("Questions the new versions ask that nothing answers yet:"));
|
|
143
|
+
lines.push("");
|
|
144
|
+
for (const line of formatQuestionPlan(facts.questions, {
|
|
145
|
+
unreadable: facts.unreadable,
|
|
146
|
+
width,
|
|
147
|
+
})) {
|
|
148
|
+
lines.push(line === "" ? "" : ` ${line}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const notes: string[] = [];
|
|
153
|
+
if (facts.unreadable.length > 0) {
|
|
154
|
+
notes.push(
|
|
155
|
+
`A dry run downloads no recipe, so the dependencies of the versions not on this ` +
|
|
156
|
+
`machine yet are not shown: ${facts.unreadable.join(", ")}.`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
for (const entry of facts.unreachable) {
|
|
160
|
+
notes.push(
|
|
161
|
+
`The index of '${entry.repo}' could not be fetched, so its pins stay where ` +
|
|
162
|
+
`they are. ${firstLine(entry.reason)}`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
for (const entry of facts.failed) {
|
|
166
|
+
notes.push(
|
|
167
|
+
`The subscription to '${entry.key}' could not be resolved, so its pins stay ` +
|
|
168
|
+
`where they are. ${firstLine(entry.reason)}`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
for (const repo of facts.linked) {
|
|
172
|
+
notes.push(
|
|
173
|
+
`The repository '${repo}' is linked to a working copy, so builds keep ` +
|
|
174
|
+
`reading that checkout until it is unlinked; the pins above take effect then.`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
for (const entry of facts.builtIn) {
|
|
178
|
+
notes.push(
|
|
179
|
+
`The subscription to '${entry.key}' is one sous provides itself, and it stays ` +
|
|
180
|
+
`at ${
|
|
181
|
+
entry.version === undefined ? "the version" : `version ${entry.version}, the version`
|
|
182
|
+
} this installation of sous ships.`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (facts.switchedOff.length > 0) {
|
|
186
|
+
notes.push(
|
|
187
|
+
`Switched off, so left alone: ${facts.switchedOff.join(", ")}.`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (notes.length > 0) {
|
|
192
|
+
lines.push("");
|
|
193
|
+
for (const text of notes) {
|
|
194
|
+
lines.push(...wrapText(`${BULLET} ${text}`, width, { hangingIndent: 2 }).map(palette.note));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
lines.push("");
|
|
199
|
+
return lines;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The first line of a possibly multi-line reason, which is what fits in a note.
|
|
204
|
+
*
|
|
205
|
+
* @param text - The reason.
|
|
206
|
+
*/
|
|
207
|
+
function firstLine(text: string): string {
|
|
208
|
+
return text.split("\n")[0]!.trim();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* True when an update of this scope may move a recipe, judged by the recipe's
|
|
213
|
+
* key and the repository it comes from.
|
|
214
|
+
*
|
|
215
|
+
* recipeInScope({ kind: "namespace", repo: "r", namespace: "workflow" }, "workflow/x", "r")
|
|
216
|
+
* // -> true
|
|
217
|
+
*
|
|
218
|
+
* @param scope - What the update covers.
|
|
219
|
+
* @param key - The recipe key, `namespace/recipe`.
|
|
220
|
+
* @param repo - The short name of the repository it comes from.
|
|
221
|
+
*/
|
|
222
|
+
export function recipeInScope(scope: UpdateScope, key: string, repo: string): boolean {
|
|
223
|
+
switch (scope.kind) {
|
|
224
|
+
case "all":
|
|
225
|
+
return true;
|
|
226
|
+
case "repository":
|
|
227
|
+
return repo === scope.repo;
|
|
228
|
+
case "namespace":
|
|
229
|
+
return repo === scope.repo && key.startsWith(`${scope.namespace}/`);
|
|
230
|
+
case "recipe":
|
|
231
|
+
return repo === scope.repo && key === scope.key;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|