@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 +12 -0
- package/docs/markdown/README.md +52 -0
- package/docs/markdown/_sidebar.md +1 -0
- package/docs/markdown/commands.md +21 -0
- package/package.json +2 -1
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/src/base-command.ts +75 -2
- package/src/commands/update.ts +178 -0
- package/src/lib/project-install.d.mts +4 -0
- package/src/lib/project-install.mjs +46 -8
- package/src/lib/repos/providers/http.ts +37 -16
- package/src/lib/self-update/index.ts +14 -0
- package/src/lib/self-update/installs.ts +412 -0
- package/src/lib/self-update/managers.ts +126 -0
- package/src/lib/self-update/plan.ts +315 -0
- package/src/lib/self-update/registry.ts +99 -0
- package/src/lib/self-update/run.ts +506 -0
- package/src/lib/self-update/versions.ts +243 -0
package/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
|
package/docs/markdown/README.md
CHANGED
|
@@ -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.
|
|
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",
|
package/src/base-command.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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";
|