@sous-io/sous 0.2.26 → 0.2.27
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/docs/markdown/_sidebar.md +1 -0
- package/docs/markdown/commands.md +24 -8
- package/docs/markdown/repositories-authoring.md +1 -1
- package/docs/markdown/repositories.md +20 -3
- package/package.json +1 -1
- package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +3 -2
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/src/commands/build.ts +28 -43
- package/src/commands/compile.ts +2 -2
- package/src/commands/init.ts +9 -17
- package/src/commands/launch.ts +13 -7
- package/src/commands/prune.ts +13 -10
- package/src/commands/repo/remove.ts +7 -6
- package/src/commands/repo/unlink.ts +7 -6
- package/src/commands/subscription/add.ts +7 -6
- package/src/commands/subscription/remove.ts +7 -6
- package/src/commands/subscription/update.ts +7 -6
- package/src/lib/build-preparation.ts +3 -4
- package/src/lib/build-service.ts +60 -16
- package/src/lib/markdown-compiler.ts +75 -15
- package/src/lib/repos/locked-namespace-resolver.ts +132 -4
- package/src/lib/repos/locked-recipes.ts +0 -22
- package/src/lib/repos/namespace-resolver.ts +104 -19
|
@@ -26,3 +26,4 @@
|
|
|
26
26
|
- [0005: Follow-ups from the first project on the hand-off](adrs/0005-first-project-follow-ups.md)
|
|
27
27
|
- [0006: `sous repo init` follows `sous-recipes`](adrs/0006-repo-init-model-home.md)
|
|
28
28
|
- [0007: Frozen index entries and tolerant readers](adrs/0007-frozen-index-dependencies.md)
|
|
29
|
+
- [0009: One whole build, everywhere](adrs/0009-whole-build-everywhere.md)
|
|
@@ -93,10 +93,21 @@ and the command you want almost always. Takes `--dry-run`.
|
|
|
93
93
|
|
|
94
94
|
- `--no-prune`, `--no-compile`: skip one half of the run.
|
|
95
95
|
- `--rebuild`: ignore cached hashes and reprocess every output.
|
|
96
|
-
- `--strict`:
|
|
96
|
+
- `--strict`: treat compile warnings as errors (a `.tpl.` file copied without being rendered, a git branch the
|
|
97
|
+
runtime context could not read).
|
|
97
98
|
- `-w, --watch`: rebuild on every change to a source file, a config layer or a linked checkout.
|
|
98
99
|
|
|
99
|
-
Before it compiles, a build
|
|
100
|
+
Before it compiles, a build prepares the project's recipes: it seeds the core recipe, pins any subscription the
|
|
101
|
+
lockfile does not pin yet, restores whatever the store on this machine is missing, and checks upstream for the
|
|
102
|
+
repositories that ask for newer versions. Every command that builds (`init`, `build`, `launch`, `prune`,
|
|
103
|
+
`repo remove`, `repo unlink`, `subscription add`, `subscription remove`, `subscription update`) runs this same
|
|
104
|
+
build, so each one prints the same things.
|
|
105
|
+
|
|
106
|
+
A compile error (a missing or circular include, a template that fails to render, a file sous cannot read) fails
|
|
107
|
+
the build. Every target is still compiled and every error is listed, then the build exits `1`. An output whose
|
|
108
|
+
target had an error is not written, so the copy from the last good build stays in place.
|
|
109
|
+
|
|
110
|
+
A build also lists each recipe this project uses that has a newer version within the range
|
|
100
111
|
declared for it, beside the version pinned. It moves no pin; only always-pull moves one. The build reads upstream
|
|
101
112
|
for this at most once per freshness window (`store.freshnessSeconds`, five minutes by default), gives a
|
|
102
113
|
repository three seconds to answer, and otherwise answers from the cached index without a word about the
|
|
@@ -106,18 +117,22 @@ Example: `sous build --rebuild`
|
|
|
106
117
|
|
|
107
118
|
### `sous compile`
|
|
108
119
|
Compiles markdown templates into output files, and prunes nothing. Takes `--rebuild`, `--strict`, `--dry-run`
|
|
109
|
-
and `-w, --watch`, each meaning what it means on `build
|
|
120
|
+
and `-w, --watch`, each meaning what it means on `build`, and exits `1` after a compile error the way `build`
|
|
121
|
+
does. It does not prepare the recipes first. Example: `sous compile --strict`
|
|
110
122
|
|
|
111
123
|
### `sous prune`
|
|
112
|
-
Removes output files no longer in the current config.
|
|
124
|
+
Removes output files no longer in the current config. It prepares the recipes first, as `build` does, because
|
|
125
|
+
what counts as current depends on the recipes the lockfile pins. Takes `--dry-run` only. Example:
|
|
126
|
+
`sous prune --dry-run`
|
|
113
127
|
|
|
114
128
|
### `sous clear`
|
|
115
129
|
Deletes every file and directory sous has written for the project, and asks first; `-f, --force` answers that
|
|
116
130
|
confirmation ahead of time. Example: `sous clear --force`
|
|
117
131
|
|
|
118
132
|
### `sous launch TOOL...`
|
|
119
|
-
Builds this project's outputs, then starts a coding agent configured under `tools` in its config.
|
|
120
|
-
launches without building; `--continuous` restarts the agent
|
|
133
|
+
Builds this project's outputs, then starts a coding agent configured under `tools` in its config. A build that
|
|
134
|
+
fails starts nothing and exits `1`. `--no-build` launches without building; `--continuous` restarts the agent
|
|
135
|
+
whenever it exits.
|
|
121
136
|
|
|
122
137
|
Any argument `launch` does not recognize is forwarded to the tool; a flag that collides with one of sous's own
|
|
123
138
|
goes after a bare `--`: `sous launch claude --resume`, `sous launch claude -- -c`.
|
|
@@ -447,8 +462,9 @@ Example: `sous vars ask --namespace workflow --var apiUrl`
|
|
|
447
462
|
|
|
448
463
|
A command that succeeds exits `0`. A command line sous could not parse (a missing argument, an unknown flag, a
|
|
449
464
|
value outside a flag's options) exits `2`. Every other failure exits `1`. A broken config halts sous rather than
|
|
450
|
-
producing output built on a guess.
|
|
451
|
-
|
|
465
|
+
producing output built on a guess. A build with a compile error carries on long enough to compile every target
|
|
466
|
+
and list every error, then exits `1`, leaving the last good copy of each output that had an error;
|
|
467
|
+
`--strict` on `build` and `compile` makes compile warnings fail the run too.
|
|
452
468
|
|
|
453
469
|
A failure prints one error block, in plain language, and nothing else; a usage mistake gets the command's own
|
|
454
470
|
help under it, on standard error. An error sous raises names the cause and the cure:
|
|
@@ -143,7 +143,7 @@ variables:
|
|
|
143
143
|
## Declare dependencies and co-subscriptions
|
|
144
144
|
|
|
145
145
|
`depends` fetches and pins a recipe whose files stay OUT of a subscriber's output but are addressable from your
|
|
146
|
-
own templates. `subscribes` is a co-subscription: its questions run and its files DO enter the output, which is
|
|
146
|
+
own templates, and from the templates of any project whose lockfile pins it. `subscribes` is a co-subscription: its questions run and its files DO enter the output, which is
|
|
147
147
|
how a curated bundle is built. Both hold plain strings naming their target by where it lives.
|
|
148
148
|
|
|
149
149
|
```yaml
|
|
@@ -331,9 +331,26 @@ published identity and then the path inside it, on a line of its own:
|
|
|
331
331
|
```
|
|
332
332
|
|
|
333
333
|
The `~` is required. A bare `@path` is always a relative path or a declared alias, so an include
|
|
334
|
-
line can never quietly stop meaning a file on disk.
|
|
335
|
-
against
|
|
336
|
-
|
|
334
|
+
line can never quietly stop meaning a file on disk. In your own templates, `~<namespace>`
|
|
335
|
+
resolves against every recipe the lockfile pins, whatever brought it in: a recipe you subscribe
|
|
336
|
+
to, a member of a set you subscribe to, or a library another recipe depends on. Inside a recipe,
|
|
337
|
+
it resolves only against that recipe's own `depends` and `subscribes` at the versions the
|
|
338
|
+
lockfile pins. The path after the recipe name may not be absolute or hold a `.` or `..` segment.
|
|
339
|
+
|
|
340
|
+
An include naming a recipe the lockfile does not pin fails the build. When sous can tell what
|
|
341
|
+
used to bring the recipe in (a switched-off subscription, or an earlier version of a set that
|
|
342
|
+
listed it), the error names it:
|
|
343
|
+
|
|
344
|
+
```text
|
|
345
|
+
Error: Include not found: @~workflow/member/memories/member.md
|
|
346
|
+
in file: .sous/prompts/AGENTS.md
|
|
347
|
+
namespace: workflow
|
|
348
|
+
recipe: workflow/member
|
|
349
|
+
The namespace "workflow" holds no recipe named "workflow/member".
|
|
350
|
+
Version 1.0.0 of "omakase/house" brought "workflow/member" in, but the version this project
|
|
351
|
+
pins, 1.1.0, does not, so the lockfile no longer pins it.
|
|
352
|
+
Subscribe to "workflow/member" directly to keep including it, or remove the include.
|
|
353
|
+
```
|
|
337
354
|
|
|
338
355
|
## Where to go next
|
|
339
356
|
|
package/package.json
CHANGED
|
@@ -148,8 +148,9 @@ written `@~<namespace>/<recipe>/<path inside that recipe>`:
|
|
|
148
148
|
|
|
149
149
|
Scoping is deliberate. A file inside a recipe may address that recipe itself plus the
|
|
150
150
|
recipes it declares under `depends` or `subscribes`; a file in the project's own templates
|
|
151
|
-
may address the project's
|
|
152
|
-
so a reference can never quietly pick up a recipe
|
|
151
|
+
may address every recipe the project's lockfile pins, whatever brought it in. Anything else
|
|
152
|
+
is an error naming what was missing, so a reference can never quietly pick up a recipe
|
|
153
|
+
nobody asked for.
|
|
153
154
|
|
|
154
155
|
Built-in **aliases** are reserved, always begin with `~`, and are consulted before recipe
|
|
155
156
|
namespaces. There is exactly one:
|
package/src/commands/build.ts
CHANGED
|
@@ -1,23 +1,14 @@
|
|
|
1
1
|
import { Flags } from "@oclif/core";
|
|
2
2
|
import { BaseCommand } from "../base-command.js";
|
|
3
|
-
import {
|
|
3
|
+
import { runProjectBuild } from "../lib/build-service.js";
|
|
4
4
|
import { PidService } from "../lib/pid-service.js";
|
|
5
5
|
import { resolveRootScope } from "../lib/settings.js";
|
|
6
|
-
import { describeLinkedRepos } from "../lib/repos/links.js";
|
|
7
6
|
import { resolveStoreSettings } from "../lib/repos/store/settings.js";
|
|
8
|
-
import { prepareRepositoriesForBuild } from "../lib/build-preparation.js";
|
|
9
7
|
import { subscriptionServiceFor } from "../lib/repos/subscription-service.js";
|
|
10
8
|
import { buildReloadWatchConfig, startConfigReloadWatch } from "../lib/watch-loop.js";
|
|
11
9
|
import type { WatchHandle } from "../lib/watch-service.js";
|
|
12
10
|
import { WatchService } from "../lib/watch-service.js";
|
|
13
|
-
import {
|
|
14
|
-
footer,
|
|
15
|
-
heading,
|
|
16
|
-
log,
|
|
17
|
-
paragraph,
|
|
18
|
-
showCommandVars,
|
|
19
|
-
warning,
|
|
20
|
-
} from "../utils/formatting.js";
|
|
11
|
+
import { log, paragraph, showCommandVars } from "../utils/formatting.js";
|
|
21
12
|
|
|
22
13
|
export default class Build extends BaseCommand {
|
|
23
14
|
static description =
|
|
@@ -49,7 +40,7 @@ export default class Build extends BaseCommand {
|
|
|
49
40
|
default: false,
|
|
50
41
|
}),
|
|
51
42
|
strict: Flags.boolean({
|
|
52
|
-
description: "
|
|
43
|
+
description: "Treat compile warnings as errors",
|
|
53
44
|
default: false,
|
|
54
45
|
}),
|
|
55
46
|
watch: Flags.boolean({
|
|
@@ -71,37 +62,20 @@ export default class Build extends BaseCommand {
|
|
|
71
62
|
"No Prune": flags["no-prune"],
|
|
72
63
|
});
|
|
73
64
|
|
|
74
|
-
// A linked repository is read from a working copy instead of a published
|
|
75
|
-
// version, so it is announced every single time; a build that silently
|
|
76
|
-
// produced something different would be far worse than a noisy one.
|
|
77
|
-
const linked = describeLinkedRepos(this.configContext.sousDir);
|
|
78
|
-
if (linked.length > 0) warning(linked.join("\n"));
|
|
79
|
-
|
|
80
|
-
const repositories = subscriptionServiceFor({
|
|
81
|
-
configContext: this.configContext,
|
|
82
|
-
settings: this.settings,
|
|
83
|
-
shellEnv: this.shellEnv,
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
if (!flags["dry-run"] && !flags["no-compile"]) {
|
|
87
|
-
await prepareRepositoriesForBuild(repositories);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
heading("Building");
|
|
91
|
-
|
|
92
65
|
const buildOptions = {
|
|
93
66
|
strict: flags.strict,
|
|
94
67
|
rebuild: flags.rebuild,
|
|
95
68
|
dryRun: flags["dry-run"],
|
|
96
69
|
noCompile: flags["no-compile"],
|
|
97
70
|
noPrune: flags["no-prune"],
|
|
98
|
-
configContext: this.configContext,
|
|
99
71
|
};
|
|
100
72
|
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
73
|
+
const success = await runProjectBuild({
|
|
74
|
+
settings: this.settings,
|
|
75
|
+
configContext: this.configContext,
|
|
76
|
+
shellEnv: this.shellEnv,
|
|
77
|
+
options: buildOptions,
|
|
78
|
+
});
|
|
105
79
|
|
|
106
80
|
if (!success && !flags.watch) {
|
|
107
81
|
this.exit(1);
|
|
@@ -129,17 +103,23 @@ export default class Build extends BaseCommand {
|
|
|
129
103
|
|
|
130
104
|
const watchService = new WatchService();
|
|
131
105
|
|
|
132
|
-
// Reruns
|
|
106
|
+
// Reruns the build with the command's current settings. Called for
|
|
133
107
|
// partial rebuilds (with the changed file) and, after a clean reload, for
|
|
134
|
-
// full rebuilds
|
|
108
|
+
// full rebuilds, which prepare the recipes again the way the first build did.
|
|
135
109
|
const rebuild = async (changedFile?: string) => {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
110
|
+
// A failed rebuild has already listed its errors, and the watch keeps
|
|
111
|
+
// running so the next save can fix them.
|
|
112
|
+
await runProjectBuild({
|
|
113
|
+
settings: this.settings,
|
|
114
|
+
configContext: this.configContext,
|
|
115
|
+
shellEnv: this.shellEnv,
|
|
116
|
+
heading: "Rebuilding",
|
|
117
|
+
options: {
|
|
118
|
+
...buildOptions,
|
|
119
|
+
// --rebuild means full clean build on every trigger; skip partial optimisation
|
|
120
|
+
changedFile: buildOptions.rebuild ? undefined : changedFile,
|
|
121
|
+
},
|
|
141
122
|
});
|
|
142
|
-
footer();
|
|
143
123
|
};
|
|
144
124
|
|
|
145
125
|
const { handle, triggerFullRebuild } = startConfigReloadWatch({
|
|
@@ -154,6 +134,11 @@ export default class Build extends BaseCommand {
|
|
|
154
134
|
// and a failure never breaks the watch; the last good answer stands.
|
|
155
135
|
const pollSeconds = resolveStoreSettings(this.settings).watchPollSeconds;
|
|
156
136
|
if (pollSeconds > 0) {
|
|
137
|
+
const repositories = subscriptionServiceFor({
|
|
138
|
+
configContext: this.configContext,
|
|
139
|
+
settings: this.settings,
|
|
140
|
+
shellEnv: this.shellEnv,
|
|
141
|
+
});
|
|
157
142
|
const poll = setInterval(() => {
|
|
158
143
|
void repositories
|
|
159
144
|
.checkUpstream()
|
package/src/commands/compile.ts
CHANGED
|
@@ -34,7 +34,7 @@ export default class Compile extends BaseCommand {
|
|
|
34
34
|
static flags = {
|
|
35
35
|
...BaseCommand.baseFlags,
|
|
36
36
|
strict: Flags.boolean({
|
|
37
|
-
description: "
|
|
37
|
+
description: "Treat compile warnings as errors",
|
|
38
38
|
default: false,
|
|
39
39
|
}),
|
|
40
40
|
rebuild: Flags.boolean({
|
|
@@ -113,7 +113,7 @@ export default class Compile extends BaseCommand {
|
|
|
113
113
|
|
|
114
114
|
footer();
|
|
115
115
|
|
|
116
|
-
if (!success &&
|
|
116
|
+
if (!success && !flags.watch) {
|
|
117
117
|
this.exit(1);
|
|
118
118
|
}
|
|
119
119
|
|
package/src/commands/init.ts
CHANGED
|
@@ -17,8 +17,7 @@ import path from "node:path";
|
|
|
17
17
|
import { Args, Flags } from "@oclif/core";
|
|
18
18
|
import { confirm } from "@inquirer/prompts";
|
|
19
19
|
import { BaseCommand } from "../base-command.js";
|
|
20
|
-
import {
|
|
21
|
-
import { buildProjectOutputs } from "../lib/build-service.js";
|
|
20
|
+
import { runProjectBuild } from "../lib/build-service.js";
|
|
22
21
|
import {
|
|
23
22
|
CONFIG_FILE_NAMES,
|
|
24
23
|
SOUS_DIR_NAME,
|
|
@@ -28,7 +27,6 @@ import {
|
|
|
28
27
|
} from "../lib/config-discovery.js";
|
|
29
28
|
import { ConfigError } from "../lib/errors.js";
|
|
30
29
|
import { nonInteractiveError } from "../lib/interactive.js";
|
|
31
|
-
import { subscriptionServiceFor } from "../lib/repos/subscription-service.js";
|
|
32
30
|
import {
|
|
33
31
|
PROJECT_CONFIG_FORMATS,
|
|
34
32
|
SOUS_PACKAGE_NAME,
|
|
@@ -45,7 +43,6 @@ import {
|
|
|
45
43
|
blankLine,
|
|
46
44
|
dryRunNotice,
|
|
47
45
|
footer,
|
|
48
|
-
heading,
|
|
49
46
|
log,
|
|
50
47
|
paragraph,
|
|
51
48
|
section,
|
|
@@ -262,8 +259,8 @@ export default class Init extends BaseCommand {
|
|
|
262
259
|
}
|
|
263
260
|
|
|
264
261
|
/**
|
|
265
|
-
* Adopts the config just written and builds the project with it,
|
|
266
|
-
* same
|
|
262
|
+
* Adopts the config just written and builds the project with it, through
|
|
263
|
+
* the same build `sous build` runs: the repositories are prepared (which
|
|
267
264
|
* seeds the core recipe into the store and pins it in the lockfile), then
|
|
268
265
|
* the outputs are compiled.
|
|
269
266
|
*
|
|
@@ -274,17 +271,12 @@ export default class Init extends BaseCommand {
|
|
|
274
271
|
resolveConfigFlag(configPath, this.configLocator.cwd, this.configLocator.confDirOverride)
|
|
275
272
|
);
|
|
276
273
|
|
|
277
|
-
await
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
);
|
|
284
|
-
|
|
285
|
-
heading("Building the project");
|
|
286
|
-
|
|
287
|
-
const succeeded = await buildProjectOutputs(this.settings, this.configContext);
|
|
274
|
+
const succeeded = await runProjectBuild({
|
|
275
|
+
settings: this.settings,
|
|
276
|
+
configContext: this.configContext,
|
|
277
|
+
shellEnv: this.shellEnv,
|
|
278
|
+
heading: "Building the project",
|
|
279
|
+
});
|
|
288
280
|
|
|
289
281
|
if (!succeeded) {
|
|
290
282
|
throw new ConfigError(
|
package/src/commands/launch.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { Args, Flags } from "@oclif/core";
|
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import { BaseCommand } from "../base-command.js";
|
|
5
|
-
import {
|
|
5
|
+
import { runProjectBuild } from "../lib/build-service.js";
|
|
6
|
+
import { ConfigError } from "../lib/errors.js";
|
|
6
7
|
import { resolveRootScope, resolveTools } from "../lib/settings.js";
|
|
7
8
|
import { displayError, footer, heading, showCommandVars } from "../utils/formatting.js";
|
|
8
9
|
|
|
@@ -85,16 +86,21 @@ export default class Launch extends BaseCommand {
|
|
|
85
86
|
...(passThroughArgs.length > 0 && { "Tool Args": passThroughArgs.join(" ") }),
|
|
86
87
|
});
|
|
87
88
|
|
|
88
|
-
const buildService = new BuildService();
|
|
89
|
-
|
|
90
89
|
do {
|
|
91
|
-
// Build step (unless --no-build)
|
|
90
|
+
// Build step (unless --no-build). A failed build stops here: the agent
|
|
91
|
+
// would otherwise start on outputs the build could not bring up to date.
|
|
92
92
|
if (!flags["no-build"]) {
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
const built = await runProjectBuild({
|
|
94
|
+
settings: this.settings,
|
|
95
95
|
configContext: this.configContext,
|
|
96
|
+
shellEnv: this.shellEnv,
|
|
96
97
|
});
|
|
97
|
-
|
|
98
|
+
if (!built) {
|
|
99
|
+
throw new ConfigError(
|
|
100
|
+
`The build failed, so ${args.tool} was not started. Fix the errors listed ` +
|
|
101
|
+
`above, or pass --no-build to start it with the outputs as they are.`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
98
104
|
}
|
|
99
105
|
|
|
100
106
|
// Config-defined args first, then pass-through, then promptFile content
|
package/src/commands/prune.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Flags } from "@oclif/core";
|
|
2
2
|
import { BaseCommand } from "../base-command.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { runProjectBuild } from "../lib/build-service.js";
|
|
4
|
+
import { showCommandVars } from "../utils/formatting.js";
|
|
5
5
|
|
|
6
6
|
export default class Prune extends BaseCommand {
|
|
7
7
|
static description = "Remove output files that are no longer in the current config";
|
|
@@ -22,19 +22,22 @@ export default class Prune extends BaseCommand {
|
|
|
22
22
|
async run(): Promise<void> {
|
|
23
23
|
const { flags } = await this.parse(Prune);
|
|
24
24
|
|
|
25
|
-
const stateFilePath = resolveStateFilePath(this.settings, this.configContext);
|
|
26
|
-
|
|
27
25
|
showCommandVars({
|
|
28
26
|
Project: this.projectLabel,
|
|
29
27
|
Config: this.configContext.configPath,
|
|
30
28
|
"Dry Run": flags["dry-run"],
|
|
31
29
|
});
|
|
32
30
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
31
|
+
// Prune goes through the same build path as everything else, with the
|
|
32
|
+
// compile step switched off: what counts as current depends on the recipes
|
|
33
|
+
// the lockfile pins, so they are restored first, or a recipe missing from
|
|
34
|
+
// the store would have its outputs pruned as if it were gone.
|
|
35
|
+
await runProjectBuild({
|
|
36
|
+
settings: this.settings,
|
|
37
|
+
configContext: this.configContext,
|
|
38
|
+
shellEnv: this.shellEnv,
|
|
39
|
+
heading: "Pruning",
|
|
40
|
+
options: { noCompile: true, dryRun: flags["dry-run"] },
|
|
41
|
+
});
|
|
39
42
|
}
|
|
40
43
|
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { Args, Flags } from "@oclif/core";
|
|
16
16
|
import { BaseCommand } from "../../base-command.js";
|
|
17
|
-
import {
|
|
17
|
+
import { runProjectBuild } from "../../lib/build-service.js";
|
|
18
18
|
import { ConfigError } from "../../lib/errors.js";
|
|
19
19
|
import { resolveRootScope } from "../../lib/settings.js";
|
|
20
20
|
import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js";
|
|
@@ -175,11 +175,12 @@ export default class RepoRemove extends BaseCommand {
|
|
|
175
175
|
private async rebuildProject(name: string): Promise<void> {
|
|
176
176
|
await this.reloadDiscoveredConfig();
|
|
177
177
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
178
|
+
const succeeded = await runProjectBuild({
|
|
179
|
+
settings: this.settings,
|
|
180
|
+
configContext: this.configContext,
|
|
181
|
+
shellEnv: this.shellEnv,
|
|
182
|
+
heading: "Building the project",
|
|
183
|
+
});
|
|
183
184
|
|
|
184
185
|
if (!succeeded) {
|
|
185
186
|
throw new ConfigError(
|
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { Args, Flags } from "@oclif/core";
|
|
4
4
|
import { BaseCommand } from "../../base-command.js";
|
|
5
|
-
import {
|
|
5
|
+
import { runProjectBuild } from "../../lib/build-service.js";
|
|
6
6
|
import { ConfigError, isConfigError } from "../../lib/errors.js";
|
|
7
7
|
import {
|
|
8
8
|
NonInteractiveError,
|
|
@@ -409,11 +409,12 @@ export default class RepoUnlink extends BaseCommand {
|
|
|
409
409
|
private async rebuildProject(name: string): Promise<void> {
|
|
410
410
|
await this.reloadDiscoveredConfig();
|
|
411
411
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
412
|
+
const succeeded = await runProjectBuild({
|
|
413
|
+
settings: this.settings,
|
|
414
|
+
configContext: this.configContext,
|
|
415
|
+
shellEnv: this.shellEnv,
|
|
416
|
+
heading: "Building the project",
|
|
417
|
+
});
|
|
417
418
|
|
|
418
419
|
if (!succeeded) {
|
|
419
420
|
throw new ConfigError(
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import { Args, Flags } from "@oclif/core";
|
|
26
26
|
import { BaseCommand } from "../../base-command.js";
|
|
27
|
-
import {
|
|
27
|
+
import { runProjectBuild } from "../../lib/build-service.js";
|
|
28
28
|
import { ConfigError } from "../../lib/errors.js";
|
|
29
29
|
import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js";
|
|
30
30
|
import { formatAskReport } from "../../lib/vars/ask.js";
|
|
@@ -267,11 +267,12 @@ export default class SubscriptionAdd extends BaseCommand {
|
|
|
267
267
|
private async rebuildProject(key: string): Promise<void> {
|
|
268
268
|
await this.reloadDiscoveredConfig();
|
|
269
269
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
270
|
+
const succeeded = await runProjectBuild({
|
|
271
|
+
settings: this.settings,
|
|
272
|
+
configContext: this.configContext,
|
|
273
|
+
shellEnv: this.shellEnv,
|
|
274
|
+
heading: "Building the project",
|
|
275
|
+
});
|
|
275
276
|
|
|
276
277
|
if (!succeeded) {
|
|
277
278
|
throw new ConfigError(
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { Args, Flags } from "@oclif/core";
|
|
14
14
|
import { BaseCommand } from "../../base-command.js";
|
|
15
|
-
import {
|
|
15
|
+
import { runProjectBuild } from "../../lib/build-service.js";
|
|
16
16
|
import { ConfigError } from "../../lib/errors.js";
|
|
17
17
|
import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js";
|
|
18
18
|
import { renderTable, type TableColumn } from "../../utils/table.js";
|
|
@@ -163,11 +163,12 @@ export default class SubscriptionRemove extends BaseCommand {
|
|
|
163
163
|
private async rebuildProject(key: string): Promise<void> {
|
|
164
164
|
await this.reloadDiscoveredConfig();
|
|
165
165
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
166
|
+
const succeeded = await runProjectBuild({
|
|
167
|
+
settings: this.settings,
|
|
168
|
+
configContext: this.configContext,
|
|
169
|
+
shellEnv: this.shellEnv,
|
|
170
|
+
heading: "Building the project",
|
|
171
|
+
});
|
|
171
172
|
|
|
172
173
|
if (!succeeded) {
|
|
173
174
|
throw new ConfigError(
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { Args, Flags } from "@oclif/core";
|
|
21
21
|
import { BaseCommand } from "../../base-command.js";
|
|
22
|
-
import {
|
|
22
|
+
import { runProjectBuild } from "../../lib/build-service.js";
|
|
23
23
|
import { ConfigError } from "../../lib/errors.js";
|
|
24
24
|
import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js";
|
|
25
25
|
import { describeUpdateScope } from "../../lib/repos/update-plan.js";
|
|
@@ -198,11 +198,12 @@ export default class SubscriptionUpdate extends BaseCommand {
|
|
|
198
198
|
private async rebuildProject(): Promise<void> {
|
|
199
199
|
await this.reloadDiscoveredConfig();
|
|
200
200
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
201
|
+
const succeeded = await runProjectBuild({
|
|
202
|
+
settings: this.settings,
|
|
203
|
+
configContext: this.configContext,
|
|
204
|
+
shellEnv: this.shellEnv,
|
|
205
|
+
heading: "Building the project",
|
|
206
|
+
});
|
|
206
207
|
|
|
207
208
|
if (!succeeded) {
|
|
208
209
|
throw new ConfigError(
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The step that runs before a project's outputs are compiled, and what it says
|
|
3
|
-
* while it runs. `
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* either command.
|
|
3
|
+
* while it runs. `runProjectBuild` (`build-service.ts`) calls it, and every
|
|
4
|
+
* command that builds goes through that one function, so the step and its
|
|
5
|
+
* reporting are the same whichever command the user typed.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
import type { SubscriptionService } from "./repos/subscription-service.js";
|
package/src/lib/build-service.ts
CHANGED
|
@@ -10,14 +10,16 @@ import { CompilationService, resolveOutputPath } from "./markdown-compiler.js";
|
|
|
10
10
|
import type { CompilationConfig, CompilationTarget } from "./markdown-compiler.js";
|
|
11
11
|
import { StateService } from "./state.js";
|
|
12
12
|
import { isProtectedPath } from "./state.js";
|
|
13
|
-
import { protectedRepoPaths } from "./repos/links.js";
|
|
13
|
+
import { describeLinkedRepos, protectedRepoPaths } from "./repos/links.js";
|
|
14
|
+
import { subscriptionServiceFor } from "./repos/subscription-service.js";
|
|
15
|
+
import { prepareRepositoriesForBuild } from "./build-preparation.js";
|
|
14
16
|
import {
|
|
15
17
|
noRecipeAnswers,
|
|
16
18
|
resolveRecipeAnswers,
|
|
17
19
|
unansweredWarning,
|
|
18
20
|
type RecipeAnswers,
|
|
19
21
|
} from "./vars/answers.js";
|
|
20
|
-
import { log, warning } from "../utils/formatting.js";
|
|
22
|
+
import { footer, heading, log, warning } from "../utils/formatting.js";
|
|
21
23
|
|
|
22
24
|
export type BuildOptions = {
|
|
23
25
|
strict?: boolean;
|
|
@@ -437,22 +439,64 @@ export class BuildService {
|
|
|
437
439
|
}
|
|
438
440
|
}
|
|
439
441
|
|
|
442
|
+
/** What {@link runProjectBuild} is given. */
|
|
443
|
+
export type ProjectBuildInput = {
|
|
444
|
+
/** The merged project config, reloaded after any change a command made. */
|
|
445
|
+
settings: Settings;
|
|
446
|
+
/** Where the active config was discovered. */
|
|
447
|
+
configContext: ConfigContext;
|
|
448
|
+
/** The shell environment as it was before the env files were injected. */
|
|
449
|
+
shellEnv?: NodeJS.ProcessEnv;
|
|
450
|
+
/** The build's own options; every one defaults to what a plain `sous build` uses. */
|
|
451
|
+
options?: Omit<BuildOptions, "configContext">;
|
|
452
|
+
/** The heading printed above the compile and prune step. Defaults to "Building". */
|
|
453
|
+
heading?: string;
|
|
454
|
+
};
|
|
455
|
+
|
|
440
456
|
/**
|
|
441
|
-
*
|
|
457
|
+
* The one build every command runs: `build`, `init`, `launch`, `prune`,
|
|
458
|
+
* `repo remove`, `repo unlink`, `subscription add`, `subscription remove` and
|
|
459
|
+
* `subscription update` all come through here, so a project is built the same
|
|
460
|
+
* way whichever of them the user typed.
|
|
442
461
|
*
|
|
443
|
-
*
|
|
444
|
-
*
|
|
445
|
-
*
|
|
446
|
-
*
|
|
447
|
-
*
|
|
462
|
+
* In order, it announces every linked repository, prepares the project's
|
|
463
|
+
* recipes (seeds the core recipe, locks new subscriptions, restores whatever
|
|
464
|
+
* the store is missing, checks upstream and reports newer versions), and then
|
|
465
|
+
* compiles and prunes. A dry run prepares nothing, because preparing writes the
|
|
466
|
+
* lockfile and the store. A partial rebuild in watch mode (one changed source
|
|
467
|
+
* file) prepares nothing either: the config did not change, so neither did the
|
|
468
|
+
* recipes it needs.
|
|
448
469
|
*
|
|
449
|
-
* @param
|
|
450
|
-
* @
|
|
451
|
-
* @returns True when compile and prune both succeeded.
|
|
470
|
+
* @param input - The project, the build options and the heading.
|
|
471
|
+
* @returns True when compile and prune both succeeded; false after any compile error.
|
|
452
472
|
*/
|
|
453
|
-
export async function
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
473
|
+
export async function runProjectBuild(input: ProjectBuildInput): Promise<boolean> {
|
|
474
|
+
const options = input.options ?? {};
|
|
475
|
+
const partial = options.changedFile !== undefined;
|
|
476
|
+
|
|
477
|
+
// A linked repository is read from a working copy instead of a published
|
|
478
|
+
// version, so it is announced on every build; a build that silently produced
|
|
479
|
+
// something different would be far worse than a noisy one.
|
|
480
|
+
if (!partial) {
|
|
481
|
+
const linked = describeLinkedRepos(input.configContext.sousDir);
|
|
482
|
+
if (linked.length > 0) warning(linked.join("\n"));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (!options.dryRun && !partial) {
|
|
486
|
+
await prepareRepositoriesForBuild(
|
|
487
|
+
subscriptionServiceFor({
|
|
488
|
+
configContext: input.configContext,
|
|
489
|
+
settings: input.settings,
|
|
490
|
+
...(input.shellEnv === undefined ? {} : { shellEnv: input.shellEnv }),
|
|
491
|
+
})
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
heading(input.heading ?? "Building");
|
|
496
|
+
const succeeded = await new BuildService().build(input.settings, {
|
|
497
|
+
...options,
|
|
498
|
+
configContext: input.configContext,
|
|
499
|
+
});
|
|
500
|
+
footer();
|
|
501
|
+
return succeeded;
|
|
458
502
|
}
|
|
@@ -63,6 +63,11 @@ export type CompilationConfig = {
|
|
|
63
63
|
};
|
|
64
64
|
|
|
65
65
|
export type CompilationServiceOptions = {
|
|
66
|
+
/**
|
|
67
|
+
* Treat compile warnings as errors. An error always fails the compile; this
|
|
68
|
+
* only decides whether a warning (a `.tpl.` file copied without being
|
|
69
|
+
* rendered, a git branch that could not be read) does too.
|
|
70
|
+
*/
|
|
66
71
|
strict?: boolean;
|
|
67
72
|
rebuild?: boolean;
|
|
68
73
|
dryRun?: boolean;
|
|
@@ -184,14 +189,26 @@ export class CompilationService {
|
|
|
184
189
|
this.encoder = get_encoding("o200k_base");
|
|
185
190
|
}
|
|
186
191
|
|
|
187
|
-
/**
|
|
192
|
+
/**
|
|
193
|
+
* Records a compile error and prints it. The compile carries on, so every
|
|
194
|
+
* target is compiled and every error is listed; the output the error belongs
|
|
195
|
+
* to is not written, and the compile as a whole reports failure.
|
|
196
|
+
*/
|
|
188
197
|
private handleError(message: string): void {
|
|
189
198
|
this.errors.push(message);
|
|
190
199
|
displayError(message);
|
|
200
|
+
}
|
|
191
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Records a compile warning: printed as a warning, or, in strict mode,
|
|
204
|
+
* recorded as an error, with everything an error brings.
|
|
205
|
+
*/
|
|
206
|
+
private handleWarning(message: string): void {
|
|
192
207
|
if (this.strict) {
|
|
193
|
-
|
|
208
|
+
this.handleError(message);
|
|
209
|
+
return;
|
|
194
210
|
}
|
|
211
|
+
warning(message);
|
|
195
212
|
}
|
|
196
213
|
|
|
197
214
|
/**
|
|
@@ -383,7 +400,10 @@ export class CompilationService {
|
|
|
383
400
|
).trim();
|
|
384
401
|
} catch (error) {
|
|
385
402
|
const message = error instanceof Error ? error.message : String(error);
|
|
386
|
-
this.
|
|
403
|
+
this.handleWarning(
|
|
404
|
+
`Sous could not read the current git branch, so the runtime context says ` +
|
|
405
|
+
`"unknown": ${message}`
|
|
406
|
+
);
|
|
387
407
|
}
|
|
388
408
|
|
|
389
409
|
const runtimeHeader = `## Runtime Session Context
|
|
@@ -427,6 +447,21 @@ ${taskFileContents}
|
|
|
427
447
|
fs.writeFileSync(ctx.outputPath, `${runtimeHeader}${taskFileBlock}\n`, "utf8");
|
|
428
448
|
}
|
|
429
449
|
|
|
450
|
+
/**
|
|
451
|
+
* Says that an output was not written because its target had an error, and
|
|
452
|
+
* what that leaves on disk.
|
|
453
|
+
*
|
|
454
|
+
* @param destFile - The output that was not written.
|
|
455
|
+
*/
|
|
456
|
+
private reportNotWritten(destFile: string): void {
|
|
457
|
+
const verb = this.dryRun ? "would not be written" : "not written";
|
|
458
|
+
log(
|
|
459
|
+
fs.existsSync(destFile)
|
|
460
|
+
? ` ✗ ${destFile} (${verb} because of an error; the previous copy stays)`
|
|
461
|
+
: ` ✗ ${destFile} (${verb} because of an error)`
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
430
465
|
/** Compile a single target, writing to all of its outputs. */
|
|
431
466
|
private async compileTarget(
|
|
432
467
|
target: CompilationTarget,
|
|
@@ -445,6 +480,8 @@ ${taskFileContents}
|
|
|
445
480
|
? target.includeSourceComments
|
|
446
481
|
: this.includeSourceComments;
|
|
447
482
|
|
|
483
|
+
const errorsBefore = this.errors.length;
|
|
484
|
+
|
|
448
485
|
if (target.runtimeContext) {
|
|
449
486
|
this.generateRuntimeSessionContext(target);
|
|
450
487
|
}
|
|
@@ -457,6 +494,11 @@ ${taskFileContents}
|
|
|
457
494
|
return false;
|
|
458
495
|
}
|
|
459
496
|
|
|
497
|
+
// An include that failed left a hole in the assembled content (and in strict
|
|
498
|
+
// mode, a warning counts as an error), so none of this target's outputs may
|
|
499
|
+
// be written: the previous copy stays in place.
|
|
500
|
+
const assembled = this.errors.length === errorsBefore;
|
|
501
|
+
|
|
460
502
|
// Compute source hash once per target from the assembled content
|
|
461
503
|
const contentHash = hashContent(content);
|
|
462
504
|
|
|
@@ -473,6 +515,12 @@ ${taskFileContents}
|
|
|
473
515
|
if (resolvedDest === undefined) continue;
|
|
474
516
|
destFile = resolvedDest;
|
|
475
517
|
|
|
518
|
+
if (!assembled) {
|
|
519
|
+
this.reportNotWritten(destFile);
|
|
520
|
+
allSucceeded = false;
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
|
|
476
524
|
// A rendered output depends on its variables as much as on its source: a
|
|
477
525
|
// changed answer or `_vars` value with the same template must re-render,
|
|
478
526
|
// so the variable scope is part of a `.tpl.` output's source hash. A
|
|
@@ -508,8 +556,15 @@ ${taskFileContents}
|
|
|
508
556
|
// directly, and it stays as a tripwire in case the resolver ever changes.
|
|
509
557
|
if (isTpl && !output.vars) {
|
|
510
558
|
this.unrenderedTemplates.push(`${target.rootInputPath} → ${destFile}`);
|
|
559
|
+
// In strict mode this warning is an error, so the output is not written.
|
|
560
|
+
if (this.strict) {
|
|
561
|
+
this.reportNotWritten(destFile);
|
|
562
|
+
allSucceeded = false;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
511
565
|
}
|
|
512
566
|
|
|
567
|
+
const errorsBeforeRender = this.errors.length;
|
|
513
568
|
const resolvedContent = (isTpl && output.vars)
|
|
514
569
|
? await this.renderContent(
|
|
515
570
|
content,
|
|
@@ -522,6 +577,14 @@ ${taskFileContents}
|
|
|
522
577
|
target.rootInputPath
|
|
523
578
|
)
|
|
524
579
|
: content;
|
|
580
|
+
|
|
581
|
+
// A template that failed to render is not written either.
|
|
582
|
+
if (this.errors.length > errorsBeforeRender) {
|
|
583
|
+
this.reportNotWritten(destFile);
|
|
584
|
+
allSucceeded = false;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
|
|
525
588
|
const fileContent = resolvedContent;
|
|
526
589
|
const outputDir = path.dirname(destFile);
|
|
527
590
|
|
|
@@ -582,8 +645,11 @@ ${taskFileContents}
|
|
|
582
645
|
}
|
|
583
646
|
|
|
584
647
|
/**
|
|
585
|
-
* Compile all targets from the given config.
|
|
586
|
-
*
|
|
648
|
+
* Compile all targets from the given config. Every target is compiled even
|
|
649
|
+
* after an error, so every error is listed; an output whose target had an
|
|
650
|
+
* error is not written and keeps its previous copy and its state entry.
|
|
651
|
+
*
|
|
652
|
+
* @returns True when nothing reported an error; false after any error.
|
|
587
653
|
*/
|
|
588
654
|
async compile(config: CompilationConfig, stateFilePath?: string): Promise<boolean> {
|
|
589
655
|
const stateService = new StateService();
|
|
@@ -632,15 +698,7 @@ ${taskFileContents}
|
|
|
632
698
|
`\nFix: add a '_vars' block to the output (an empty '_vars: {}' is enough to\n` +
|
|
633
699
|
`enable rendering), or rename the source so it does not contain '.tpl.'.`;
|
|
634
700
|
|
|
635
|
-
|
|
636
|
-
// Recorded as a failure rather than exiting here, so state still gets
|
|
637
|
-
// written and the caller decides the exit code.
|
|
638
|
-
this.errors.push(message);
|
|
639
|
-
displayError(message);
|
|
640
|
-
allSucceeded = false;
|
|
641
|
-
} else {
|
|
642
|
-
warning(message);
|
|
643
|
-
}
|
|
701
|
+
this.handleWarning(message);
|
|
644
702
|
}
|
|
645
703
|
|
|
646
704
|
if (this.errors.length > 0) {
|
|
@@ -665,7 +723,9 @@ ${taskFileContents}
|
|
|
665
723
|
await stateService.save(stateFilePath, state);
|
|
666
724
|
}
|
|
667
725
|
|
|
668
|
-
|
|
726
|
+
// Any error fails the compile, including one that belongs to no single
|
|
727
|
+
// target (a bad config option).
|
|
728
|
+
return allSucceeded && this.errors.length === 0;
|
|
669
729
|
} finally {
|
|
670
730
|
if (this.encoder) {
|
|
671
731
|
this.encoder.free();
|
|
@@ -12,25 +12,37 @@
|
|
|
12
12
|
*
|
|
13
13
|
* - a file inside a recipe may address only that recipe's own declared
|
|
14
14
|
* dependencies (`depends` plus `subscribes`), at their pinned versions;
|
|
15
|
-
* - a file in the project's own templates may address the
|
|
16
|
-
*
|
|
15
|
+
* - a file in the project's own templates may address every recipe the
|
|
16
|
+
* lockfile pins, whatever holds it: the project, a set it subscribes to,
|
|
17
|
+
* or another recipe's `depends`.
|
|
18
|
+
*
|
|
19
|
+
* When a project template names a recipe the lockfile does not pin, the error
|
|
20
|
+
* says which recipe used to bring it in, when the cached indexes or the config
|
|
21
|
+
* can tell.
|
|
17
22
|
*
|
|
18
23
|
* A project with no lockfile entries gets no resolver at all, so a project that
|
|
19
24
|
* uses no repositories behaves exactly as it did before: `~` in an include line
|
|
20
25
|
* means an alias and nothing else.
|
|
21
26
|
*/
|
|
22
27
|
|
|
28
|
+
import semver from "semver";
|
|
23
29
|
import type { Settings } from "../settings.js";
|
|
30
|
+
import { resolveStoreRoot } from "../sous-home.js";
|
|
24
31
|
import {
|
|
25
32
|
StaticNamespaceResolver,
|
|
33
|
+
normalizeRef,
|
|
34
|
+
type DroppedRecipe,
|
|
26
35
|
type NamespaceResolver,
|
|
27
36
|
} from "./namespace-resolver.js";
|
|
28
37
|
import {
|
|
29
38
|
listLockedRecipes,
|
|
30
|
-
|
|
39
|
+
readProjectLockfile,
|
|
31
40
|
readRecipeManifestIn,
|
|
32
41
|
type LockedRecipeLocation,
|
|
33
42
|
} from "./locked-recipes.js";
|
|
43
|
+
import { createIndexCache } from "./providers/index.js";
|
|
44
|
+
import type { IndexFile } from "./formats/index-file.js";
|
|
45
|
+
import type { Lockfile } from "./formats/lockfile.js";
|
|
34
46
|
|
|
35
47
|
/** How the project's namespace resolver is built. */
|
|
36
48
|
export type ProjectNamespaceResolverOptions = {
|
|
@@ -82,9 +94,125 @@ export function createProjectNamespaceResolver(
|
|
|
82
94
|
if (declared.length > 0) dependencies[recipe.key] = declared;
|
|
83
95
|
}
|
|
84
96
|
|
|
97
|
+
// No `projectScope`: a project template may address every recipe the
|
|
98
|
+
// lockfile pins, and those are exactly the recipes this resolver knows.
|
|
85
99
|
return new StaticNamespaceResolver({
|
|
86
100
|
recipes,
|
|
87
101
|
dependencies,
|
|
88
|
-
|
|
102
|
+
explainMissing: (recipe) => explainUnpinnedRecipe(recipe, options),
|
|
89
103
|
});
|
|
90
104
|
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Why the lockfile does not pin a recipe a project template asked for, when
|
|
108
|
+
* that can be known without the network. Two things are checked, in order:
|
|
109
|
+
*
|
|
110
|
+
* - a subscription to it (or to its namespace) that is switched off;
|
|
111
|
+
* - another published version of a pinned recipe that brought it in, read
|
|
112
|
+
* from the cached indexes of the lockfile's repositories. That version may
|
|
113
|
+
* declare it directly, or declare a recipe that no longer is pinned either
|
|
114
|
+
* and that declares it in turn; the shortest such chain is reported, and
|
|
115
|
+
* among the versions of one pinned recipe the newest wins. This is the
|
|
116
|
+
* usual story of a set that dropped one of its members, and the members'
|
|
117
|
+
* own libraries with it.
|
|
118
|
+
*
|
|
119
|
+
* Only ever runs while an error is being reported, so it reads the lockfile
|
|
120
|
+
* and the cached indexes then rather than on every build.
|
|
121
|
+
*
|
|
122
|
+
* @param recipe - The `namespace/recipe` the template asked for.
|
|
123
|
+
* @param options - The project's `.sous/` directory, its config and the environment.
|
|
124
|
+
*/
|
|
125
|
+
export function explainUnpinnedRecipe(
|
|
126
|
+
recipe: string,
|
|
127
|
+
options: Pick<ProjectNamespaceResolverOptions, "sousDir" | "settings" | "env">
|
|
128
|
+
): DroppedRecipe | undefined {
|
|
129
|
+
const namespace = recipe.slice(0, recipe.indexOf("/"));
|
|
130
|
+
for (const [key, entry] of Object.entries(options.settings.subscriptions ?? {})) {
|
|
131
|
+
if (entry?.enabled !== false) continue;
|
|
132
|
+
const ref = normalizeRef(key);
|
|
133
|
+
if (ref === recipe || ref === namespace) {
|
|
134
|
+
return { by: "disabled-subscription", subscription: key };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let lock: Lockfile;
|
|
139
|
+
try {
|
|
140
|
+
lock = readProjectLockfile(options.sousDir);
|
|
141
|
+
} catch {
|
|
142
|
+
// The build reports a broken lockfile on its own; an error message is no
|
|
143
|
+
// place to report it a second time.
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const published = publishedVersions(lock, options.env ?? process.env);
|
|
148
|
+
|
|
149
|
+
/** What one published version of a recipe declares, as recipe keys. */
|
|
150
|
+
const declaredBy = (key: string, version: string): string[] =>
|
|
151
|
+
Object.keys(published.get(key)?.[version]?.dependencies ?? {});
|
|
152
|
+
|
|
153
|
+
// Breadth first from every other version of every pinned recipe, through
|
|
154
|
+
// recipes the lockfile does not pin, so the shortest chain is found first.
|
|
155
|
+
type Step = { key: string; root: { recipe: string; version: string }; through: string[] };
|
|
156
|
+
const queue: Step[] = [];
|
|
157
|
+
for (const holder of Object.keys(lock.recipes).sort()) {
|
|
158
|
+
const pinned = lock.recipes[holder]!.version;
|
|
159
|
+
const versions = Object.keys(published.get(holder) ?? {})
|
|
160
|
+
.filter((version) => version !== pinned && semver.valid(version) !== null)
|
|
161
|
+
.sort(semver.rcompare);
|
|
162
|
+
for (const version of versions) {
|
|
163
|
+
for (const key of declaredBy(holder, version)) {
|
|
164
|
+
queue.push({ key, root: { recipe: holder, version }, through: [] });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const seen = new Set<string>();
|
|
170
|
+
while (queue.length > 0) {
|
|
171
|
+
const step = queue.shift()!;
|
|
172
|
+
if (step.key === recipe) {
|
|
173
|
+
const root = step.root;
|
|
174
|
+
return {
|
|
175
|
+
by: "recipe",
|
|
176
|
+
recipe: root.recipe,
|
|
177
|
+
declaredAt: root.version,
|
|
178
|
+
pinned: lock.recipes[root.recipe]!.version,
|
|
179
|
+
...(step.through.length > 0 ? { through: step.through } : {}),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
// A pinned recipe is explained by its own entry, and each recipe is
|
|
183
|
+
// walked once.
|
|
184
|
+
if (Object.hasOwn(lock.recipes, step.key) || seen.has(step.key)) continue;
|
|
185
|
+
seen.add(step.key);
|
|
186
|
+
for (const version of Object.keys(published.get(step.key) ?? {})) {
|
|
187
|
+
for (const key of declaredBy(step.key, version)) {
|
|
188
|
+
queue.push({ key, root: step.root, through: [...step.through, step.key] });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Every published version of every recipe in the cached indexes of the
|
|
198
|
+
* repositories the lockfile names, keyed by recipe. A repository with no
|
|
199
|
+
* cached index contributes nothing.
|
|
200
|
+
*
|
|
201
|
+
* @param lock - The project's lockfile.
|
|
202
|
+
* @param env - The environment, which decides where the store is.
|
|
203
|
+
*/
|
|
204
|
+
function publishedVersions(
|
|
205
|
+
lock: Lockfile,
|
|
206
|
+
env: NodeJS.ProcessEnv
|
|
207
|
+
): Map<string, IndexFile["recipes"][string]["versions"]> {
|
|
208
|
+
const cache = createIndexCache({ storeRoot: resolveStoreRoot(env) });
|
|
209
|
+
const found = new Map<string, IndexFile["recipes"][string]["versions"]>();
|
|
210
|
+
const identities = new Set(Object.values(lock.repos).map((repo) => repo.identity));
|
|
211
|
+
for (const identity of [...identities].sort()) {
|
|
212
|
+
const index = cache.readCached(identity);
|
|
213
|
+
for (const [key, entry] of Object.entries(index?.recipes ?? {})) {
|
|
214
|
+
if (!found.has(key)) found.set(key, entry.versions);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return found;
|
|
218
|
+
}
|
|
@@ -20,7 +20,6 @@
|
|
|
20
20
|
|
|
21
21
|
import fs from "node:fs";
|
|
22
22
|
import path from "node:path";
|
|
23
|
-
import type { Settings } from "../settings.js";
|
|
24
23
|
import { resolveStoreRoot } from "../sous-home.js";
|
|
25
24
|
import { parseLockfile, type LockKind, type Lockfile } from "./formats/lockfile.js";
|
|
26
25
|
import { parseRecipeManifest, type RecipeManifest } from "./formats/recipe-manifest.js";
|
|
@@ -34,7 +33,6 @@ import {
|
|
|
34
33
|
} from "./load-manifest.js";
|
|
35
34
|
import { readEffectiveLinks } from "./links.js";
|
|
36
35
|
import { identitySegments } from "./identity.js";
|
|
37
|
-
import { enabledSubscriptions } from "./defaults.js";
|
|
38
36
|
import { PROJECT_HOLDER } from "./formats/lockfile.js";
|
|
39
37
|
|
|
40
38
|
/** One locked recipe, together with the directory its files are read from. */
|
|
@@ -233,26 +231,6 @@ export function keysHeldBySubscription(lock: Lockfile, key: string): string[] {
|
|
|
233
231
|
.sort();
|
|
234
232
|
}
|
|
235
233
|
|
|
236
|
-
/**
|
|
237
|
-
* The refs a project's own templates may address: everything it subscribed to
|
|
238
|
-
* directly. Both sources are read, because a subscription may be written by
|
|
239
|
-
* `sous subscribe` into the managed layer, hand-written in the primary config,
|
|
240
|
-
* or (for an older project) recorded only in the lockfile.
|
|
241
|
-
*
|
|
242
|
-
* @param settings - The merged project config.
|
|
243
|
-
* @param locked - The locked recipes, as returned by listLockedRecipes.
|
|
244
|
-
*/
|
|
245
|
-
export function projectSubscriptionRefs(
|
|
246
|
-
settings: Settings,
|
|
247
|
-
locked: LockedRecipeLocation[]
|
|
248
|
-
): string[] {
|
|
249
|
-
const refs = new Set<string>(Object.keys(enabledSubscriptions(settings)));
|
|
250
|
-
for (const recipe of locked) {
|
|
251
|
-
if (recipe.requestedBy.includes(PROJECT_HOLDER)) refs.add(recipe.key);
|
|
252
|
-
}
|
|
253
|
-
return [...refs].sort();
|
|
254
|
-
}
|
|
255
|
-
|
|
256
234
|
/**
|
|
257
235
|
* Every locked recipe reachable from a set of keys through the lockfile's
|
|
258
236
|
* holder lists: the keys themselves, whatever they hold, and so on.
|
|
@@ -26,10 +26,31 @@ import path from "node:path";
|
|
|
26
26
|
* the including file:
|
|
27
27
|
* - a file that lives inside a recipe may address only that recipe's declared
|
|
28
28
|
* dependencies (`depends` plus `subscribes`) at their pinned versions;
|
|
29
|
-
* - a file in the project's own templates may address the
|
|
30
|
-
*
|
|
29
|
+
* - a file in the project's own templates may address every recipe the
|
|
30
|
+
* project's lockfile pins, whatever holds it.
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Why a recipe a project template asked for is not pinned any more, when that
|
|
35
|
+
* can be known. Only ever attached to an answer for a project template.
|
|
36
|
+
*/
|
|
37
|
+
export type DroppedRecipe =
|
|
38
|
+
/**
|
|
39
|
+
* Another version of a pinned recipe declares it (in `depends` or
|
|
40
|
+
* `subscribes`), and the version the lockfile pins does not: the usual story
|
|
41
|
+
* of a set that dropped one of its members.
|
|
42
|
+
*/
|
|
43
|
+
| {
|
|
44
|
+
by: "recipe";
|
|
45
|
+
recipe: string;
|
|
46
|
+
declaredAt: string;
|
|
47
|
+
pinned: string;
|
|
48
|
+
/** The recipes, no longer pinned either, that the chain ran through, in order. */
|
|
49
|
+
through?: string[];
|
|
50
|
+
}
|
|
51
|
+
/** The project subscribes to it, but the subscription is switched off. */
|
|
52
|
+
| { by: "disabled-subscription"; subscription: string };
|
|
53
|
+
|
|
33
54
|
/** A single `~namespace/rest` resolution request. */
|
|
34
55
|
export type NamespaceRequest = {
|
|
35
56
|
/** The namespace name, with the leading `~` already stripped (e.g. `workflow`). */
|
|
@@ -59,20 +80,24 @@ export type NamespaceRequest = {
|
|
|
59
80
|
export type NamespaceResolution =
|
|
60
81
|
/** Ordered absolute paths to try, most preferred first. An empty list means the lookup produced nothing. */
|
|
61
82
|
| { kind: "candidates"; candidates: string[] }
|
|
62
|
-
/**
|
|
63
|
-
|
|
83
|
+
/**
|
|
84
|
+
* No such namespace is known at all. `known` lists the namespaces that are.
|
|
85
|
+
* `dropped` says why a project template's recipe is no longer pinned, when
|
|
86
|
+
* that can be known.
|
|
87
|
+
*/
|
|
88
|
+
| { kind: "unknown-namespace"; known: string[]; dropped?: DroppedRecipe }
|
|
64
89
|
/**
|
|
65
90
|
* The namespace exists but holds no such recipe. `recipe` is the fully
|
|
66
91
|
* qualified ref that was asked for; `known` lists the recipe refs the
|
|
67
|
-
* namespace does hold.
|
|
92
|
+
* namespace does hold; `dropped` is as above.
|
|
68
93
|
*/
|
|
69
|
-
| { kind: "unknown-recipe"; recipe: string; known: string[] }
|
|
94
|
+
| { kind: "unknown-recipe"; recipe: string; known: string[]; dropped?: DroppedRecipe }
|
|
70
95
|
/**
|
|
71
96
|
* The recipe exists but the including file is not allowed to address it.
|
|
72
97
|
* `recipe` is the fully qualified ref that was asked for. `includingRecipe`
|
|
73
98
|
* is the ref of the recipe the including file belongs to, or `null` when the
|
|
74
|
-
* including file is one of the project's own templates (
|
|
75
|
-
*
|
|
99
|
+
* including file is one of the project's own templates (which only happens
|
|
100
|
+
* when the resolver was given a narrower `projectScope`).
|
|
76
101
|
*/
|
|
77
102
|
| { kind: "not-a-dependency"; recipe: string; includingRecipe: string | null }
|
|
78
103
|
/**
|
|
@@ -126,6 +151,7 @@ export function formatNamespaceProblem(opts: {
|
|
|
126
151
|
? `Available namespaces: ${resolution.known.join(", ")}.`
|
|
127
152
|
: "This project has no recipe namespaces available yet."
|
|
128
153
|
);
|
|
154
|
+
if (resolution.dropped) lines.push(...describeDropped(resolution.dropped, opts));
|
|
129
155
|
} else if (resolution.kind === "unknown-recipe") {
|
|
130
156
|
lines.push(`recipe: ${resolution.recipe}`);
|
|
131
157
|
lines.push(`The namespace "${opts.namespace}" holds no recipe named "${resolution.recipe}".`);
|
|
@@ -134,6 +160,7 @@ export function formatNamespaceProblem(opts: {
|
|
|
134
160
|
? `Recipes in this namespace: ${resolution.known.join(", ")}.`
|
|
135
161
|
: `The namespace "${opts.namespace}" currently holds no recipes.`
|
|
136
162
|
);
|
|
163
|
+
if (resolution.dropped) lines.push(...describeDropped(resolution.dropped, opts));
|
|
137
164
|
} else if (resolution.kind === "not-a-dependency") {
|
|
138
165
|
lines.push(`recipe: ${resolution.recipe}`);
|
|
139
166
|
if (resolution.includingRecipe) {
|
|
@@ -144,10 +171,7 @@ export function formatNamespaceProblem(opts: {
|
|
|
144
171
|
`Add "${resolution.recipe}" to the "depends" list in that recipe's manifest before addressing it as "~${opts.namespace}".`
|
|
145
172
|
);
|
|
146
173
|
} else {
|
|
147
|
-
lines.push(`
|
|
148
|
-
lines.push(
|
|
149
|
-
`Subscribe to it before addressing it as "~${opts.namespace}" from a project template.`
|
|
150
|
-
);
|
|
174
|
+
lines.push(`The project's own templates may not address "${resolution.recipe}".`);
|
|
151
175
|
}
|
|
152
176
|
} else if (resolution.kind === "escapes-recipe") {
|
|
153
177
|
lines.push(`recipe: ${resolution.recipe}`);
|
|
@@ -167,6 +191,37 @@ export function formatNamespaceProblem(opts: {
|
|
|
167
191
|
return lines.map((line) => ` ${line}`).join("\n");
|
|
168
192
|
}
|
|
169
193
|
|
|
194
|
+
/**
|
|
195
|
+
* The lines that say why a recipe a project template asked for is no longer
|
|
196
|
+
* pinned, and what brings it back.
|
|
197
|
+
*
|
|
198
|
+
* @param dropped - What is known about how the recipe used to be pinned.
|
|
199
|
+
* @param opts - The namespace and the rest of the reference, for the recipe's name.
|
|
200
|
+
*/
|
|
201
|
+
function describeDropped(
|
|
202
|
+
dropped: DroppedRecipe,
|
|
203
|
+
opts: { namespace: string; rest: string }
|
|
204
|
+
): string[] {
|
|
205
|
+
const recipe = `${opts.namespace}/${opts.rest.split("/")[0] ?? ""}`;
|
|
206
|
+
if (dropped.by === "disabled-subscription") {
|
|
207
|
+
return [
|
|
208
|
+
`This project subscribes to "${dropped.subscription}", but that subscription is ` +
|
|
209
|
+
`switched off ("enabled: false"), so the lockfile does not pin "${recipe}".`,
|
|
210
|
+
`Switch the subscription back on, or remove the include.`,
|
|
211
|
+
];
|
|
212
|
+
}
|
|
213
|
+
const through =
|
|
214
|
+
dropped.through === undefined || dropped.through.length === 0
|
|
215
|
+
? ""
|
|
216
|
+
: ` (through ${dropped.through.map((key) => `"${key}"`).join(", ")})`;
|
|
217
|
+
return [
|
|
218
|
+
`Version ${dropped.declaredAt} of "${dropped.recipe}" brought "${recipe}" in${through}, but ` +
|
|
219
|
+
`the version this project pins, ${dropped.pinned}, does not, so the lockfile no longer ` +
|
|
220
|
+
`pins it.`,
|
|
221
|
+
`Subscribe to "${recipe}" directly to keep including it, or remove the include.`,
|
|
222
|
+
];
|
|
223
|
+
}
|
|
224
|
+
|
|
170
225
|
/** Options for {@link StaticNamespaceResolver}. */
|
|
171
226
|
export type StaticNamespaceResolverOptions = {
|
|
172
227
|
/**
|
|
@@ -186,9 +241,16 @@ export type StaticNamespaceResolverOptions = {
|
|
|
186
241
|
/**
|
|
187
242
|
* What the project's own templates may address, in the same ref forms as
|
|
188
243
|
* `dependencies`. Omit it to make every known recipe addressable from
|
|
189
|
-
* project templates
|
|
244
|
+
* project templates, which is the rule for a real project: its templates may
|
|
245
|
+
* address everything its lockfile pins.
|
|
190
246
|
*/
|
|
191
247
|
projectScope?: string[];
|
|
248
|
+
/**
|
|
249
|
+
* Says why a recipe a project template asked for is not among `recipes`,
|
|
250
|
+
* when that can be known. Consulted only for a project template's reference
|
|
251
|
+
* to a recipe the resolver does not know.
|
|
252
|
+
*/
|
|
253
|
+
explainMissing?: (recipe: string) => DroppedRecipe | undefined;
|
|
192
254
|
};
|
|
193
255
|
|
|
194
256
|
/**
|
|
@@ -196,7 +258,7 @@ export type StaticNamespaceResolverOptions = {
|
|
|
196
258
|
* recipe refs to directories.
|
|
197
259
|
*
|
|
198
260
|
* It implements the full scoping rule (recipe files see their declared
|
|
199
|
-
* dependencies; project files see
|
|
261
|
+
* dependencies; project files see every known recipe, or `projectScope`) without knowing
|
|
200
262
|
* anything about repositories, versions or the store, which makes it the
|
|
201
263
|
* resolver used by tests and a usable core for the real implementation to wrap.
|
|
202
264
|
*/
|
|
@@ -204,6 +266,7 @@ export class StaticNamespaceResolver implements NamespaceResolver {
|
|
|
204
266
|
private readonly recipes: Record<string, string>;
|
|
205
267
|
private readonly dependencies: Record<string, string[]>;
|
|
206
268
|
private readonly projectScope?: string[];
|
|
269
|
+
private readonly explainMissing?: (recipe: string) => DroppedRecipe | undefined;
|
|
207
270
|
|
|
208
271
|
constructor(options: StaticNamespaceResolverOptions) {
|
|
209
272
|
this.recipes = {};
|
|
@@ -212,6 +275,18 @@ export class StaticNamespaceResolver implements NamespaceResolver {
|
|
|
212
275
|
}
|
|
213
276
|
this.dependencies = options.dependencies ?? {};
|
|
214
277
|
this.projectScope = options.projectScope;
|
|
278
|
+
this.explainMissing = options.explainMissing;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Why a project template's reference names a recipe this resolver does not
|
|
283
|
+
* know, or undefined when the including file is a recipe's own or nothing is
|
|
284
|
+
* known.
|
|
285
|
+
*/
|
|
286
|
+
private droppedFor(recipe: string, fromFile: string): { dropped?: DroppedRecipe } {
|
|
287
|
+
if (this.explainMissing === undefined || this.includingRecipe(fromFile) !== null) return {};
|
|
288
|
+
const dropped = this.explainMissing(recipe);
|
|
289
|
+
return dropped === undefined ? {} : { dropped };
|
|
215
290
|
}
|
|
216
291
|
|
|
217
292
|
/** Every namespace this resolver knows about, sorted. */
|
|
@@ -254,17 +329,27 @@ export class StaticNamespaceResolver implements NamespaceResolver {
|
|
|
254
329
|
resolve(request: NamespaceRequest): NamespaceResolution {
|
|
255
330
|
const { namespace, rest, fromFile } = request;
|
|
256
331
|
|
|
257
|
-
if (!this.knownNamespaces().includes(namespace)) {
|
|
258
|
-
return { kind: "unknown-namespace", known: this.knownNamespaces() };
|
|
259
|
-
}
|
|
260
|
-
|
|
261
332
|
const segments = rest.split("/").filter((segment) => segment.length > 0);
|
|
262
333
|
const recipeName = segments[0] ?? "";
|
|
263
334
|
const ref = `${namespace}/${recipeName}`;
|
|
335
|
+
|
|
336
|
+
if (!this.knownNamespaces().includes(namespace)) {
|
|
337
|
+
return {
|
|
338
|
+
kind: "unknown-namespace",
|
|
339
|
+
known: this.knownNamespaces(),
|
|
340
|
+
...this.droppedFor(ref, fromFile),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
264
344
|
const recipeDir = this.recipes[ref];
|
|
265
345
|
|
|
266
346
|
if (!recipeDir) {
|
|
267
|
-
return {
|
|
347
|
+
return {
|
|
348
|
+
kind: "unknown-recipe",
|
|
349
|
+
recipe: ref,
|
|
350
|
+
known: this.recipesIn(namespace),
|
|
351
|
+
...this.droppedFor(ref, fromFile),
|
|
352
|
+
};
|
|
268
353
|
}
|
|
269
354
|
|
|
270
355
|
const includingRecipe = this.includingRecipe(fromFile);
|