@sous-io/sous 0.2.30 → 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 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,4 @@
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)
@@ -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`
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.31",
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",
@@ -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.31
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
 
@@ -38,15 +38,48 @@ export type FetchTextOptions = {
38
38
  label?: string;
39
39
  /** Cancels the request, for a caller that will not wait past a deadline. */
40
40
  signal?: AbortSignal;
41
+ /** The `Accept` header to send. Defaults to `application/json`. */
42
+ accept?: string;
43
+ /**
44
+ * The lines that explain a failed status to the reader. Defaults to the
45
+ * hints about a sous repository (a missing index, a private repository), so
46
+ * a caller fetching anything else names its own.
47
+ */
48
+ statusHints?: (status: number) => string[];
41
49
  };
42
50
 
51
+ /**
52
+ * What a failed status usually means when the URL names a file in a sous
53
+ * repository: the default for `FetchTextOptions.statusHints`.
54
+ *
55
+ * @param status - The HTTP status the server answered with.
56
+ */
57
+ function repositoryStatusHints(status: number): string[] {
58
+ if (status === 404) {
59
+ return [
60
+ " Either the repository publishes no sous index yet, or the URL names a " +
61
+ "repository that does not exist.",
62
+ ];
63
+ }
64
+ if (status === 401 || status === 403) {
65
+ return [
66
+ " The repository is private or the request was not authorized. Sous uses a " +
67
+ "token from the environment, or from the provider's command line tool when " +
68
+ "one is installed and signed in.",
69
+ ];
70
+ }
71
+ return [];
72
+ }
73
+
43
74
  /**
44
75
  * GETs a URL and returns its body as text. Any non-2xx response, or a transport
45
76
  * failure, becomes a ConfigError that names the URL and the status, and says
46
- * plainly what a 404 or a 401 usually means for a sous repository.
77
+ * plainly what the status usually means (for a sous repository, unless the
78
+ * caller passes `statusHints`).
47
79
  *
48
80
  * @param url - The absolute HTTPS URL to fetch.
49
- * @param options - Bearer token, fetch implementation and error label.
81
+ * @param options - Bearer token, fetch implementation, error label, `Accept`
82
+ * header and status hints.
50
83
  */
51
84
  export async function fetchText(
52
85
  url: string,
@@ -62,7 +95,7 @@ export async function fetchText(
62
95
  );
63
96
  }
64
97
 
65
- const headers: Record<string, string> = { Accept: "application/json" };
98
+ const headers: Record<string, string> = { Accept: options.accept ?? "application/json" };
66
99
  if (options.token !== undefined && options.token.length > 0) {
67
100
  headers["Authorization"] = `Bearer ${options.token}`;
68
101
  }
@@ -85,19 +118,7 @@ export async function fetchText(
85
118
  `Sous could not fetch the ${label} from ${url}.`,
86
119
  ` The server answered ${response.status} ${response.statusText}.`,
87
120
  ];
88
- if (response.status === 404) {
89
- lines.push(
90
- " Either the repository publishes no sous index yet, or the URL names a " +
91
- "repository that does not exist."
92
- );
93
- }
94
- if (response.status === 401 || response.status === 403) {
95
- lines.push(
96
- " The repository is private or the request was not authorized. Sous uses a " +
97
- "token from the environment, or from the provider's command line tool when " +
98
- "one is installed and signed in."
99
- );
100
- }
121
+ lines.push(...(options.statusHints ?? repositoryStatusHints)(response.status));
101
122
  throw new ConfigError(lines.join("\n"));
102
123
  }
103
124
 
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The self-update layer: which copies of sous are installed, which version
3
+ * each should move to, and the package manager command that moves it.
4
+ *
5
+ * Import from here rather than from the individual modules. Nothing exported
6
+ * here installs anything; `run.ts`, imported on its own by `sous update`, is
7
+ * the flow that runs the commands the plans name.
8
+ */
9
+
10
+ export * from "./versions.js";
11
+ export * from "./registry.js";
12
+ export * from "./managers.js";
13
+ export * from "./installs.js";
14
+ export * from "./plan.js";