@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
@@ -2,27 +2,41 @@
2
2
  * `sous recipe show <ref>`.
3
3
  *
4
4
  * Shows one recipe in full: where it is published, every version it publishes,
5
- * what the version this project would use depends on, the questions it asks,
6
- * and where its files land in this project. It reads only what sous already has
7
- * on disk: the repository's cached index, the project's lockfile, and the
8
- * recipe's own files when they are in the store or a linked working copy.
9
- * `--latest` reads the repository's index from upstream instead, saving
10
- * nothing, and `--installed` looks the ref up among installed recipes only.
5
+ * what the version this project would use depends on and how each dependency is
6
+ * declared, everything subscribing to it would install, every question that
7
+ * would ask, and where its files land in this project. It reads only what sous
8
+ * already has on disk: the repository's cached index, the project's lockfile,
9
+ * and the recipe's own files when they are in the store or a linked working
10
+ * copy. The index describes each version's dependencies and questions, so a
11
+ * recipe nothing has installed is described as fully as one in the store, its
12
+ * files excepted. `--latest` reads the repository's index from upstream
13
+ * instead, saving nothing, and `--installed` looks the ref up among installed
14
+ * recipes only.
11
15
  */
12
16
 
13
17
  import { Args } from "@oclif/core";
18
+ import semver from "semver";
14
19
  import { BaseCommand } from "../../base-command.js";
15
20
  import { resolveRootScope } from "../../lib/settings.js";
16
- import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js";
21
+ import {
22
+ subscriptionServiceFor,
23
+ type SubscriptionPreview,
24
+ type SubscriptionService,
25
+ } from "../../lib/repos/subscription-service.js";
17
26
  import { loadCatalogContext } from "../../lib/repos/catalog-inputs.js";
18
27
  import {
19
28
  describeInstalled,
20
29
  describeRecipe,
30
+ type CatalogInputs,
21
31
  type RecipeContentListing,
22
32
  type RecipeDependencyListing,
23
- type RecipeVariableListing,
33
+ type RecipeDetail,
24
34
  type RecipeVersionListing,
25
35
  } from "../../lib/repos/catalog.js";
36
+ import { PROJECT_REQUESTER } from "../../lib/repos/resolver.js";
37
+ import { describeDeclaration, describeDependencyKind } from "../../lib/repos/declarations.js";
38
+ import { formatQuestionPlan } from "../../lib/vars/question-plan.js";
39
+ import { describeError } from "../../lib/repos/release/validate.js";
26
40
  import {
27
41
  INDENT,
28
42
  describeIndexSource,
@@ -61,14 +75,19 @@ const DEPENDENCY_COLUMNS: TableColumn[] = [
61
75
  { key: "kind", header: "Kind", overflow: "wrap", priority: "medium", minWidth: 16 },
62
76
  ];
63
77
 
64
- /** The columns the variable listing shows. */
65
- const VARIABLE_COLUMNS: TableColumn[] = [
66
- { key: "name", header: "Variable", overflow: "truncate", minWidth: 10 },
67
- { key: "type", header: "Type", priority: "medium" },
68
- { key: "env", header: "Environment variable", overflow: "truncate", minWidth: 12 },
69
- { key: "required", header: "Required", priority: "medium" },
70
- { key: "secret", header: "Secret", priority: "low" },
71
- { key: "prompt", header: "What it asks", overflow: "wrap", flex: 1, priority: "low", minWidth: 16 },
78
+ /** The columns the listing of everything a subscription installs shows. */
79
+ const INSTALLS_COLUMNS: TableColumn[] = [
80
+ { key: "key", header: "Recipe", kind: "path", overflow: "truncate", minWidth: 12 },
81
+ { key: "version", header: "Version", overflow: "truncate", minWidth: 7 },
82
+ { key: "kind", header: "Kind", overflow: "wrap", minWidth: 16 },
83
+ {
84
+ key: "neededBy",
85
+ header: "Needed by",
86
+ overflow: "wrap",
87
+ flex: 1,
88
+ priority: "medium",
89
+ minWidth: 12,
90
+ },
72
91
  ];
73
92
 
74
93
  /** The columns the content listing shows. */
@@ -86,7 +105,8 @@ const CONTENT_COLUMNS: TableColumn[] = [
86
105
  ];
87
106
 
88
107
  export default class RecipeShow extends BaseCommand {
89
- static description = "Show one recipe: its versions, dependencies, variables and files";
108
+ static description =
109
+ "Show one recipe: its versions, dependencies, what subscribing installs and asks, and its files";
90
110
 
91
111
  /**
92
112
  * The other spelling of the topic. It lives under a hidden topic, so it is
@@ -165,24 +185,76 @@ export default class RecipeShow extends BaseCommand {
165
185
  printBrowsingNotes({ notChecked: notChecked.filter((name) => name === detail.repo) });
166
186
 
167
187
  this.printVersions(detail.versions);
168
- this.printDependencies(detail.dependencies, detail.manifestRead);
188
+ this.printDependencies(
189
+ detail.dependencies,
190
+ detail.manifestRead || detail.dependenciesRecorded
191
+ );
169
192
 
170
- if (!detail.manifestRead) {
193
+ const preview = await this.preview(service, inputs, detail);
194
+ if (typeof preview === "string") {
195
+ blankLine();
196
+ paragraph(preview);
197
+ } else {
198
+ this.printInstalls(preview);
199
+ this.printQuestions(preview);
200
+ }
201
+
202
+ if (detail.manifestRead) {
203
+ this.printContents(detail.contents);
204
+ } else {
205
+ blankLine();
206
+ subheading("Where its files land in this project");
171
207
  blankLine();
172
208
  paragraph(
173
- "The recipe's own files are not on this machine, so the questions it asks and " +
174
- "the files it publishes are not known here. Subscribing to it fetches them."
209
+ "The recipe's own files are not on this machine, so the files it publishes are " +
210
+ "not known here. Subscribing to it fetches them."
175
211
  );
176
- footer();
177
- return;
178
212
  }
179
213
 
180
- this.printVariables(detail.variables);
181
- this.printContents(detail.contents);
182
-
183
214
  footer();
184
215
  }
185
216
 
217
+ /**
218
+ * Works out what subscribing to the described version would install and ask,
219
+ * from the same indexes the rest of the page reads, fetching nothing. The
220
+ * answer is a sentence instead when it cannot be worked out.
221
+ *
222
+ * @param service - The project's subscription service.
223
+ * @param inputs - The catalog's inputs, whose indexes the preview resolves against.
224
+ * @param detail - The recipe being described.
225
+ */
226
+ private async preview(
227
+ service: SubscriptionService,
228
+ inputs: CatalogInputs,
229
+ detail: RecipeDetail
230
+ ): Promise<SubscriptionPreview | string> {
231
+ if (detail.describing === undefined) {
232
+ return (
233
+ "This recipe publishes no version a subscription would install, so nothing " +
234
+ "is described below."
235
+ );
236
+ }
237
+ try {
238
+ return await service.previewSubscription(
239
+ {
240
+ repo: detail.repo,
241
+ namespace: detail.namespace,
242
+ recipe: detail.name,
243
+ range: detail.describing,
244
+ },
245
+ {
246
+ indexes: new Map(inputs.repos.map((repo) => [repo.name, repo.index])),
247
+ ...(semver.prerelease(detail.describing) === null ? {} : { prerelease: true }),
248
+ }
249
+ );
250
+ } catch (error) {
251
+ return (
252
+ `Sous could not work out what subscribing to version ${detail.describing} ` +
253
+ `would install. ${describeError(error)}`
254
+ );
255
+ }
256
+ }
257
+
186
258
  /**
187
259
  * Every published version, newest first, with what each one is to this
188
260
  * project.
@@ -207,24 +279,21 @@ export default class RecipeShow extends BaseCommand {
207
279
  }
208
280
 
209
281
  /**
210
- * What the described version depends on, from both sides: what the recipe's
211
- * manifest declares, and what its repository's index resolved that to when it
212
- * was released.
282
+ * What the described version depends on: the manifest entry that brings each
283
+ * dependency in and whether it is a co-subscription or a build dependency,
284
+ * and what its repository's index resolved it to when it was released.
213
285
  *
214
286
  * @param dependencies - The dependencies the catalog listed.
215
- * @param manifestRead - Whether the recipe's own manifest could be read.
287
+ * @param known - Whether the manifest or the index says what the version declares.
216
288
  */
217
- private printDependencies(
218
- dependencies: RecipeDependencyListing[],
219
- manifestRead: boolean
220
- ): void {
289
+ private printDependencies(dependencies: RecipeDependencyListing[], known: boolean): void {
221
290
  blankLine();
222
291
  subheading("What it depends on");
223
292
  blankLine();
224
293
 
225
294
  if (dependencies.length === 0) {
226
295
  paragraph(
227
- manifestRead
296
+ known
228
297
  ? "This recipe depends on nothing else."
229
298
  : "The repository's index records no dependencies for this version."
230
299
  );
@@ -233,7 +302,10 @@ export default class RecipeShow extends BaseCommand {
233
302
 
234
303
  const rows = dependencies.map((entry) => ({
235
304
  key: entry.key,
236
- declared: entry.declared ?? "not declared in the manifest",
305
+ declared:
306
+ entry.declared === undefined
307
+ ? "not recorded in the index"
308
+ : describeDeclaration(entry.declared),
237
309
  resolved:
238
310
  entry.resolvedVersion ??
239
311
  (entry.resolvedRange === undefined
@@ -249,32 +321,69 @@ export default class RecipeShow extends BaseCommand {
249
321
  }
250
322
 
251
323
  /**
252
- * The questions the recipe asks, and the environment variable each answer is
253
- * stored under.
324
+ * Every recipe a subscription to the described version would install, each
325
+ * with whether its files land in the project and what brings it in, and the
326
+ * repositories it would need that the project does not trust yet.
254
327
  *
255
- * @param variables - The variables the catalog listed.
328
+ * @param preview - What subscribing would install and ask.
256
329
  */
257
- private printVariables(variables: RecipeVariableListing[]): void {
330
+ private printInstalls(preview: SubscriptionPreview): void {
258
331
  blankLine();
259
- subheading("What it asks you");
332
+ subheading("What subscribing installs");
260
333
  blankLine();
261
334
 
262
- if (variables.length === 0) {
263
- paragraph("This recipe asks no questions.");
264
- return;
335
+ const rows = preview.resolved.map((recipe) => {
336
+ const subscribed = recipe.requestedBy.includes(PROJECT_REQUESTER);
337
+ const holders = recipe.requestedBy.filter((holder) => holder !== PROJECT_REQUESTER);
338
+ return {
339
+ key: recipe.key,
340
+ version: recipe.version,
341
+ kind: subscribed ? "the subscription itself" : describeDependencyKind(recipe.kind),
342
+ neededBy: holders.length === 0 ? "this subscription" : holders.join(", "),
343
+ };
344
+ });
345
+
346
+ for (const line of renderTable(INSTALLS_COLUMNS, rows, { indent: INDENT })) {
347
+ log(indent(line, INDENT));
265
348
  }
266
349
 
267
- const rows = variables.map((entry) => ({
268
- name: entry.name,
269
- type: entry.type,
270
- env: entry.env,
271
- required: entry.required ? "yes" : "no",
272
- secret: entry.secret ? "yes" : "no",
273
- prompt: entry.prompt,
274
- }));
350
+ if (preview.missingRepos.length > 0) {
351
+ blankLine();
352
+ paragraph(
353
+ `Subscribing also needs ${
354
+ preview.missingRepos.length === 1 ? "a repository" : "repositories"
355
+ } this project does not trust yet, so what ${
356
+ preview.missingRepos.length === 1 ? "it publishes" : "they publish"
357
+ } is not listed above: ` +
358
+ preview.missingRepos
359
+ .map(
360
+ (missing) =>
361
+ `${missing.name}${missing.url === undefined ? "" : ` (${missing.url})`}, ` +
362
+ `needed by ${missing.requiredBy.map((entry) => entry.requestedBy).join(", ")}`
363
+ )
364
+ .join("; ") +
365
+ "."
366
+ );
367
+ }
368
+ }
275
369
 
276
- for (const line of renderTable(VARIABLE_COLUMNS, rows, { indent: INDENT })) {
277
- log(indent(line, INDENT));
370
+ /**
371
+ * Every question a subscription to the described version would ask, laid out
372
+ * exactly as `sous subscription add --dry-run` lays them out, without the
373
+ * flags that answer them ahead of time.
374
+ *
375
+ * @param preview - What subscribing would install and ask.
376
+ */
377
+ private printQuestions(preview: SubscriptionPreview): void {
378
+ blankLine();
379
+ subheading("What subscribing asks you");
380
+ blankLine();
381
+
382
+ for (const line of formatQuestionPlan(preview.questions, {
383
+ unreadable: preview.unreadable,
384
+ answerHints: false,
385
+ })) {
386
+ log(line === "" ? "" : indent(line));
278
387
  }
279
388
  }
280
389
 
@@ -311,13 +420,3 @@ export default class RecipeShow extends BaseCommand {
311
420
  }
312
421
  }
313
422
 
314
- /**
315
- * Plain-language wording for how a dependency was declared.
316
- *
317
- * @param kind - What the manifest declared it as, when the manifest was read.
318
- */
319
- function describeDependencyKind(kind: "depends" | "subscribes" | undefined): string {
320
- if (kind === "depends") return "build dependency";
321
- if (kind === "subscribes") return "co-subscription";
322
- return "unknown";
323
- }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * `sous update` moves sous itself to a newer (or a chosen) version, for the
3
+ * global install, the project's own install, or both, through whichever
4
+ * package manager owns each one (sous-io/sous#135).
5
+ *
6
+ * sous update
7
+ * sous update --global
8
+ * sous update --major
9
+ * sous update --version 0.2.18
10
+ * sous update --dry-run
11
+ *
12
+ * It runs outside a project as readily as inside one, so it opts out of the
13
+ * config requirement, as `sous init` does. `bin/run.js` never hands this
14
+ * command to a project's own copy: updating the copy that was invoked is the
15
+ * point. The flow itself lives in `lib/self-update/run.ts`; this class turns
16
+ * the flags into its options and decides the exit code.
17
+ */
18
+
19
+ import path from "node:path";
20
+ import { Flags } from "@oclif/core";
21
+ import { BaseCommand } from "../base-command.js";
22
+ import { SOUS_DIR_NAME, findConfigInSousDir } from "../lib/config-discovery.js";
23
+ import { ConfigError, isConfigError } from "../lib/errors.js";
24
+ import { CLI_ROOT } from "../lib/package-info.js";
25
+ import { registryUrl, runSelfUpdate } from "../lib/self-update/run.js";
26
+ import type { UpdateScope, VersionRequest } from "../lib/self-update/index.js";
27
+ import { confirmationFlag } from "../utils/flags.js";
28
+ import { footer, showCommandVars } from "../utils/formatting.js";
29
+
30
+ export default class Update extends BaseCommand {
31
+ static description =
32
+ "Update sous itself, globally and in this project, through the package manager that installed each copy";
33
+
34
+ /** This command updates sous wherever it is installed; a project config is optional. */
35
+ static override requiresConfig = false;
36
+
37
+ /**
38
+ * A config this copy cannot load (one written for a newer sous, say) is the
39
+ * very thing an update may fix, so it is a warning here rather than a stop.
40
+ */
41
+ static override toleratesConfigErrors = true;
42
+
43
+ static examples = [
44
+ "<%= config.bin %> update",
45
+ "<%= config.bin %> update --global",
46
+ "<%= config.bin %> update --major",
47
+ "<%= config.bin %> update --version 0.2.18",
48
+ "<%= config.bin %> update --version next",
49
+ "<%= config.bin %> update --dry-run",
50
+ ];
51
+
52
+ static flags = {
53
+ ...BaseCommand.baseFlags,
54
+ major: Flags.boolean({
55
+ description: "Install the newest published version, whatever its major version",
56
+ default: false,
57
+ exclusive: ["version"],
58
+ }),
59
+ version: Flags.string({
60
+ description: "Install this version, the newest version in this range, or the version this dist-tag names",
61
+ exclusive: ["major"],
62
+ }),
63
+ prerelease: Flags.boolean({
64
+ description:
65
+ "Count prerelease versions as candidates; on by default when the installed version is a prerelease",
66
+ allowNo: true,
67
+ aliases: ["pre"],
68
+ }),
69
+ global: Flags.boolean({
70
+ description: "Update only the global install",
71
+ default: false,
72
+ exclusive: ["project"],
73
+ }),
74
+ project: Flags.boolean({
75
+ description: "Update only this project's own install",
76
+ default: false,
77
+ exclusive: ["global"],
78
+ }),
79
+ yes: confirmationFlag(),
80
+ "dry-run": Flags.boolean({
81
+ description: "Print every install's plan without installing anything",
82
+ default: false,
83
+ }),
84
+ "no-build": Flags.boolean({
85
+ description: "Update the project install without building the project afterwards",
86
+ default: false,
87
+ }),
88
+ };
89
+
90
+ async run(): Promise<void> {
91
+ const { flags } = await this.parse(Update);
92
+ const dryRun = flags["dry-run"];
93
+ const request: VersionRequest =
94
+ flags.version !== undefined
95
+ ? { kind: "spec", spec: flags.version }
96
+ : flags.major
97
+ ? { kind: "major" }
98
+ : { kind: "default" };
99
+ const only: UpdateScope | undefined = flags.global
100
+ ? "global"
101
+ : flags.project
102
+ ? "project"
103
+ : undefined;
104
+
105
+ showCommandVars({
106
+ Directory: this.configLocator.cwd,
107
+ Installs: only === undefined ? "global and project" : `${only} only`,
108
+ Version: describeRequest(request),
109
+ Prereleases:
110
+ flags.prerelease === undefined
111
+ ? "counted when the installed version is one"
112
+ : flags.prerelease
113
+ ? "counted"
114
+ : "not counted",
115
+ Registry: registryUrl(),
116
+ "Dry Run": dryRun,
117
+ });
118
+
119
+ const outcome = await runSelfUpdate({
120
+ cwd: this.configLocator.cwd,
121
+ ownRoot: CLI_ROOT,
122
+ request,
123
+ ...(flags.prerelease === undefined ? {} : { prerelease: flags.prerelease }),
124
+ ...(only === undefined ? {} : { only }),
125
+ dryRun,
126
+ yes: flags.yes,
127
+ interactive: this.interactive,
128
+ build: !flags["no-build"],
129
+ buildDirFor: (projectRoot) => this.buildDirFor(projectRoot),
130
+ });
131
+
132
+ footer();
133
+
134
+ if (outcome.failed) {
135
+ throw new ConfigError(
136
+ "Not every step of the update succeeded; the summary above lists what completed and what failed."
137
+ );
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Where a build after a project update runs: the directory of the config
143
+ * discovery found, when it lies inside the project that was updated, and
144
+ * otherwise the project root when its own `.sous/` holds a primary config.
145
+ * A config this copy could not load counts as found: the new copy may
146
+ * understand it, and its build is where any real problem is reported.
147
+ * Undefined means the project has no sous config, so nothing is built.
148
+ */
149
+ private buildDirFor(projectRoot: string): string | undefined {
150
+ const found = this.hasConfig ? this.discovered.sousDir : this.rejectedConfig?.sousDir;
151
+ if (found !== undefined) {
152
+ const configDir = path.dirname(found);
153
+ const relative = path.relative(projectRoot, configDir);
154
+ if (!relative.startsWith("..") && !path.isAbsolute(relative)) return configDir;
155
+ }
156
+ return holdsPrimaryConfig(path.join(projectRoot, SOUS_DIR_NAME)) ? projectRoot : undefined;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Whether a `.sous/` directory holds a primary config. Holding several is
162
+ * still holding one: the build that follows reports that mistake itself.
163
+ */
164
+ function holdsPrimaryConfig(sousDir: string): boolean {
165
+ try {
166
+ return findConfigInSousDir(sousDir) !== null;
167
+ } catch (error) {
168
+ if (isConfigError(error)) return true;
169
+ throw error;
170
+ }
171
+ }
172
+
173
+ /** The version request, as the opening block says it. */
174
+ function describeRequest(request: VersionRequest): string {
175
+ if (request.kind === "default") return "the newest in each install's major version";
176
+ if (request.kind === "major") return "the newest published, whatever its major version";
177
+ return request.spec;
178
+ }
@@ -18,7 +18,11 @@ export type HandoffPlan =
18
18
 
19
19
  export function isEnvFlagOn(value: string | undefined): boolean;
20
20
  export function binEntryOf(pkg: unknown): string | undefined;
21
+ export function readPackageJson(dir: string): Record<string, unknown> | undefined;
21
22
  export function findProjectInstall(startDir: string, ownRoot: string): ProjectInstall | undefined;
23
+ export function findProjectCopy(startDir: string): string | undefined;
24
+ export function realpathOr(p: string): string;
25
+ export function describeInstall(candidate: string, ownRoot: string): ProjectInstall | undefined;
22
26
  export function formatHandoffNotice(input: {
23
27
  install: Extract<ProjectInstall, { same: false }>;
24
28
  ownVersion: string;
@@ -27,6 +27,10 @@
27
27
  * - Anything unreadable or ambiguous means "run the copy that was invoked".
28
28
  * A hand-off is a convenience; a refusal to run is not.
29
29
  * - `SOUS_NO_DELEGATE` (anything but 0/false/no/off) runs the invoked copy.
30
+ * - `sous update` runs the invoked copy: updating the copy that was invoked
31
+ * is its job, and a project copy may be too old to have the command. The
32
+ * command word is the first entry on the command line that is not a flag,
33
+ * so `sous subscription update` still hands off.
30
34
  * - The notice goes to stderr, so piped stdout stays clean. It prints when
31
35
  * the two versions differ, and on every hand-off under `SOUS_DEBUG` or
32
36
  * with `--verbose` anywhere on the command line. Plain, it is one line
@@ -50,6 +54,20 @@ export const DEBUG_ENV = "SOUS_DEBUG";
50
54
  /** The flag that makes the notice say where both installs are. */
51
55
  export const VERBOSE_FLAG = "--verbose";
52
56
 
57
+ /** The command that always runs in the copy that was invoked. */
58
+ export const SELF_UPDATE_COMMAND = "update";
59
+
60
+ /**
61
+ * The command word on a command line: its first entry that does not start
62
+ * with `-`, or undefined when every entry is a flag.
63
+ *
64
+ * commandWordOf(["--verbose", "update", "--yes"]) -> "update"
65
+ * commandWordOf(["subscription", "update"]) -> "subscription"
66
+ */
67
+ export function commandWordOf(argv) {
68
+ return argv.find((arg) => !arg.startsWith("-"));
69
+ }
70
+
53
71
  /**
54
72
  * Whether an on/off environment variable is on: set to anything but an empty
55
73
  * string, `0`, `false`, `no` or `off` (case-insensitive, whitespace trimmed).
@@ -60,10 +78,10 @@ export function isEnvFlagOn(value) {
60
78
  }
61
79
 
62
80
  /**
63
- * Reads a package.json, returning the parsed object or undefined for a file
64
- * that is missing, unreadable or not JSON.
81
+ * Reads the package.json in `dir`, returning the parsed object or undefined
82
+ * for a file that is missing, unreadable or not JSON.
65
83
  */
66
- function readPackageJson(dir) {
84
+ export function readPackageJson(dir) {
67
85
  try {
68
86
  return JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
69
87
  } catch {
@@ -103,19 +121,32 @@ export function binEntryOf(pkg) {
103
121
  * older one further up would be a guess.
104
122
  */
105
123
  export function findProjectInstall(startDir, ownRoot) {
124
+ const candidate = findProjectCopy(startDir);
125
+ return candidate === undefined ? undefined : describeInstall(candidate, ownRoot);
126
+ }
127
+
128
+ /**
129
+ * Looks up from `startDir` for the nearest `node_modules/@sous-io/sous` that
130
+ * holds a package.json, and returns that path as found, WITHOUT resolving
131
+ * symlinks (pnpm links it into its own store, so only the unresolved path says
132
+ * which project holds it). Undefined when no ancestor holds one.
133
+ */
134
+ export function findProjectCopy(startDir) {
106
135
  let dir = path.resolve(startDir);
107
136
  for (;;) {
108
137
  const candidate = path.join(dir, "node_modules", ...PACKAGE_NAME.split("/"));
109
- if (fs.existsSync(path.join(candidate, "package.json"))) {
110
- return describeInstall(candidate, ownRoot);
111
- }
138
+ if (fs.existsSync(path.join(candidate, "package.json"))) return candidate;
112
139
  const parent = path.dirname(dir);
113
140
  if (parent === dir) return undefined;
114
141
  dir = parent;
115
142
  }
116
143
  }
117
144
 
118
- function realpathOr(p) {
145
+ /**
146
+ * The real path of `p`, or `p` resolved when it cannot be read (it does not
147
+ * exist, or a link in it is broken).
148
+ */
149
+ export function realpathOr(p) {
119
150
  try {
120
151
  return fs.realpathSync(p);
121
152
  } catch {
@@ -123,7 +154,13 @@ function realpathOr(p) {
123
154
  }
124
155
  }
125
156
 
126
- function describeInstall(candidate, ownRoot) {
157
+ /**
158
+ * Describes the copy of the package at `candidate` the way `findProjectInstall`
159
+ * does: `{ same: true, root }` when it is `ownRoot`, compared by real path,
160
+ * `{ same: false, root, version, bin }` for a usable other copy, and undefined
161
+ * for one that is not usable.
162
+ */
163
+ export function describeInstall(candidate, ownRoot) {
127
164
  const root = realpathOr(candidate);
128
165
  if (root === realpathOr(ownRoot)) return { same: true, root };
129
166
  const pkg = readPackageJson(root);
@@ -162,6 +199,7 @@ export function formatHandoffNotice({ install, ownVersion, ownRoot, verbose }) {
162
199
  */
163
200
  export function planHandoff({ cwd, ownRoot, env, argv = [] }) {
164
201
  if (isEnvFlagOn(env[NO_DELEGATE_ENV])) return { kind: "run-self" };
202
+ if (commandWordOf(argv) === SELF_UPDATE_COMMAND) return { kind: "run-self" };
165
203
  const install = findProjectInstall(cwd, ownRoot);
166
204
  if (!install || install.same) return { kind: "run-self" };
167
205