@sous-io/sous 0.2.29 → 0.2.31
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/README.md +12 -0
- package/docs/markdown/README.md +52 -0
- package/docs/markdown/_sidebar.md +2 -0
- package/docs/markdown/commands.md +31 -6
- package/docs/markdown/repositories-authoring.md +7 -3
- package/docs/markdown/repositories-consuming.md +26 -12
- package/docs/markdown/repositories-file-formats.md +35 -5
- package/docs/markdown/repositories-variables.md +4 -1
- package/package.json +2 -1
- package/recipes/core/sous-skills/skills/about-sous-repos/SKILL.tpl.md +7 -4
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/src/base-command.ts +75 -2
- package/src/commands/lock/rebuild.ts +5 -4
- package/src/commands/recipe/show.ts +163 -64
- package/src/commands/update.ts +178 -0
- package/src/lib/project-install.d.mts +4 -0
- package/src/lib/project-install.mjs +46 -8
- package/src/lib/repos/catalog.ts +43 -55
- package/src/lib/repos/declarations.ts +177 -0
- package/src/lib/repos/formats/common.ts +14 -0
- package/src/lib/repos/formats/index-file.ts +40 -1
- package/src/lib/repos/formats/lockfile.ts +5 -3
- package/src/lib/repos/formats/recipe-manifest.ts +6 -204
- package/src/lib/repos/formats/variable-definition.ts +268 -0
- package/src/lib/repos/providers/http.ts +37 -16
- package/src/lib/repos/release/index-builder.ts +168 -32
- package/src/lib/repos/resolver.ts +53 -12
- package/src/lib/repos/subscription-service.ts +93 -11
- package/src/lib/repos/update-plan.ts +3 -2
- package/src/lib/self-update/index.ts +14 -0
- package/src/lib/self-update/installs.ts +412 -0
- package/src/lib/self-update/managers.ts +126 -0
- package/src/lib/self-update/plan.ts +315 -0
- package/src/lib/self-update/registry.ts +99 -0
- package/src/lib/self-update/run.ts +506 -0
- package/src/lib/self-update/versions.ts +243 -0
- package/src/lib/vars/question-plan.ts +44 -21
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `sous update` does, from the first registry request to the summary.
|
|
3
|
+
*
|
|
4
|
+
* Every plan is worked out before anything runs: the registry is read, the
|
|
5
|
+
* installs are found and `buildUpdatePlans` decides each one's version. Then
|
|
6
|
+
* the plans are taken one at a time, in the order the planner returns them
|
|
7
|
+
* (the global installs, then the project's): each prints its facts, asks its
|
|
8
|
+
* own question (sous-io/sous#135 rules one confirmation per install), and runs
|
|
9
|
+
* when the answer is yes. Asking the second question after the first install
|
|
10
|
+
* has run means its answer can take the first result into account.
|
|
11
|
+
*
|
|
12
|
+
* Nothing here decides whether a question may be asked; the caller says so,
|
|
13
|
+
* from `src/lib/interactive.ts`. The registry fetch, the command runner that
|
|
14
|
+
* asks the package managers where their global installs are, the executor
|
|
15
|
+
* that runs an install and the prompt are all injectable, so a test drives the
|
|
16
|
+
* whole flow with no network and no package manager.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { color } from "@oclif/color";
|
|
22
|
+
import { binEntryOf, readPackageJson, realpathOr } from "../project-install.mjs";
|
|
23
|
+
import { nonInteractiveError } from "../interactive.js";
|
|
24
|
+
import type { CommandRunner } from "../repos/providers/git.js";
|
|
25
|
+
import type { FetchLike } from "../repos/providers/http.js";
|
|
26
|
+
import { confirmPrompt, type ConfirmPromptResult } from "../../utils/confirm-prompt.js";
|
|
27
|
+
import {
|
|
28
|
+
blankLine,
|
|
29
|
+
blankLines,
|
|
30
|
+
displayError,
|
|
31
|
+
dryRunNotice,
|
|
32
|
+
heading,
|
|
33
|
+
indent,
|
|
34
|
+
keysHelpTip,
|
|
35
|
+
log,
|
|
36
|
+
note,
|
|
37
|
+
section,
|
|
38
|
+
showVariables,
|
|
39
|
+
subheading,
|
|
40
|
+
type VariableEntry,
|
|
41
|
+
} from "../../utils/formatting.js";
|
|
42
|
+
import { discoverInstalls } from "./installs.js";
|
|
43
|
+
import { buildUpdatePlans, type PlannedCommand, type UpdateNotice, type UpdatePlan } from "./plan.js";
|
|
44
|
+
import { fetchPublishedVersions, packageMetadataUrl, registryBaseUrl } from "./registry.js";
|
|
45
|
+
import type { VersionRequest } from "./versions.js";
|
|
46
|
+
import type { UpdateScope } from "./plan.js";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Runs one command with the terminal handed straight to it, so a package
|
|
50
|
+
* manager's progress and its errors reach the person as it prints them, and
|
|
51
|
+
* resolves with its exit code. Tests replace it with one that records calls.
|
|
52
|
+
*/
|
|
53
|
+
export type InstallExecutor = (
|
|
54
|
+
command: PlannedCommand,
|
|
55
|
+
options?: { env?: NodeJS.ProcessEnv }
|
|
56
|
+
) => Promise<number>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The real executor. On Windows a bare command name is started through the
|
|
60
|
+
* shell, so npm's `.cmd` shims resolve; an absolute path (the Node binary a
|
|
61
|
+
* post-update build runs under) is started directly. A command that cannot be
|
|
62
|
+
* started at all writes the reason to stderr and resolves with 127.
|
|
63
|
+
*/
|
|
64
|
+
export const spawnInstall: InstallExecutor = (command, options = {}) =>
|
|
65
|
+
new Promise<number>((resolve) => {
|
|
66
|
+
const child = spawn(command.command, command.args, {
|
|
67
|
+
cwd: command.cwd,
|
|
68
|
+
stdio: "inherit",
|
|
69
|
+
env: options.env ?? process.env,
|
|
70
|
+
shell: process.platform === "win32" && !path.isAbsolute(command.command),
|
|
71
|
+
});
|
|
72
|
+
child.on("error", (error: Error) => {
|
|
73
|
+
process.stderr.write(`${error.message}\n`);
|
|
74
|
+
resolve(127);
|
|
75
|
+
});
|
|
76
|
+
child.on("close", (code) => resolve(code ?? 1));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/** Asks one yes-or-no question; the shared prompt, replaceable in a test. */
|
|
80
|
+
export type UpdateConfirm = (config: {
|
|
81
|
+
message: string;
|
|
82
|
+
default: boolean;
|
|
83
|
+
hint: string;
|
|
84
|
+
}) => Promise<ConfirmPromptResult>;
|
|
85
|
+
|
|
86
|
+
/** What an update run needs. */
|
|
87
|
+
export type SelfUpdateOptions = {
|
|
88
|
+
/** Where the project lookup starts. */
|
|
89
|
+
cwd: string;
|
|
90
|
+
/** The package root of the running copy. */
|
|
91
|
+
ownRoot: string;
|
|
92
|
+
request: VersionRequest;
|
|
93
|
+
/** Whether prereleases count; undefined means the planner's default, per install. */
|
|
94
|
+
prerelease?: boolean;
|
|
95
|
+
/** `--global` or `--project`; undefined covers both. */
|
|
96
|
+
only?: UpdateScope;
|
|
97
|
+
dryRun: boolean;
|
|
98
|
+
/** True when `--yes` answered every question. */
|
|
99
|
+
yes: boolean;
|
|
100
|
+
/** Whether a question may be asked (`src/lib/interactive.ts`). */
|
|
101
|
+
interactive: boolean;
|
|
102
|
+
/** False under `--no-build`. */
|
|
103
|
+
build: boolean;
|
|
104
|
+
/**
|
|
105
|
+
* The directory a post-update build runs in, for a project root, or
|
|
106
|
+
* undefined when that project has no sous config.
|
|
107
|
+
*/
|
|
108
|
+
buildDirFor: (projectRoot: string) => string | undefined;
|
|
109
|
+
/** Where the registry override is read from, and the environment installs run with. */
|
|
110
|
+
env?: NodeJS.ProcessEnv;
|
|
111
|
+
fetchImpl?: FetchLike;
|
|
112
|
+
/** How the package managers are asked for their global roots. */
|
|
113
|
+
run?: CommandRunner;
|
|
114
|
+
execute?: InstallExecutor;
|
|
115
|
+
confirm?: UpdateConfirm;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* What became of one plan.
|
|
120
|
+
*
|
|
121
|
+
* - `updated`: the install ran and exited 0.
|
|
122
|
+
* - `failed`: the install ran and exited non-zero (`code`).
|
|
123
|
+
* - `declined`: the person answered no.
|
|
124
|
+
* - `current`: nothing newer to install; nothing was asked.
|
|
125
|
+
* - `planned`: a dry run, which runs nothing.
|
|
126
|
+
*/
|
|
127
|
+
export type PlanOutcome = {
|
|
128
|
+
plan: UpdatePlan;
|
|
129
|
+
result: "updated" | "failed" | "declined" | "current" | "planned";
|
|
130
|
+
code?: number;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* What became of the post-update build.
|
|
135
|
+
*
|
|
136
|
+
* - `built` / `failed`: it ran, and exited 0 or not (`code`).
|
|
137
|
+
* - `skipped`: `--no-build`.
|
|
138
|
+
* - `no-config`: the project has no sous config to build.
|
|
139
|
+
* - `no-bin`: the newly installed copy names no bin sous can run.
|
|
140
|
+
*/
|
|
141
|
+
export type BuildOutcome = {
|
|
142
|
+
result: "built" | "failed" | "skipped" | "no-config" | "no-bin";
|
|
143
|
+
directory?: string;
|
|
144
|
+
code?: number;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** Everything a run did. */
|
|
148
|
+
export type SelfUpdateOutcome = {
|
|
149
|
+
outcomes: PlanOutcome[];
|
|
150
|
+
notices: UpdateNotice[];
|
|
151
|
+
/** Set only when a project install was updated. */
|
|
152
|
+
build?: BuildOutcome;
|
|
153
|
+
/** True when an install or the build failed; the command then exits 1. */
|
|
154
|
+
failed: boolean;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** The key legend under every question, in the style every sous prompt uses. */
|
|
158
|
+
const QUESTION_HINT = keysHelpTip([
|
|
159
|
+
["y/n", "answer"],
|
|
160
|
+
["⏎", "accept default"],
|
|
161
|
+
["⇥", "advanced"],
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
/** The flag spellings the non-interactive remedy names. */
|
|
165
|
+
const YES_REMEDY = "pass '--yes' (spelled '-y' or '--force' if you prefer) to update every install listed";
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Works out every plan, then takes them one at a time: print, ask, run.
|
|
169
|
+
*
|
|
170
|
+
* @throws ConfigError when the registry cannot be read or a `--version` spec
|
|
171
|
+
* matches nothing (before anything runs), and NonInteractiveError when a
|
|
172
|
+
* question has to be asked and cannot be.
|
|
173
|
+
*/
|
|
174
|
+
export async function runSelfUpdate(options: SelfUpdateOptions): Promise<SelfUpdateOutcome> {
|
|
175
|
+
const env = options.env ?? process.env;
|
|
176
|
+
const execute = options.execute ?? spawnInstall;
|
|
177
|
+
const confirm: UpdateConfirm = options.confirm ?? ((config) => confirmPrompt(config));
|
|
178
|
+
|
|
179
|
+
const metadata = await fetchPublishedVersions({
|
|
180
|
+
env,
|
|
181
|
+
...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
|
|
182
|
+
});
|
|
183
|
+
const discovery = await discoverInstalls({
|
|
184
|
+
cwd: options.cwd,
|
|
185
|
+
ownRoot: options.ownRoot,
|
|
186
|
+
...(options.run === undefined ? {} : { run: options.run }),
|
|
187
|
+
});
|
|
188
|
+
const { plans, notices } = buildUpdatePlans(discovery, metadata, {
|
|
189
|
+
request: options.request,
|
|
190
|
+
only: options.only,
|
|
191
|
+
...(options.prerelease === undefined ? {} : { prerelease: options.prerelease }),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
if (notices.length > 0) {
|
|
195
|
+
section("Not updated");
|
|
196
|
+
for (const notice of notices) printNotice(notice);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const actionable = plans.filter((plan) => plan.direction !== "current");
|
|
200
|
+
const globalCount = plans.filter((plan) => plan.scope === "global").length;
|
|
201
|
+
const outcome: SelfUpdateOutcome = { outcomes: [], notices, failed: false };
|
|
202
|
+
|
|
203
|
+
if (plans.length === 0) {
|
|
204
|
+
blankLine();
|
|
205
|
+
note("There is no install of sous here that sous can update.", { indent: 2 });
|
|
206
|
+
return outcome;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
heading(options.dryRun ? "Update plan" : "Updating");
|
|
210
|
+
|
|
211
|
+
for (const plan of plans) {
|
|
212
|
+
const title = planTitle(plan, globalCount);
|
|
213
|
+
const buildDir =
|
|
214
|
+
plan.scope === "project" && plan.projectRoot !== undefined
|
|
215
|
+
? options.buildDirFor(plan.projectRoot)
|
|
216
|
+
: undefined;
|
|
217
|
+
subheading(title);
|
|
218
|
+
showVariables(planFacts(plan, { dryRun: options.dryRun, build: options.build, buildDir }));
|
|
219
|
+
|
|
220
|
+
if (plan.direction === "current") {
|
|
221
|
+
outcome.outcomes.push({ plan, result: "current" });
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (options.dryRun) {
|
|
225
|
+
outcome.outcomes.push({ plan, result: "planned" });
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const question = questionFor(plan, title);
|
|
230
|
+
let proceed = options.yes;
|
|
231
|
+
if (!proceed) {
|
|
232
|
+
if (!options.interactive) {
|
|
233
|
+
throw nonInteractiveError({
|
|
234
|
+
prompt: `"${question}"`,
|
|
235
|
+
remedy: `${YES_REMEDY}.`,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
const index = actionable.indexOf(plan) + 1;
|
|
239
|
+
proceed = await ask(confirm, question, plan, `Question ${index} of ${actionable.length}: ${title}`);
|
|
240
|
+
}
|
|
241
|
+
if (!proceed) {
|
|
242
|
+
outcome.outcomes.push({ plan, result: "declined" });
|
|
243
|
+
blankLine();
|
|
244
|
+
log(indent(`The ${title.toLowerCase()} was left at ${plan.installed}.`));
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const code = await runCommandAnnounced(execute, plan.command, env);
|
|
249
|
+
if (code !== 0) {
|
|
250
|
+
outcome.failed = true;
|
|
251
|
+
outcome.outcomes.push({ plan, result: "failed", code });
|
|
252
|
+
displayError(
|
|
253
|
+
`${plan.manager} exited with code ${code}, so the ${title.toLowerCase()} is still at ` +
|
|
254
|
+
`${plan.installed}. Its own output is above.`,
|
|
255
|
+
(line) => console.error(line)
|
|
256
|
+
);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
outcome.outcomes.push({ plan, result: "updated" });
|
|
260
|
+
blankLine();
|
|
261
|
+
log(indent(`The ${title.toLowerCase()} is now at ${plan.target}.`));
|
|
262
|
+
|
|
263
|
+
if (plan.scope === "project") {
|
|
264
|
+
outcome.build = await buildProject(plan, buildDir, options.build, execute, env);
|
|
265
|
+
if (outcome.build.result === "failed" || outcome.build.result === "no-bin") {
|
|
266
|
+
outcome.failed = true;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (options.dryRun) {
|
|
272
|
+
blankLine();
|
|
273
|
+
dryRunNotice("Nothing was installed. Run the same command without '--dry-run' to update.");
|
|
274
|
+
return outcome;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
printSummary(outcome, globalCount);
|
|
278
|
+
return outcome;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** "Global install", "Global install (pnpm)" when there are several, or "Project install". */
|
|
282
|
+
function planTitle(plan: UpdatePlan, globalCount: number): string {
|
|
283
|
+
if (plan.scope === "project") return "Project install";
|
|
284
|
+
return globalCount > 1 ? `Global install (${plan.manager})` : "Global install";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The question one plan asks. */
|
|
288
|
+
function questionFor(plan: UpdatePlan, title: string): string {
|
|
289
|
+
const verb = plan.direction === "downgrade" ? "Downgrade" : "Update";
|
|
290
|
+
return `${verb} the ${title.toLowerCase()} from ${plan.installed} to ${plan.target}?`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** A command as a person would type it. */
|
|
294
|
+
export function formatCommand(command: PlannedCommand): string {
|
|
295
|
+
return [command.command, ...command.args].join(" ");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The facts one plan prints before its question. The command itself is shown
|
|
300
|
+
* in a dry run, and behind Tab otherwise, where it is printed again just
|
|
301
|
+
* before it runs.
|
|
302
|
+
*/
|
|
303
|
+
function planFacts(
|
|
304
|
+
plan: UpdatePlan,
|
|
305
|
+
context: { dryRun: boolean; build: boolean; buildDir: string | undefined }
|
|
306
|
+
): VariableEntry[] {
|
|
307
|
+
const facts: VariableEntry[] = [];
|
|
308
|
+
if (plan.projectRoot !== undefined) facts.push({ label: "Project", value: plan.projectRoot });
|
|
309
|
+
facts.push(
|
|
310
|
+
plan.pnpRoot === undefined
|
|
311
|
+
? { label: "Location", value: plan.location }
|
|
312
|
+
: { label: "Location", value: plan.location, detail: "Yarn Plug'n'Play, which keeps no node_modules copy" }
|
|
313
|
+
);
|
|
314
|
+
facts.push(
|
|
315
|
+
plan.managerAssumed
|
|
316
|
+
? {
|
|
317
|
+
label: "Package manager",
|
|
318
|
+
value: plan.manager,
|
|
319
|
+
detail: "assumed, because the project's files name no package manager",
|
|
320
|
+
}
|
|
321
|
+
: { label: "Package manager", value: plan.manager }
|
|
322
|
+
);
|
|
323
|
+
if (plan.declaredIn !== undefined) {
|
|
324
|
+
facts.push({
|
|
325
|
+
label: "Declared in",
|
|
326
|
+
value: plan.declaredIn.section,
|
|
327
|
+
detail: `as ${plan.declaredIn.range}`,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
facts.push({ label: "Installed", value: plan.installed });
|
|
331
|
+
|
|
332
|
+
if (plan.direction === "current") {
|
|
333
|
+
facts.push({
|
|
334
|
+
label: "Will install",
|
|
335
|
+
value: "nothing",
|
|
336
|
+
detail: `${plan.installed} is already the version this update chooses`,
|
|
337
|
+
});
|
|
338
|
+
return facts;
|
|
339
|
+
}
|
|
340
|
+
facts.push(
|
|
341
|
+
plan.direction === "downgrade"
|
|
342
|
+
? { label: "Will install", value: plan.target, detail: `a downgrade from ${plan.installed}` }
|
|
343
|
+
: { label: "Will install", value: plan.target }
|
|
344
|
+
);
|
|
345
|
+
if (context.dryRun) {
|
|
346
|
+
facts.push(commandFact("Command", plan.command));
|
|
347
|
+
}
|
|
348
|
+
if (plan.scope === "project") facts.push(buildFact(context.build, context.buildDir));
|
|
349
|
+
return facts;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The row naming a command and, when it has one, where it runs. */
|
|
353
|
+
function commandFact(label: string, command: PlannedCommand): VariableEntry {
|
|
354
|
+
return command.cwd === undefined
|
|
355
|
+
? { label, value: formatCommand(command) }
|
|
356
|
+
: { label, value: formatCommand(command), detail: `in ${command.cwd}` };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** The row saying whether a project update is followed by a build. */
|
|
360
|
+
function buildFact(build: boolean, buildDir: string | undefined): VariableEntry {
|
|
361
|
+
if (!build) return { label: "Then builds", value: "no", detail: "--no-build was passed" };
|
|
362
|
+
if (buildDir === undefined) {
|
|
363
|
+
return { label: "Then builds", value: "no", detail: "the project has no sous config" };
|
|
364
|
+
}
|
|
365
|
+
return { label: "Then builds", value: "yes", detail: `with the new copy, in ${buildDir}` };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Asks one plan's question. Tab shows the command the answer runs, and the
|
|
370
|
+
* question is asked again.
|
|
371
|
+
*/
|
|
372
|
+
async function ask(
|
|
373
|
+
confirm: UpdateConfirm,
|
|
374
|
+
question: string,
|
|
375
|
+
plan: UpdatePlan,
|
|
376
|
+
header: string
|
|
377
|
+
): Promise<boolean> {
|
|
378
|
+
for (;;) {
|
|
379
|
+
blankLines(2);
|
|
380
|
+
log(color.bold(header));
|
|
381
|
+
const answered = await confirm({ message: question, default: true, hint: QUESTION_HINT });
|
|
382
|
+
if (answered.kind === "value") return answered.value;
|
|
383
|
+
blankLine();
|
|
384
|
+
showVariables([
|
|
385
|
+
commandFact("Command", plan.command),
|
|
386
|
+
{ label: "Real path", value: realpathOr(plan.location) },
|
|
387
|
+
]);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Prints a command, then runs it with the terminal handed over, and returns its exit code. */
|
|
392
|
+
async function runCommandAnnounced(
|
|
393
|
+
execute: InstallExecutor,
|
|
394
|
+
command: PlannedCommand,
|
|
395
|
+
env: NodeJS.ProcessEnv
|
|
396
|
+
): Promise<number> {
|
|
397
|
+
blankLine();
|
|
398
|
+
showVariables([commandFact("Running", command)]);
|
|
399
|
+
blankLine();
|
|
400
|
+
return execute(command, { env });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Runs the newly installed project copy's own build, so the lockfile's
|
|
405
|
+
* `core/sous-skills` pin moves to the version just installed, with the
|
|
406
|
+
* hand-off switched off so no other copy takes the build over. The copy's bin
|
|
407
|
+
* is read from its package.json again, now that the install has replaced it,
|
|
408
|
+
* and runs under this Node. Under Yarn Plug'n'Play there is no bin on disk to
|
|
409
|
+
* run that way, so Yarn runs the dependency's bin itself
|
|
410
|
+
* (`yarn run --binaries-only sous build`), with its resolution hooks loaded.
|
|
411
|
+
*/
|
|
412
|
+
async function buildProject(
|
|
413
|
+
plan: UpdatePlan,
|
|
414
|
+
buildDir: string | undefined,
|
|
415
|
+
build: boolean,
|
|
416
|
+
execute: InstallExecutor,
|
|
417
|
+
env: NodeJS.ProcessEnv
|
|
418
|
+
): Promise<BuildOutcome> {
|
|
419
|
+
if (!build) return { result: "skipped" };
|
|
420
|
+
if (buildDir === undefined) return { result: "no-config" };
|
|
421
|
+
|
|
422
|
+
section("Building the project");
|
|
423
|
+
let command: PlannedCommand;
|
|
424
|
+
if (plan.pnpRoot !== undefined) {
|
|
425
|
+
command = { command: "yarn", args: ["run", "--binaries-only", "sous", "build"], cwd: buildDir };
|
|
426
|
+
} else {
|
|
427
|
+
const root = realpathOr(plan.location);
|
|
428
|
+
const entry = binEntryOf(readPackageJson(root));
|
|
429
|
+
if (entry === undefined) {
|
|
430
|
+
displayError(
|
|
431
|
+
`The update stands, but the new copy at ${root} names no bin sous can run, so the ` +
|
|
432
|
+
"project was not built. Run 'sous build' in the project.",
|
|
433
|
+
(line) => console.error(line)
|
|
434
|
+
);
|
|
435
|
+
return { result: "no-bin", directory: buildDir };
|
|
436
|
+
}
|
|
437
|
+
command = { command: process.execPath, args: [path.resolve(root, entry), "build"], cwd: buildDir };
|
|
438
|
+
}
|
|
439
|
+
const code = await runCommandAnnounced(execute, command, { ...env, SOUS_NO_DELEGATE: "1" });
|
|
440
|
+
if (code !== 0) {
|
|
441
|
+
displayError(
|
|
442
|
+
`The update stands, but the build that followed it exited with code ${code}. ` +
|
|
443
|
+
"Fix what the build reported above and run 'sous build' again." +
|
|
444
|
+
(plan.pnpRoot === undefined ? "" : PNP_BUILD_HINT),
|
|
445
|
+
(line) => console.error(line)
|
|
446
|
+
);
|
|
447
|
+
return { result: "failed", directory: buildDir, code };
|
|
448
|
+
}
|
|
449
|
+
return { result: "built", directory: buildDir };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* What a failed build under Yarn Plug'n'Play adds: sous cannot run from inside
|
|
454
|
+
* Yarn's zip cache, only from a copy Yarn has unpacked.
|
|
455
|
+
*/
|
|
456
|
+
const PNP_BUILD_HINT =
|
|
457
|
+
" Under Yarn Plug'n'Play, sous runs only from an unpacked copy: if the error above is " +
|
|
458
|
+
"'EBADF: bad file descriptor', add \"dependenciesMeta\": { \"@sous-io/sous\": { \"unplugged\": true } } " +
|
|
459
|
+
"to package.json, then run 'yarn install'.";
|
|
460
|
+
|
|
461
|
+
/** Prints one notice: its sentence, then where the copy is and the command to run by hand. */
|
|
462
|
+
function printNotice(notice: UpdateNotice): void {
|
|
463
|
+
note(notice.message, { indent: 2 });
|
|
464
|
+
const facts: VariableEntry[] = [];
|
|
465
|
+
if (notice.location !== undefined) facts.push({ label: "Location", value: notice.location });
|
|
466
|
+
if (notice.manualCommand !== undefined) facts.push(commandFact("Run by hand", notice.manualCommand));
|
|
467
|
+
showVariables(facts);
|
|
468
|
+
blankLine();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** The summary: one line per plan, and one for the build when it applied. */
|
|
472
|
+
function printSummary(outcome: SelfUpdateOutcome, globalCount: number): void {
|
|
473
|
+
section("Summary");
|
|
474
|
+
const rows: VariableEntry[] = outcome.outcomes.map(({ plan, result, code }) => {
|
|
475
|
+
const label = planTitle(plan, globalCount);
|
|
476
|
+
switch (result) {
|
|
477
|
+
case "updated":
|
|
478
|
+
return { label, value: `updated to ${plan.target}`, detail: `from ${plan.installed}` };
|
|
479
|
+
case "failed":
|
|
480
|
+
return { label, value: `failed`, detail: `${plan.manager} exited with code ${code}` };
|
|
481
|
+
case "declined":
|
|
482
|
+
return { label, value: `skipped`, detail: `left at ${plan.installed}` };
|
|
483
|
+
default:
|
|
484
|
+
return { label, value: `up to date at ${plan.installed}` };
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
const build = outcome.build;
|
|
488
|
+
if (build !== undefined) {
|
|
489
|
+
const label = "Project build";
|
|
490
|
+
if (build.result === "built") rows.push({ label, value: "succeeded", detail: `in ${build.directory}` });
|
|
491
|
+
if (build.result === "failed") {
|
|
492
|
+
rows.push({ label, value: "failed", detail: `exited with code ${build.code}` });
|
|
493
|
+
}
|
|
494
|
+
if (build.result === "no-bin") rows.push({ label, value: "failed", detail: "the new copy names no bin" });
|
|
495
|
+
if (build.result === "skipped") rows.push({ label, value: "skipped", detail: "--no-build was passed" });
|
|
496
|
+
if (build.result === "no-config") {
|
|
497
|
+
rows.push({ label, value: "not run", detail: "the project has no sous config" });
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
showVariables(rows);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** The registry URL an update reads, for the command's opening block. */
|
|
504
|
+
export function registryUrl(env: NodeJS.ProcessEnv = process.env): string {
|
|
505
|
+
return packageMetadataUrl(registryBaseUrl(env));
|
|
506
|
+
}
|