@sous-io/sous 0.2.30 → 0.2.32

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 CHANGED
@@ -40,6 +40,18 @@ output. When the two versions differ, one line on standard error names the versi
40
40
  to; `--verbose` announces every hand-off and adds where both installs are. Set
41
41
  `SOUS_NO_DELEGATE=1` to run the copy you invoked instead.
42
42
 
43
+ To update, run `sous update`. It updates the global install, the project's own install, or both
44
+ (whichever this directory has), through the package manager that installed each one (npm, pnpm or
45
+ Yarn), and asks before each one. By default each install moves to the newest version in its current
46
+ major; `--major` goes past it, `--version <version|range|tag>` picks one (a downgrade included),
47
+ `--global` and `--project` narrow the run, `--yes` answers every question and `--dry-run` only
48
+ prints the plan. After a project update, the new copy builds the project, so its lockfile moves to
49
+ the new version too.
50
+
51
+ ```bash
52
+ sous update --dry-run # show what would be installed where, and install nothing
53
+ ```
54
+
43
55
  Or run it from a clone (useful when developing sous itself):
44
56
 
45
57
  ```bash
@@ -59,6 +59,58 @@ $ SOUS_NO_DELEGATE=1 sous --version
59
59
  v0.3.0
60
60
  ```
61
61
 
62
+ ### Updating
63
+
64
+ `sous update` moves each copy of sous to a newer version (or one you choose) through the package
65
+ manager that installed it, so you never need to remember which one that was or its syntax:
66
+
67
+ - **Outside a project**, or in a project that does not install sous, it updates the global install.
68
+ Each of npm, pnpm and Yarn classic is asked where its global packages live, and every global
69
+ install found is updated with its own manager (`npm i -g`, `pnpm add -g`, `yarn global add`).
70
+ - **In a project that installs sous**, it updates the project's install with the project's own
71
+ manager (`npm i -D -E`, `pnpm add -D -E`, `yarn add -D -E`), which pins the exact version in
72
+ `package.json` and updates the lockfile. A dependency declared under `dependencies` stays there.
73
+ It updates the global install as well when there is one. A Yarn Plug'n'Play project, which has
74
+ no `node_modules`, counts as installing sous when its `package.json` declares it and a `.pnp.cjs`
75
+ sits beside it or above it; the installed version is read from `yarn.lock`.
76
+ - **After a project update**, the newly installed copy builds the project, so
77
+ `.sous/sous.lock.json` pins the core recipe at the new version. Under Yarn Plug'n'Play, Yarn
78
+ runs that build (`yarn run --binaries-only sous build`). `--no-build` skips it.
79
+
80
+ Under Yarn Plug'n'Play, sous runs only from a copy Yarn has unpacked; from inside Yarn's zip cache
81
+ it fails with `EBADF: bad file descriptor`. Unpack it in `package.json`, then run `yarn install`:
82
+
83
+ ```json
84
+ "dependenciesMeta": { "@sous-io/sous": { "unplugged": true } }
85
+ ```
86
+
87
+ `update` always runs in the copy you invoked, never handed off to the project's own; a project copy
88
+ may be too old to have the command. A project config this copy cannot load (one written for a newer
89
+ sous, say) is a warning rather than a stop, since installing a newer copy may be the fix.
90
+
91
+ Every plan is worked out before anything is installed, and each install prints its plan and asks
92
+ its own question. Each is judged on its own version:
93
+
94
+ - By default, the newest published version in the install's current major (for a 0.x install,
95
+ anything below 1.0.0). A default run never downgrades.
96
+ - `--major`: the newest published version, whatever its major.
97
+ - `--version <version|range|tag>`: exactly that version, the newest one in that range, or the one a
98
+ dist-tag such as `next` points at. This is how you downgrade, and the plan says when it is one.
99
+ - `--prerelease` counts prerelease versions, and is on by default when the installed version is a
100
+ prerelease; `--no-prerelease` switches it off.
101
+ - `--global` or `--project` updates only that install; `-y, --yes` answers every question;
102
+ `--dry-run` prints the plans, with the command each would run, and installs nothing.
103
+
104
+ A copy run through `npx`, or one managed by Volta or by a manager sous does not drive, is listed
105
+ with the command to run by hand, and the rest are still updated. A package manager's own failure
106
+ is printed as the manager reported it; sous never retries with `sudo`.
107
+
108
+ ```term
109
+ $ sous update --dry-run
110
+ // every install's plan, and nothing installed
111
+ $ sous update --global --yes
112
+ ```
113
+
62
114
  ## Where to look
63
115
 
64
116
  - Watch the [animated introduction](../) for the full pitch
@@ -29,3 +29,5 @@
29
29
  - [0008: One ref parser, told where the ref came from](adrs/0008-one-ref-parser.md)
30
30
  - [0009: One whole build, everywhere](adrs/0009-whole-build-everywhere.md)
31
31
  - [0010: The index describes each version's dependencies and questions](adrs/0010-index-describes-recipes.md)
32
+ - [0011: `sous update` updates sous through the package managers](adrs/0011-self-update.md)
33
+ - [0012: Recipes do not install Sous plugins](adrs/0012-recipes-and-plugins.md)
@@ -146,6 +146,27 @@ so it works offline. `--limit <n>` sets how many matches to show, defaulting to
146
146
  only installed recipes and adds an Installed column. Also spelled `sous repo search`.
147
147
  Example: `sous search task --limit 50`
148
148
 
149
+ ### `sous update`
150
+ Updates sous itself: the global install, this project's own install, or both, each through the package manager
151
+ that installed it. It works outside a project too, and it always runs in the copy you invoked, never handed off
152
+ to the project's own. Every plan is printed before anything is installed, and each install asks its own
153
+ question. After a project update the new copy builds the project, so the lockfile pins the core recipe at the new
154
+ version. A project config this copy cannot load is one warning, not a stop. Takes `-y, --yes` and `--dry-run`
155
+ (which also prints the command each install would run). See [Updating](README.md#updating).
156
+
157
+ - `--major`: install the newest published version, whatever its major. By default each install moves to the
158
+ newest version in its current major, and never down.
159
+ - `--version <version|range|tag>`: install exactly this version, the newest in this range, or the one this
160
+ dist-tag names. This is how you downgrade.
161
+ - `--prerelease`, `--no-prerelease`: count prerelease versions as candidates, or not. On by default when the
162
+ installed version is a prerelease. `--pre` is the same flag.
163
+ - `--global`, `--project`: update only that install.
164
+ - `--no-build`: update the project install without building the project afterwards.
165
+
166
+ `--major` and `--version` exclude each other, and so do `--global` and `--project`.
167
+
168
+ Example: `sous update --version next --dry-run`
169
+
149
170
  ### `sous help [COMMAND]`
150
171
  Prints the help for sous, or for one command or topic. Works from any directory, including one with no config
151
172
  above it. Example: `sous help repo add`
@@ -80,9 +80,9 @@ contents: # what a subscriber actually receives
80
80
  | `contents`, `variables` | no | Contents default to an empty list, which is what a curated bundle publishes; a duplicated variable name, or two definitions claiming one `env`, is an error |
81
81
  | `submissions` | no | Whether this recipe takes proposed changes, winning over the repository's block; see [The submissions block](#the-submissions-block) |
82
82
 
83
- `contents[].kind` is `skills`, `memories`, `prompts` or `config`. The first three are written to the
84
- destinations named by [`recipeOutputs`](#recipeoutputs-where-the-files-land); `config` entries become config
85
- layers instead. `include` needs at least one glob, and both lists are recipe-relative.
83
+ `contents[].kind` is `skills`, `memories`, `prompts` or `config`; there is no kind for a sous plugin. The
84
+ first three are written to the destinations named by [`recipeOutputs`](#recipeoutputs-where-the-files-land);
85
+ `config` entries become config layers instead. `include` needs at least one glob, and both lists are recipe-relative.
86
86
 
87
87
  ### The submissions block
88
88
 
@@ -47,7 +47,8 @@ $ sous namespace list
47
47
 
48
48
  A recipe is the unit you subscribe to and the only thing that carries a version. It may hold
49
49
  skills, memories, prompts, config layers and variable definitions in any mixture, and
50
- subscribing to it gets all of them.
50
+ subscribing to it gets all of them. A recipe may ship a plugin for a coding tool (a Claude Code
51
+ plugin, say), but never a plugin for sous itself; those come only with sous or as npm packages.
51
52
 
52
53
  ```yaml
53
54
  # recipes/workflow/task-files/sous.recipe.yaml
@@ -193,10 +194,10 @@ this machine.
193
194
  Subscribing decides what is switched on, and it is not a second security gate. A recipe is
194
195
  **active** when the project subscribes to it directly or through its namespace, or when an active
195
196
  recipe lists it under `subscribes`; only an active recipe registers entry points of its own, such as
196
- output files, skills and hooks. A recipe held only through `depends` is a library: an active recipe
197
- may include, call or import its files, but it registers nothing itself. Activation keeps a project
198
- from running what it has no use for and keeps unrelated recipes from colliding; everything in a
199
- trusted repository is already trusted.
197
+ output files, skills, tool hooks and tool plugins. A recipe held only through `depends` is a library:
198
+ an active recipe may include, call or import its files, but it registers nothing itself. Activation
199
+ keeps a project from running what it has no use for and keeps unrelated recipes from colliding;
200
+ everything in a trusted repository is already trusted.
200
201
 
201
202
  ## The official repository, and the built-in core
202
203
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sous-io/sous",
3
- "version": "0.2.30",
3
+ "version": "0.2.32",
4
4
  "description": "Compiles AI coding agent configuration (CLAUDE.md, skills, memories) from LiquidJS templates",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -60,6 +60,7 @@
60
60
  "jsonc-parser": "^3.3.1",
61
61
  "liquidjs": "^10.25.0",
62
62
  "minimatch": "^10.2.4",
63
+ "package-manager-detector": "^1.8.0",
63
64
  "semver": "^7.8.5",
64
65
  "tiktoken": "^1.0.22",
65
66
  "tsx": "^4",
@@ -188,6 +188,13 @@ wins**; if none exist, the build errors listing every path tried:
188
188
 
189
189
  ## Custom Tags
190
190
 
191
+ The tags below are built into sous. A recipe cannot add a tag or a filter, since that would be a
192
+ Sous plugin; a Sous plugin comes only from sous's built-ins or an npm-style module.
193
+
194
+ > "recipes will not be allowed to install _Sous_ plugins. All Sous plugins, besides any built-ins that ship with Sous, itself, will come from a NPM repo (or similar) as modules. Recipes _will_ be allowed to install tool plugins (e.g. Claude Code Plugins)."
195
+ >
196
+ > -- **Luke Chavers** in an agent session (2026-09-29)
197
+
191
198
  **`showVars`**: dumps all variables currently in scope as a fenced JSON block.
192
199
  Useful during development to see exactly what variables are available at a given point
193
200
  in a template. Remove before finalizing.
@@ -65,8 +65,14 @@ trust question reads only its index.
65
65
  Subscribing decides what is switched on, not what is safe. A recipe is active when the
66
66
  project subscribes to it, directly or through its namespace, or when an active recipe
67
67
  lists it under `subscribes`, and only an active recipe registers entry points of its own
68
- (output files, skills, hooks). A recipe held only through `depends` is a library: an active
69
- recipe may include, call or import its files, but it registers nothing itself.
68
+ (output files, skills, tool hooks and tool plugins). A recipe held only through `depends` is a
69
+ library: an active recipe may include, call or import its files, but it registers nothing
70
+ itself. No recipe installs a Sous plugin; Sous plugins come only from sous's built-ins and from
71
+ npm-style modules.
72
+
73
+ > "recipes will not be allowed to install _Sous_ plugins. All Sous plugins, besides any built-ins that ship with Sous, itself, will come from a NPM repo (or similar) as modules. Recipes _will_ be allowed to install tool plugins (e.g. Claude Code Plugins)."
74
+ >
75
+ > -- **Luke Chavers** in an agent session (2026-09-29)
70
76
 
71
77
  ## Sous's Own Documentation
72
78
 
@@ -54,6 +54,14 @@ The fields in that example:
54
54
  it MUST be `.json`, `.jsonc`, `.yaml` or `.yml`, and every key outside `_aliases`, `_vars`,
55
55
  `compilation`, `recipeOutputs`, `runtimeContext`, `store` and `varMappings` is dropped with a
56
56
  warning. A recipe that only bundles others may leave `contents` out.
57
+ - A recipe MAY install tool plugins (a plugin for a coding agent tool, such as a Claude Code
58
+ plugin) and MUST NOT install a Sous plugin (one that extends sous itself). Sous plugins come only
59
+ from the built-ins that ship with sous and from npm-style modules. A `config` file is
60
+ configuration, not a Sous plugin.
61
+
62
+ > "recipes will not be allowed to install _Sous_ plugins. All Sous plugins, besides any built-ins that ship with Sous, itself, will come from a NPM repo (or similar) as modules. Recipes _will_ be allowed to install tool plugins (e.g. Claude Code Plugins)."
63
+ >
64
+ > -- **Luke Chavers** in an agent session (2026-09-29)
57
65
  - `depends`, `subscribes` and `variables` are described below; `submissions` is in
58
66
  `about-sous-repos`.
59
67
 
@@ -11,7 +11,7 @@ formatVersion: 1
11
11
 
12
12
  namespace: core
13
13
  name: sous-skills
14
- version: 0.2.30
14
+ version: 0.2.32
15
15
 
16
16
  description: >-
17
17
  The skills that teach an agent what sous is and how it works: which files sous
@@ -88,6 +88,20 @@ export abstract class BaseCommand extends Command {
88
88
  */
89
89
  static requiresConfig = true;
90
90
 
91
+ /**
92
+ * Whether a config that cannot be located or loaded stops this command. It
93
+ * is false on every command but the one whose job may be to repair that
94
+ * config: `sous update` installs a sous that may understand a config this
95
+ * copy rejects (one written for a newer version, say). When true, a failure
96
+ * to locate or load the config becomes one warning, naming the config and
97
+ * quoting the error, and the run carries on with no config adopted:
98
+ * `hasConfig` is false, the env files are not applied, and `rejectedConfig`
99
+ * says where the config that failed to load was, when discovery found it.
100
+ * It only changes what a FAILURE does; finding no config at all is still
101
+ * decided by `requiresConfig`.
102
+ */
103
+ static toleratesConfigErrors = false;
104
+
91
105
  protected settings!: Settings;
92
106
 
93
107
  /** Where the active config was found. */
@@ -103,6 +117,13 @@ export abstract class BaseCommand extends Command {
103
117
  /** The full discovery result, including how the config was located. */
104
118
  protected discovered!: DiscoveredConfig;
105
119
 
120
+ /**
121
+ * The config discovery found but could not load, on a command that
122
+ * `toleratesConfigErrors`. Undefined when the config loaded, when there was
123
+ * none, or when the failure came before any config file was identified.
124
+ */
125
+ protected rejectedConfig?: { configPath: string; sousDir: string };
126
+
106
127
  /**
107
128
  * The real shell environment, snapshotted BEFORE the `.sous/` env files are
108
129
  * injected into `process.env`. The variables layer needs it to tell a value
@@ -177,7 +198,7 @@ export abstract class BaseCommand extends Command {
177
198
  [sousDirEnv, "SOUS_DIR"],
178
199
  ];
179
200
  const primary = primaryCandidates.find(([value]) => value !== undefined);
180
- const requiresConfig = (this.constructor as typeof BaseCommand).requiresConfig;
201
+ const { requiresConfig, toleratesConfigErrors } = this.constructor as typeof BaseCommand;
181
202
 
182
203
  this.configLocator = {
183
204
  cwd,
@@ -195,6 +216,12 @@ export abstract class BaseCommand extends Command {
195
216
  try {
196
217
  discovered = resolveConfigFlag(primarySource, cwd, confDirOverride, sourceLabel);
197
218
  } catch (error) {
219
+ if (toleratesConfigErrors) {
220
+ return this.warnConfigNotLoaded(
221
+ error,
222
+ `the project config at ${this.configLocator.primary?.value} (named by ${sourceLabel})`
223
+ );
224
+ }
198
225
  // A command that creates the config is pointed at a place with none in
199
226
  // it by design; the flag still says where, so this is not a failure.
200
227
  if (!requiresConfig) return;
@@ -202,7 +229,12 @@ export abstract class BaseCommand extends Command {
202
229
  return this.exit(1);
203
230
  }
204
231
  } else {
205
- discovered = discoverConfig(cwd, confDirOverride);
232
+ try {
233
+ discovered = discoverConfig(cwd, confDirOverride);
234
+ } catch (error) {
235
+ if (!toleratesConfigErrors) throw error;
236
+ return this.warnConfigNotLoaded(error, `the project config it found searching up from ${cwd}`);
237
+ }
206
238
  }
207
239
 
208
240
  if (!discovered) {
@@ -243,6 +275,7 @@ export abstract class BaseCommand extends Command {
243
275
  try {
244
276
  refreshed = refreshDiscoveredConfig(discovered);
245
277
  } catch (error) {
278
+ if (this.toleratesConfigErrors) return this.rejectConfig(discovered, error);
246
279
  displayErrorBlock(error instanceof Error ? error.message : String(error), this.errorSink);
247
280
  return this.exit(1);
248
281
  }
@@ -262,11 +295,51 @@ export abstract class BaseCommand extends Command {
262
295
  try {
263
296
  this.settings = await loadSettings(refreshed);
264
297
  } catch (error) {
298
+ if (this.toleratesConfigErrors) return this.rejectConfig(refreshed, error);
265
299
  displayErrorBlock(error instanceof Error ? error.message : String(error), this.errorSink);
266
300
  return this.exit(1);
267
301
  }
268
302
  }
269
303
 
304
+ /** The `toleratesConfigErrors` setting of the running command's class. */
305
+ private get toleratesConfigErrors(): boolean {
306
+ return (this.constructor as typeof BaseCommand).toleratesConfigErrors;
307
+ }
308
+
309
+ /**
310
+ * Undoes a config adoption that failed on a command that tolerates it: the
311
+ * env files it injected are taken back out of `process.env`, nothing is left
312
+ * adopted, and one warning says what happened.
313
+ */
314
+ private rejectConfig(discovered: DiscoveredConfig, error: unknown): void {
315
+ for (const name of Object.keys(process.env)) {
316
+ if (!(name in this.shellEnv)) delete process.env[name];
317
+ }
318
+ this.shellEnv = {};
319
+ this.discovered = undefined as unknown as DiscoveredConfig;
320
+ this.configContext = undefined as unknown as ConfigContext;
321
+ this.rejectedConfig = { configPath: discovered.configPath, sousDir: discovered.sousDir };
322
+ this.warnConfigNotLoaded(error, `the project config at ${discovered.configPath}`);
323
+ }
324
+
325
+ /**
326
+ * The one warning a command that tolerates config errors prints in place of
327
+ * failing.
328
+ *
329
+ * @param error - What locating or loading the config threw.
330
+ * @param config - Which config: its file, or where it was looked for.
331
+ */
332
+ private warnConfigNotLoaded(error: unknown, config: string): void {
333
+ const message = error instanceof Error ? error.message : String(error);
334
+ warning(
335
+ `Sous could not load ${config}, so this command carries on without it.\n` +
336
+ "\n" +
337
+ "The error was:\n" +
338
+ message,
339
+ this.errorSink
340
+ );
341
+ }
342
+
270
343
  /**
271
344
  * Renders any failure as a plain, readable message instead of an oclif stack
272
345
  * trace: a config mistake, a mistyped command line, or a question sous could
@@ -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