@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.
Files changed (37) hide show
  1. package/README.md +12 -0
  2. package/docs/markdown/README.md +52 -0
  3. package/docs/markdown/_sidebar.md +2 -0
  4. package/docs/markdown/commands.md +31 -6
  5. package/docs/markdown/repositories-authoring.md +7 -3
  6. package/docs/markdown/repositories-consuming.md +26 -12
  7. package/docs/markdown/repositories-file-formats.md +35 -5
  8. package/docs/markdown/repositories-variables.md +4 -1
  9. package/package.json +2 -1
  10. package/recipes/core/sous-skills/skills/about-sous-repos/SKILL.tpl.md +7 -4
  11. package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
  12. package/src/base-command.ts +75 -2
  13. package/src/commands/lock/rebuild.ts +5 -4
  14. package/src/commands/recipe/show.ts +163 -64
  15. package/src/commands/update.ts +178 -0
  16. package/src/lib/project-install.d.mts +4 -0
  17. package/src/lib/project-install.mjs +46 -8
  18. package/src/lib/repos/catalog.ts +43 -55
  19. package/src/lib/repos/declarations.ts +177 -0
  20. package/src/lib/repos/formats/common.ts +14 -0
  21. package/src/lib/repos/formats/index-file.ts +40 -1
  22. package/src/lib/repos/formats/lockfile.ts +5 -3
  23. package/src/lib/repos/formats/recipe-manifest.ts +6 -204
  24. package/src/lib/repos/formats/variable-definition.ts +268 -0
  25. package/src/lib/repos/providers/http.ts +37 -16
  26. package/src/lib/repos/release/index-builder.ts +168 -32
  27. package/src/lib/repos/resolver.ts +53 -12
  28. package/src/lib/repos/subscription-service.ts +93 -11
  29. package/src/lib/repos/update-plan.ts +3 -2
  30. package/src/lib/self-update/index.ts +14 -0
  31. package/src/lib/self-update/installs.ts +412 -0
  32. package/src/lib/self-update/managers.ts +126 -0
  33. package/src/lib/self-update/plan.ts +315 -0
  34. package/src/lib/self-update/registry.ts +99 -0
  35. package/src/lib/self-update/run.ts +506 -0
  36. package/src/lib/self-update/versions.ts +243 -0
  37. 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
+ }