@sous-io/sous 0.2.3 → 0.2.4
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 +18 -9
- package/docs/markdown/_sidebar.md +1 -0
- package/docs/markdown/commands.md +21 -0
- package/docs/markdown/config-discovery.md +3 -2
- package/docs/markdown/config-inspection.md +4 -1
- package/docs/markdown/configuration.md +2 -1
- package/docs/markdown/repositories-quickstart.md +4 -1
- package/package.json +1 -1
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/src/base-command.ts +76 -8
- package/src/commands/build.ts +3 -80
- package/src/commands/init.ts +269 -0
- package/src/lib/build-preparation.ts +86 -0
- package/src/lib/config-discovery.ts +2 -16
- package/src/lib/project-scaffold/index.ts +251 -0
- package/src/lib/project-scaffold/templates.ts +230 -0
- package/src/lib/repos/links.ts +3 -1
package/README.md
CHANGED
|
@@ -41,16 +41,22 @@ npm install
|
|
|
41
41
|
npm link
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
Then set up a project
|
|
45
|
-
`sous.config.js`, `sous.config.mjs`, `sous.config.json`, or `sous.config.yaml`:
|
|
44
|
+
Then set up a project:
|
|
46
45
|
|
|
47
46
|
```bash
|
|
48
47
|
cd /path/to/your/project
|
|
49
|
-
|
|
50
|
-
$EDITOR .sous/sous.config.js
|
|
48
|
+
sous init
|
|
51
49
|
```
|
|
52
50
|
|
|
53
|
-
|
|
51
|
+
`sous init` writes the project's `.sous/` directory and runs the first build. It creates a
|
|
52
|
+
commented `sous.config.js` (pass `--format json` for a JSON config bound to the shipped
|
|
53
|
+
schema), a starter prompt at `.sous/prompts/AGENTS.md` that the config compiles to `AGENTS.md`
|
|
54
|
+
at the project root, the two env files described below, and a `.sous/.gitignore` covering the
|
|
55
|
+
files sous keeps local to one machine. The first build compiles the starter prompt and the
|
|
56
|
+
`core` skills every project gets, and pins them in `.sous/sous.lock.json`. A project that
|
|
57
|
+
already holds a config is left untouched.
|
|
58
|
+
|
|
59
|
+
The config it writes compiles one file:
|
|
54
60
|
|
|
55
61
|
```js
|
|
56
62
|
export const config = {
|
|
@@ -59,17 +65,19 @@ export const config = {
|
|
|
59
65
|
compilation: {
|
|
60
66
|
targets: [
|
|
61
67
|
{
|
|
62
|
-
entryPoint: "${sousDir}/AGENTS.md",
|
|
63
|
-
outputs: [{ destinationFile: "${projectRoot}/
|
|
68
|
+
entryPoint: "${sousDir}/prompts/AGENTS.md",
|
|
69
|
+
outputs: [{ destinationFile: "${projectRoot}/AGENTS.md" }],
|
|
64
70
|
},
|
|
65
71
|
],
|
|
66
72
|
},
|
|
73
|
+
recipeOutputs: { skills: ["${projectRoot}/.claude/skills"] },
|
|
67
74
|
};
|
|
68
75
|
```
|
|
69
76
|
|
|
70
77
|
`${sousDir}` is the `.sous/` directory sous found, so a config can name paths relative to
|
|
71
|
-
itself without hardcoding anything machine-specific. One config describes one project.
|
|
72
|
-
|
|
78
|
+
itself without hardcoding anything machine-specific. One config describes one project. A
|
|
79
|
+
`.sous/` may hold one config file, named `sous.config.js`, `sous.config.mjs`,
|
|
80
|
+
`sous.config.json`, `sous.config.jsonc` or `sous.config.yaml`. After editing, build:
|
|
73
81
|
|
|
74
82
|
```bash
|
|
75
83
|
sous build
|
|
@@ -95,6 +103,7 @@ Useful commands:
|
|
|
95
103
|
|
|
96
104
|
| Command | What it does |
|
|
97
105
|
|---|---|
|
|
106
|
+
| `sous init` | Set a project up: write `.sous/`, then run the first build |
|
|
98
107
|
| `sous build` | Compile, then prune stale outputs |
|
|
99
108
|
| `sous build --watch` | Rebuild on source changes |
|
|
100
109
|
| `sous compile` | Compile only |
|
|
@@ -46,6 +46,27 @@ Every topic answers to both spellings of its name: `repo` and `repos`, `subscrip
|
|
|
46
46
|
|
|
47
47
|
## Top-level commands
|
|
48
48
|
|
|
49
|
+
### `sous init [DIRECTORY]`
|
|
50
|
+
Sets a project up for sous: writes its `.sous/` directory, then runs the first build. It is the one command that
|
|
51
|
+
runs before a config exists, and the starting point for a project that has never used sous. It writes a commented
|
|
52
|
+
primary config, a starter prompt at `.sous/prompts/AGENTS.md` that the config compiles to `AGENTS.md` at the
|
|
53
|
+
project root, `.sous/.env` and `.sous/.env.local.example`, and the sous-managed block in `.sous/.gitignore`. The
|
|
54
|
+
first build compiles the starter prompt and the `core` skills, and pins them in `.sous/sous.lock.json`.
|
|
55
|
+
|
|
56
|
+
A project whose `.sous/` already holds a primary config is refused, and nothing is written. Setting up a directory
|
|
57
|
+
inside a project that is already set up is a question rather than an error, since a subproject may want its own
|
|
58
|
+
instructions; `-y, --yes` answers it ahead of time, and a run with no terminal fails naming that flag.
|
|
59
|
+
|
|
60
|
+
- `DIRECTORY`: the project directory to set up; the current one by default. `--sous-dir` and `SOUS_DIR` also say
|
|
61
|
+
where, when no directory is given.
|
|
62
|
+
- `--format <js|json>`: which config to write, `js` by default. The JSON config carries `$schema`, bound to the
|
|
63
|
+
schema artifact published for the running sous version.
|
|
64
|
+
- `--name <name>`: the display name written into the config; the directory's own name by default.
|
|
65
|
+
- `--no-build`: write the setup without running the first build.
|
|
66
|
+
- `--dry-run`: print the files that would be written without writing them.
|
|
67
|
+
|
|
68
|
+
Example: `sous init --format json`
|
|
69
|
+
|
|
49
70
|
### `sous build`
|
|
50
71
|
Compiles this project's outputs, then removes the ones its config no longer produces. It is compile plus prune,
|
|
51
72
|
and the command you want almost always. Takes `--dry-run`.
|
|
@@ -57,8 +57,9 @@ first, so setting a location var inside `.env.local` has no effect on discovery.
|
|
|
57
57
|
|
|
58
58
|
All are hard `ConfigError`s; sous never guesses:
|
|
59
59
|
|
|
60
|
-
- **No config found**: the error lists every directory checked during the walk and
|
|
61
|
-
|
|
60
|
+
- **No config found**: the error lists every directory checked during the walk and names the two
|
|
61
|
+
fixes: run `sous init` in the project's root directory, which writes the config and everything
|
|
62
|
+
else a first build needs, or pass `--config <path>`.
|
|
62
63
|
- **Multiple primary configs**: two or more of `sous.config.js|mjs|json|jsonc|yaml` in the same
|
|
63
64
|
`.sous/` is an error naming every candidate.
|
|
64
65
|
- **Duplicate layer baseNames**: any two loaded files (primary or conf.d) whose names differ
|
|
@@ -27,7 +27,10 @@ tooling:
|
|
|
27
27
|
{ "$schema": "./path/to/sous.config.schema.json" }
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
The `$schema` key is accepted at the top level and ignored by sous itself.
|
|
30
|
+
The `$schema` key is accepted at the top level and ignored by sous itself. A config written by
|
|
31
|
+
`sous init --format json` binds it to the same artifact as published on GitHub for the running
|
|
32
|
+
version, so an editor resolves it without a path into the installed package; point it at the
|
|
33
|
+
package-local copy instead when the editor should work offline.
|
|
31
34
|
|
|
32
35
|
## sous config show
|
|
33
36
|
|
|
@@ -39,7 +39,8 @@ export const config = {
|
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
A JSON config may set `"$schema"` to bind the `sous.config.schema.json` artifact shipped with
|
|
42
|
-
sous for editor autocompletion; sous accepts and ignores the key.
|
|
42
|
+
sous for editor autocompletion; sous accepts and ignores the key. `sous init --format json`
|
|
43
|
+
writes one already bound to the copy of that artifact published for the running version.
|
|
43
44
|
|
|
44
45
|
?> Configs use `${var}` syntax. Template files use LiquidJS double-brace syntax instead; the two
|
|
45
46
|
are resolved at different stages and never mix.
|
|
@@ -24,7 +24,10 @@ echo 'name: my-project' > .sous/sous.config.yaml
|
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
Everything else has a default: skills compile into `<project root>/.claude/skills`, and the
|
|
27
|
-
lockfile, the state file and the config layers sous writes land under `.sous/`.
|
|
27
|
+
lockfile, the state file and the config layers sous writes land under `.sous/`. Outside a
|
|
28
|
+
walkthrough, `sous init` writes a fuller starting point (a commented config, a starter prompt,
|
|
29
|
+
the env files and the ignore block) and runs the first build for you; the one-line config is
|
|
30
|
+
used here so that each step shows one thing. `SOUS_HOME` puts
|
|
28
31
|
the machine-wide store somewhere throwaway, so this walkthrough leaves your real one alone. See
|
|
29
32
|
[The config file](configuration.md) for the keys you will want later.
|
|
30
33
|
|
package/package.json
CHANGED
package/src/base-command.ts
CHANGED
|
@@ -40,6 +40,23 @@ import { reportCommandError } from "./utils/command-errors.js";
|
|
|
40
40
|
* lives in `lib/interactive.ts`, which also treats a truthy `CI` and a
|
|
41
41
|
* non-terminal stdin or stdout the same way.
|
|
42
42
|
*/
|
|
43
|
+
/**
|
|
44
|
+
* What the config-locating flags and environment said on this run, resolved to
|
|
45
|
+
* absolute paths but not yet turned into a config.
|
|
46
|
+
*/
|
|
47
|
+
export type ConfigLocator = {
|
|
48
|
+
/** The working directory discovery started from. */
|
|
49
|
+
cwd: string;
|
|
50
|
+
/**
|
|
51
|
+
* The explicit config location, when one was given: the resolved path and
|
|
52
|
+
* the flag or environment variable that supplied it (`--config`,
|
|
53
|
+
* `--sous-config`, `SOUS_CONFIG`, `--sous-dir` or `SOUS_DIR`).
|
|
54
|
+
*/
|
|
55
|
+
primary?: { value: string; source: string };
|
|
56
|
+
/** The conf.d directory override, when `--sous-confd` or `SOUS_CONFD` gave one. */
|
|
57
|
+
confDirOverride?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
43
60
|
export abstract class BaseCommand extends Command {
|
|
44
61
|
static baseFlags = {
|
|
45
62
|
config: Flags.string({
|
|
@@ -60,11 +77,29 @@ export abstract class BaseCommand extends Command {
|
|
|
60
77
|
"non-interactive": nonInteractiveFlag(),
|
|
61
78
|
};
|
|
62
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Whether a run of this command needs a project config to exist. Every
|
|
82
|
+
* command that works on a project leaves this true, and a run that finds no
|
|
83
|
+
* config fails before `run()` with the "No sous config found" block. The one
|
|
84
|
+
* command whose job is to CREATE the config sets it false: discovery still
|
|
85
|
+
* runs, so the config-locating flags still say where the project is, but
|
|
86
|
+
* finding nothing is the expected case rather than an error, and `settings`
|
|
87
|
+
* stays unset until `adoptConfig` is given the config that was written.
|
|
88
|
+
*/
|
|
89
|
+
static requiresConfig = true;
|
|
90
|
+
|
|
63
91
|
protected settings!: Settings;
|
|
64
92
|
|
|
65
93
|
/** Where the active config was found. */
|
|
66
94
|
protected configContext!: ConfigContext;
|
|
67
95
|
|
|
96
|
+
/**
|
|
97
|
+
* What the config-locating flags and environment said, before discovery
|
|
98
|
+
* turned it into a config. A command that runs without a config reads the
|
|
99
|
+
* project's location from here.
|
|
100
|
+
*/
|
|
101
|
+
protected configLocator!: ConfigLocator;
|
|
102
|
+
|
|
68
103
|
/** The full discovery result, including how the config was located. */
|
|
69
104
|
protected discovered!: DiscoveredConfig;
|
|
70
105
|
|
|
@@ -142,6 +177,16 @@ export abstract class BaseCommand extends Command {
|
|
|
142
177
|
[sousDirEnv, "SOUS_DIR"],
|
|
143
178
|
];
|
|
144
179
|
const primary = primaryCandidates.find(([value]) => value !== undefined);
|
|
180
|
+
const requiresConfig = (this.constructor as typeof BaseCommand).requiresConfig;
|
|
181
|
+
|
|
182
|
+
this.configLocator = {
|
|
183
|
+
cwd,
|
|
184
|
+
primary:
|
|
185
|
+
primary !== undefined
|
|
186
|
+
? { value: path.resolve(cwd, expandHome(primary[0] as string)), source: primary[1] }
|
|
187
|
+
: undefined,
|
|
188
|
+
confDirOverride,
|
|
189
|
+
};
|
|
145
190
|
|
|
146
191
|
let discovered: DiscoveredConfig | null;
|
|
147
192
|
|
|
@@ -150,6 +195,9 @@ export abstract class BaseCommand extends Command {
|
|
|
150
195
|
try {
|
|
151
196
|
discovered = resolveConfigFlag(primarySource, cwd, confDirOverride, sourceLabel);
|
|
152
197
|
} catch (error) {
|
|
198
|
+
// A command that creates the config is pointed at a place with none in
|
|
199
|
+
// it by design; the flag still says where, so this is not a failure.
|
|
200
|
+
if (!requiresConfig) return;
|
|
153
201
|
displayError(error instanceof Error ? error.message : String(error), this.errorSink);
|
|
154
202
|
return this.exit(1);
|
|
155
203
|
}
|
|
@@ -158,10 +206,29 @@ export abstract class BaseCommand extends Command {
|
|
|
158
206
|
}
|
|
159
207
|
|
|
160
208
|
if (!discovered) {
|
|
209
|
+
if (!requiresConfig) return;
|
|
161
210
|
displayErrorBlock(formatNotFoundMessage(), this.errorSink);
|
|
162
211
|
return this.exit(1);
|
|
163
212
|
}
|
|
164
213
|
|
|
214
|
+
await this.adoptConfig(discovered);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** True once a config has been discovered or adopted and its settings loaded. */
|
|
218
|
+
protected get hasConfig(): boolean {
|
|
219
|
+
return this.discovered !== undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Makes a discovered config THE config for the rest of the run: loads its
|
|
224
|
+
* env files, enumerates its layers again now that `SOUS_HOME` may have
|
|
225
|
+
* changed, and loads the settings. `init()` calls it for the config discovery
|
|
226
|
+
* found; a command that creates a config calls it for the one it wrote, so
|
|
227
|
+
* both go through the same steps in the same order.
|
|
228
|
+
*
|
|
229
|
+
* @param discovered - The config to adopt.
|
|
230
|
+
*/
|
|
231
|
+
protected async adoptConfig(discovered: DiscoveredConfig): Promise<void> {
|
|
165
232
|
// Inject .sous/.env.local and .sous/.env before anything resolves variables,
|
|
166
233
|
// keeping a copy of what the shell itself set so the variables layer can
|
|
167
234
|
// still tell the two apart.
|
|
@@ -172,27 +239,28 @@ export abstract class BaseCommand extends Command {
|
|
|
172
239
|
// file-settable, and it decides where the store holding a subscribed
|
|
173
240
|
// recipe's config layers is. A first pass already ran during discovery, when
|
|
174
241
|
// only the real environment was known.
|
|
242
|
+
let refreshed: DiscoveredConfig;
|
|
175
243
|
try {
|
|
176
|
-
|
|
244
|
+
refreshed = refreshDiscoveredConfig(discovered);
|
|
177
245
|
} catch (error) {
|
|
178
246
|
displayErrorBlock(error instanceof Error ? error.message : String(error), this.errorSink);
|
|
179
247
|
return this.exit(1);
|
|
180
248
|
}
|
|
181
249
|
|
|
182
|
-
this.discovered =
|
|
250
|
+
this.discovered = refreshed;
|
|
183
251
|
this.configContext = {
|
|
184
|
-
sousDir:
|
|
185
|
-
configPath:
|
|
186
|
-
confDir:
|
|
187
|
-
layerPaths:
|
|
252
|
+
sousDir: refreshed.sousDir,
|
|
253
|
+
configPath: refreshed.configPath,
|
|
254
|
+
confDir: refreshed.confDir,
|
|
255
|
+
layerPaths: refreshed.layerPaths,
|
|
188
256
|
};
|
|
189
257
|
|
|
190
258
|
// Routed through the command's error sink, not stdout: a recipe layer
|
|
191
259
|
// warning must not land in the middle of `sous config show | jq`.
|
|
192
|
-
for (const notice of
|
|
260
|
+
for (const notice of refreshed.recipeLayerWarnings) warning(notice, this.errorSink);
|
|
193
261
|
|
|
194
262
|
try {
|
|
195
|
-
this.settings = await loadSettings(
|
|
263
|
+
this.settings = await loadSettings(refreshed);
|
|
196
264
|
} catch (error) {
|
|
197
265
|
displayErrorBlock(error instanceof Error ? error.message : String(error), this.errorSink);
|
|
198
266
|
return this.exit(1);
|
package/src/commands/build.ts
CHANGED
|
@@ -5,15 +5,12 @@ import { PidService } from "../lib/pid-service.js";
|
|
|
5
5
|
import { resolveRootScope } from "../lib/settings.js";
|
|
6
6
|
import { describeLinkedRepos } from "../lib/repos/links.js";
|
|
7
7
|
import { resolveStoreSettings } from "../lib/repos/store/settings.js";
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
type SubscriptionService,
|
|
11
|
-
} from "../lib/repos/subscription-service.js";
|
|
8
|
+
import { prepareRepositoriesForBuild } from "../lib/build-preparation.js";
|
|
9
|
+
import { subscriptionServiceFor } from "../lib/repos/subscription-service.js";
|
|
12
10
|
import { buildReloadWatchConfig, startConfigReloadWatch } from "../lib/watch-loop.js";
|
|
13
11
|
import type { WatchHandle } from "../lib/watch-service.js";
|
|
14
12
|
import { WatchService } from "../lib/watch-service.js";
|
|
15
13
|
import {
|
|
16
|
-
blankLine,
|
|
17
14
|
footer,
|
|
18
15
|
heading,
|
|
19
16
|
log,
|
|
@@ -87,7 +84,7 @@ export default class Build extends BaseCommand {
|
|
|
87
84
|
});
|
|
88
85
|
|
|
89
86
|
if (!flags["dry-run"] && !flags["no-compile"]) {
|
|
90
|
-
await
|
|
87
|
+
await prepareRepositoriesForBuild(repositories);
|
|
91
88
|
}
|
|
92
89
|
|
|
93
90
|
heading("Building");
|
|
@@ -196,78 +193,4 @@ export default class Build extends BaseCommand {
|
|
|
196
193
|
await new Promise(() => {}); // keep process alive
|
|
197
194
|
}
|
|
198
195
|
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Gets this project's recipes ready to compile: restores whatever the store is
|
|
202
|
-
* missing (a fresh clone, or a collected store) and then asks upstream for the
|
|
203
|
-
* repositories that prefer a newer in-range version.
|
|
204
|
-
*
|
|
205
|
-
* Restoring asks nothing and decides nothing; it fetches exactly what the
|
|
206
|
-
* lockfile pins. An upstream check that fails is reported and then ignored,
|
|
207
|
-
* because a build must not depend on the network being up.
|
|
208
|
-
*
|
|
209
|
-
* @param repositories - The subscription service for this project.
|
|
210
|
-
*/
|
|
211
|
-
private async prepareRepositories(repositories: SubscriptionService): Promise<void> {
|
|
212
|
-
const needsRestore = repositories.needsRestore();
|
|
213
|
-
if (needsRestore) {
|
|
214
|
-
heading("Restoring recipes");
|
|
215
|
-
blankLine();
|
|
216
|
-
paragraph(
|
|
217
|
-
"This project's lockfile pins recipes that are not in the store on this " +
|
|
218
|
-
"machine, so they are being fetched at exactly the versions it records."
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const { seed, subscriptions, restored, upstream } =
|
|
223
|
-
await repositories.prepareForBuild();
|
|
224
|
-
|
|
225
|
-
// Seeding the packaged core recipe is silent when it works, which is almost
|
|
226
|
-
// always; it is only worth a word when it could not be done at all.
|
|
227
|
-
if (seed.skippedBecause !== undefined) warning(seed.skippedBecause);
|
|
228
|
-
|
|
229
|
-
// A subscription the lockfile did not pin yet has just been pinned. That is
|
|
230
|
-
// a change to a committed file, so it is always announced.
|
|
231
|
-
if (subscriptions.added.length > 0 || subscriptions.moved.length > 0) {
|
|
232
|
-
heading("Locking subscribed recipes");
|
|
233
|
-
blankLine();
|
|
234
|
-
for (const entry of subscriptions.added) {
|
|
235
|
-
paragraph(` pinned: ${entry.key} at version ${entry.version}.`);
|
|
236
|
-
}
|
|
237
|
-
for (const change of subscriptions.moved) {
|
|
238
|
-
paragraph(` ${change.key} moved from version ${change.from} to version ${change.to}.`);
|
|
239
|
-
}
|
|
240
|
-
blankLine();
|
|
241
|
-
paragraph(
|
|
242
|
-
"The lockfile has been updated. Commit it, so everyone building this project " +
|
|
243
|
-
"gets exactly these versions."
|
|
244
|
-
);
|
|
245
|
-
footer();
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
for (const failure of subscriptions.failed) {
|
|
249
|
-
warning(
|
|
250
|
-
`Sous could not work out which version of '${failure.key}' to use, so nothing ` +
|
|
251
|
-
`from it was compiled.\n${failure.reason}`
|
|
252
|
-
);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
if (restored !== undefined && restored.restored.length > 0) {
|
|
256
|
-
blankLine();
|
|
257
|
-
for (const key of restored.restored) paragraph(` restored: ${key}`);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
for (const change of upstream.updated) {
|
|
261
|
-
paragraph(` ${change.key} moved from ${change.from} to ${change.to}.`);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
for (const failure of upstream.failed) {
|
|
265
|
-
warning(
|
|
266
|
-
`Sous could not check the repository '${failure.repo}' for a newer version, so ` +
|
|
267
|
-
`this build uses the versions it already had.\n${failure.reason}`
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
if (needsRestore) footer();
|
|
272
|
-
}
|
|
273
196
|
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sous init` sets a project up for sous.
|
|
3
|
+
*
|
|
4
|
+
* It writes the `.sous/` directory a project needs (a commented primary config,
|
|
5
|
+
* the starter prompt that config compiles, the two answers files and the
|
|
6
|
+
* sous-managed ignore block) and then runs the first build, which is what
|
|
7
|
+
* seeds the `core` recipe and pins it in the lockfile. A project that is
|
|
8
|
+
* already set up is left exactly as it is.
|
|
9
|
+
*
|
|
10
|
+
* This is the one command that runs BEFORE a project config exists, so it
|
|
11
|
+
* opts out of the config requirement every other command inherits. Discovery
|
|
12
|
+
* still runs, which is how `--sous-dir` and `SOUS_DIR` say where to write, and
|
|
13
|
+
* how a run inside an existing project is noticed.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { Args, Flags } from "@oclif/core";
|
|
18
|
+
import { confirm } from "@inquirer/prompts";
|
|
19
|
+
import { BaseCommand } from "../base-command.js";
|
|
20
|
+
import { prepareRepositoriesForBuild } from "../lib/build-preparation.js";
|
|
21
|
+
import { buildProjectOutputs } from "../lib/build-service.js";
|
|
22
|
+
import {
|
|
23
|
+
CONFIG_FILE_NAMES,
|
|
24
|
+
SOUS_DIR_NAME,
|
|
25
|
+
discoverConfig,
|
|
26
|
+
expandHome,
|
|
27
|
+
resolveConfigFlag,
|
|
28
|
+
} from "../lib/config-discovery.js";
|
|
29
|
+
import { ConfigError } from "../lib/errors.js";
|
|
30
|
+
import { nonInteractiveError } from "../lib/interactive.js";
|
|
31
|
+
import { subscriptionServiceFor } from "../lib/repos/subscription-service.js";
|
|
32
|
+
import {
|
|
33
|
+
PROJECT_CONFIG_FORMATS,
|
|
34
|
+
STARTER_OUTPUT_NAME,
|
|
35
|
+
STARTER_PROMPT_RELATIVE_PATH,
|
|
36
|
+
scaffoldProject,
|
|
37
|
+
sousDirFor,
|
|
38
|
+
type ProjectConfigFormat,
|
|
39
|
+
} from "../lib/project-scaffold/index.js";
|
|
40
|
+
import { SOUS_VERSION } from "../lib/settings.js";
|
|
41
|
+
import { confirmationFlag } from "../utils/flags.js";
|
|
42
|
+
import {
|
|
43
|
+
blankLine,
|
|
44
|
+
dryRunNotice,
|
|
45
|
+
footer,
|
|
46
|
+
heading,
|
|
47
|
+
log,
|
|
48
|
+
paragraph,
|
|
49
|
+
section,
|
|
50
|
+
showCommandVars,
|
|
51
|
+
showVariables,
|
|
52
|
+
warning,
|
|
53
|
+
} from "../utils/formatting.js";
|
|
54
|
+
|
|
55
|
+
export default class Init extends BaseCommand {
|
|
56
|
+
static description =
|
|
57
|
+
"Set a project up for sous: write its .sous/ directory, then run the first build";
|
|
58
|
+
|
|
59
|
+
/** This command creates the config; finding none is its normal case. */
|
|
60
|
+
static override requiresConfig = false;
|
|
61
|
+
|
|
62
|
+
static examples = [
|
|
63
|
+
"<%= config.bin %> init",
|
|
64
|
+
"<%= config.bin %> init ./my-project",
|
|
65
|
+
"<%= config.bin %> init --format json",
|
|
66
|
+
"<%= config.bin %> init --name 'My Project' --no-build",
|
|
67
|
+
"<%= config.bin %> init --dry-run",
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
static args = {
|
|
71
|
+
directory: Args.string({
|
|
72
|
+
description: "Project directory to set up (defaults to the current one)",
|
|
73
|
+
required: false,
|
|
74
|
+
}),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
static flags = {
|
|
78
|
+
...BaseCommand.baseFlags,
|
|
79
|
+
format: Flags.string({
|
|
80
|
+
description: "Format of the primary config to write",
|
|
81
|
+
options: [...PROJECT_CONFIG_FORMATS],
|
|
82
|
+
default: PROJECT_CONFIG_FORMATS[0],
|
|
83
|
+
}),
|
|
84
|
+
name: Flags.string({
|
|
85
|
+
description: "Display name for the project (defaults to the directory's own name)",
|
|
86
|
+
}),
|
|
87
|
+
// The one question this command can ask: whether to set up a project
|
|
88
|
+
// inside another one.
|
|
89
|
+
yes: confirmationFlag(),
|
|
90
|
+
"dry-run": Flags.boolean({
|
|
91
|
+
description: "Print the files that would be written without writing them",
|
|
92
|
+
default: false,
|
|
93
|
+
}),
|
|
94
|
+
"no-build": Flags.boolean({
|
|
95
|
+
description: "Write the setup without running the first build",
|
|
96
|
+
default: false,
|
|
97
|
+
}),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
async run(): Promise<void> {
|
|
101
|
+
const { args, flags } = await this.parse(Init);
|
|
102
|
+
const dryRun = flags["dry-run"];
|
|
103
|
+
const format = flags.format as ProjectConfigFormat;
|
|
104
|
+
|
|
105
|
+
const sousDir = this.targetSousDir(args.directory);
|
|
106
|
+
const projectRoot = path.dirname(sousDir);
|
|
107
|
+
|
|
108
|
+
showCommandVars({
|
|
109
|
+
Directory: projectRoot,
|
|
110
|
+
Config: path.join(sousDir, `sous.config.${format}`),
|
|
111
|
+
Name: flags.name ?? "(from the directory name)",
|
|
112
|
+
"Dry Run": dryRun,
|
|
113
|
+
Build: !dryRun && !flags["no-build"],
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await this.confirmNesting(projectRoot, flags.yes);
|
|
117
|
+
|
|
118
|
+
section("Setting up the project");
|
|
119
|
+
|
|
120
|
+
const result = await scaffoldProject({
|
|
121
|
+
sousDir,
|
|
122
|
+
format,
|
|
123
|
+
name: flags.name,
|
|
124
|
+
dryRun,
|
|
125
|
+
sousVersion: SOUS_VERSION,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
for (const file of result.files) {
|
|
129
|
+
if (result.dryRun) dryRunNotice(`would write ${file}`);
|
|
130
|
+
else log(` wrote ${file}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (result.dryRun) {
|
|
134
|
+
blankLine();
|
|
135
|
+
dryRunNotice("Nothing was written. Run the same command without '--dry-run' to set the project up.");
|
|
136
|
+
footer();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!flags["no-build"]) {
|
|
141
|
+
await this.buildProject(result.configPath);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
section("What was set up");
|
|
145
|
+
showVariables([
|
|
146
|
+
{ label: "Config", value: result.configPath },
|
|
147
|
+
{ label: "Prompt source", value: path.join(sousDir, STARTER_PROMPT_RELATIVE_PATH) },
|
|
148
|
+
{ label: "Compiled to", value: path.join(projectRoot, STARTER_OUTPUT_NAME) },
|
|
149
|
+
{ label: "Skills", value: path.join(projectRoot, ".claude", "skills") },
|
|
150
|
+
{ label: "Shared answers", value: path.join(sousDir, ".env"), detail: "committed" },
|
|
151
|
+
{
|
|
152
|
+
label: "Local answers",
|
|
153
|
+
value: path.join(sousDir, ".env.local"),
|
|
154
|
+
detail: "gitignored; .env.local.example shows the layout",
|
|
155
|
+
},
|
|
156
|
+
]);
|
|
157
|
+
|
|
158
|
+
blankLine();
|
|
159
|
+
paragraph(
|
|
160
|
+
` ${STARTER_OUTPUT_NAME} and the skills directory are build output, compiled from the ` +
|
|
161
|
+
`prompt source and from the recipes this project subscribes to; a build recompiles ` +
|
|
162
|
+
`them. The config explains each of its blocks in its own comments.`
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
footer();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Where the `.sous/` directory goes. A directory argument wins; otherwise the
|
|
170
|
+
* config-locating flags say where the project is, and otherwise it is the
|
|
171
|
+
* working directory. A path that already names a `.sous/` directory, or a
|
|
172
|
+
* config file inside one, is honored as such.
|
|
173
|
+
*/
|
|
174
|
+
private targetSousDir(directory: string | undefined): string {
|
|
175
|
+
if (directory !== undefined) {
|
|
176
|
+
return sousDirFor(path.resolve(this.configLocator.cwd, expandHome(directory)));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const primary = this.configLocator.primary;
|
|
180
|
+
if (primary === undefined) return sousDirFor(this.configLocator.cwd);
|
|
181
|
+
|
|
182
|
+
const value = primary.value;
|
|
183
|
+
if (path.basename(value) === SOUS_DIR_NAME) return value;
|
|
184
|
+
if ((CONFIG_FILE_NAMES as readonly string[]).includes(path.basename(value))) {
|
|
185
|
+
return path.dirname(value);
|
|
186
|
+
}
|
|
187
|
+
return sousDirFor(value);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* A project set up inside another one is a real choice, not a mistake sous
|
|
192
|
+
* should prevent: a subproject may want its own instructions. So when a walk
|
|
193
|
+
* up from the target finds a config in a parent directory, the facts are
|
|
194
|
+
* stated and the question is asked once; `--yes` answers it ahead of time,
|
|
195
|
+
* and a run with no terminal fails naming that flag.
|
|
196
|
+
*
|
|
197
|
+
* The target's own `.sous/` holding a config is a different case, refused
|
|
198
|
+
* outright by the scaffold.
|
|
199
|
+
*/
|
|
200
|
+
private async confirmNesting(projectRoot: string, confirmed: boolean): Promise<void> {
|
|
201
|
+
const enclosing = discoverConfig(projectRoot);
|
|
202
|
+
if (enclosing === null || path.dirname(enclosing.sousDir) === projectRoot) return;
|
|
203
|
+
|
|
204
|
+
const enclosingRoot = path.dirname(enclosing.sousDir);
|
|
205
|
+
|
|
206
|
+
warning(
|
|
207
|
+
`${projectRoot} is inside a project that is already set up for sous.\n` +
|
|
208
|
+
` The enclosing project's config is ${enclosing.configPath}.\n` +
|
|
209
|
+
` Setting this directory up too gives it a config of its own: commands run ` +
|
|
210
|
+
`here will find this one, and commands run from ${enclosingRoot} will keep ` +
|
|
211
|
+
`finding the other.`
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
if (confirmed) return;
|
|
215
|
+
|
|
216
|
+
if (!this.interactive) {
|
|
217
|
+
throw nonInteractiveError({
|
|
218
|
+
prompt: `whether to set up ${projectRoot} inside the project at ${enclosingRoot}`,
|
|
219
|
+
remedy: "pass --yes (also -y, --force) to set it up anyway",
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
blankLine();
|
|
224
|
+
const proceed = await confirm({
|
|
225
|
+
message: `Set up ${projectRoot} as a project of its own?`,
|
|
226
|
+
default: false,
|
|
227
|
+
});
|
|
228
|
+
if (!proceed) {
|
|
229
|
+
throw new ConfigError(
|
|
230
|
+
`Nothing was written. Run 'sous init' from a directory outside ${enclosingRoot}, ` +
|
|
231
|
+
`or pass --yes to set this one up anyway.`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Adopts the config just written and builds the project with it, in the
|
|
238
|
+
* same two steps `sous build` takes: the repositories are prepared (which
|
|
239
|
+
* seeds the core recipe into the store and pins it in the lockfile), then
|
|
240
|
+
* the outputs are compiled.
|
|
241
|
+
*
|
|
242
|
+
* @param configPath - The primary config the scaffold wrote.
|
|
243
|
+
*/
|
|
244
|
+
private async buildProject(configPath: string): Promise<void> {
|
|
245
|
+
await this.adoptConfig(
|
|
246
|
+
resolveConfigFlag(configPath, this.configLocator.cwd, this.configLocator.confDirOverride)
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
await prepareRepositoriesForBuild(
|
|
250
|
+
subscriptionServiceFor({
|
|
251
|
+
configContext: this.configContext,
|
|
252
|
+
settings: this.settings,
|
|
253
|
+
shellEnv: this.shellEnv,
|
|
254
|
+
})
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
heading("Building the project");
|
|
258
|
+
|
|
259
|
+
const succeeded = await buildProjectOutputs(this.settings, this.configContext);
|
|
260
|
+
|
|
261
|
+
if (!succeeded) {
|
|
262
|
+
throw new ConfigError(
|
|
263
|
+
`The project is set up, but the first build failed, so its outputs may be ` +
|
|
264
|
+
`incomplete. Everything 'sous init' wrote is in place; fix what the build ` +
|
|
265
|
+
`reported above and run 'sous build' again.`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The step that runs before a project's outputs are compiled, and what it says
|
|
3
|
+
* while it runs. `sous build` runs it on every build, and `sous init` runs it
|
|
4
|
+
* for the first build of a project it has just set up; both must say the same
|
|
5
|
+
* things about the same events, so the reporting lives here rather than in
|
|
6
|
+
* either command.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { SubscriptionService } from "./repos/subscription-service.js";
|
|
10
|
+
import { blankLine, footer, heading, paragraph, warning } from "../utils/formatting.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Gets this project's recipes ready to compile: restores whatever the store is
|
|
14
|
+
* missing (a fresh clone, or a collected store) and then asks upstream for the
|
|
15
|
+
* repositories that prefer a newer in-range version.
|
|
16
|
+
*
|
|
17
|
+
* Restoring asks nothing and decides nothing; it fetches exactly what the
|
|
18
|
+
* lockfile pins. An upstream check that fails is reported and then ignored,
|
|
19
|
+
* because a build must not depend on the network being up.
|
|
20
|
+
*
|
|
21
|
+
* @param repositories - The subscription service for this project.
|
|
22
|
+
*/
|
|
23
|
+
export async function prepareRepositoriesForBuild(
|
|
24
|
+
repositories: SubscriptionService
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
const needsRestore = repositories.needsRestore();
|
|
27
|
+
if (needsRestore) {
|
|
28
|
+
heading("Restoring recipes");
|
|
29
|
+
blankLine();
|
|
30
|
+
paragraph(
|
|
31
|
+
"This project's lockfile pins recipes that are not in the store on this " +
|
|
32
|
+
"machine, so they are being fetched at exactly the versions it records."
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { seed, subscriptions, restored, upstream } =
|
|
37
|
+
await repositories.prepareForBuild();
|
|
38
|
+
|
|
39
|
+
// Seeding the packaged core recipe is silent when it works, which is almost
|
|
40
|
+
// always; it is only worth a word when it could not be done at all.
|
|
41
|
+
if (seed.skippedBecause !== undefined) warning(seed.skippedBecause);
|
|
42
|
+
|
|
43
|
+
// A subscription the lockfile did not pin yet has just been pinned. That is
|
|
44
|
+
// a change to a committed file, so it is always announced.
|
|
45
|
+
if (subscriptions.added.length > 0 || subscriptions.moved.length > 0) {
|
|
46
|
+
heading("Locking subscribed recipes");
|
|
47
|
+
blankLine();
|
|
48
|
+
for (const entry of subscriptions.added) {
|
|
49
|
+
paragraph(` pinned: ${entry.key} at version ${entry.version}.`);
|
|
50
|
+
}
|
|
51
|
+
for (const change of subscriptions.moved) {
|
|
52
|
+
paragraph(` ${change.key} moved from version ${change.from} to version ${change.to}.`);
|
|
53
|
+
}
|
|
54
|
+
blankLine();
|
|
55
|
+
paragraph(
|
|
56
|
+
"The lockfile has been updated. Commit it, so everyone building this project " +
|
|
57
|
+
"gets exactly these versions."
|
|
58
|
+
);
|
|
59
|
+
footer();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const failure of subscriptions.failed) {
|
|
63
|
+
warning(
|
|
64
|
+
`Sous could not work out which version of '${failure.key}' to use, so nothing ` +
|
|
65
|
+
`from it was compiled.\n${failure.reason}`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (restored !== undefined && restored.restored.length > 0) {
|
|
70
|
+
blankLine();
|
|
71
|
+
for (const key of restored.restored) paragraph(` restored: ${key}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const change of upstream.updated) {
|
|
75
|
+
paragraph(` ${change.key} moved from ${change.from} to ${change.to}.`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const failure of upstream.failed) {
|
|
79
|
+
warning(
|
|
80
|
+
`Sous could not check the repository '${failure.repo}' for a newer version, so ` +
|
|
81
|
+
`this build uses the versions it already had.\n${failure.reason}`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (needsRestore) footer();
|
|
86
|
+
}
|
|
@@ -371,22 +371,8 @@ export function formatNotFoundMessage(startDir: string = process.cwd()): string
|
|
|
371
371
|
checked + more,
|
|
372
372
|
"",
|
|
373
373
|
" To fix this, either:",
|
|
374
|
-
` 1.
|
|
374
|
+
` 1. Set the project up: run 'sous init' in its root directory, which writes`,
|
|
375
|
+
` ${SOUS_DIR_NAME}/${CONFIG_FILE_NAMES[0]} and everything else a first build needs, or`,
|
|
375
376
|
" 2. Pass the config explicitly: sous <command> --config <path>",
|
|
376
|
-
"",
|
|
377
|
-
` A minimal ${CONFIG_FILE_NAMES[0]}:`,
|
|
378
|
-
"",
|
|
379
|
-
" export const config = {",
|
|
380
|
-
' name: "My Project",',
|
|
381
|
-
' _vars: { projectRoot: "${sousDir}/.." },',
|
|
382
|
-
" compilation: {",
|
|
383
|
-
" targets: [",
|
|
384
|
-
" {",
|
|
385
|
-
' entryPoint: "${sousDir}/AGENTS.md",',
|
|
386
|
-
' outputs: [{ destinationFile: "${projectRoot}/AGENTS.md" }],',
|
|
387
|
-
" },",
|
|
388
|
-
" ],",
|
|
389
|
-
" },",
|
|
390
|
-
" };",
|
|
391
377
|
].join("\n");
|
|
392
378
|
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scaffolding a project's `.sous/` directory, which is what `sous init` does.
|
|
3
|
+
*
|
|
4
|
+
* The scaffold is planned in memory first, checked against what is already on
|
|
5
|
+
* disk, and only then written. Once written, the config is read back through
|
|
6
|
+
* the very loader every command uses, so a scaffold sous itself cannot load is
|
|
7
|
+
* never reported as a success.
|
|
8
|
+
*
|
|
9
|
+
* It refuses to touch a project that is already set up: a `.sous/` directory
|
|
10
|
+
* holding a primary config is left exactly as it is, and so is any file the
|
|
11
|
+
* scaffold would otherwise write. The one file it merges rather than replaces
|
|
12
|
+
* is `.sous/.gitignore`, whose sous-managed block is applied by the same writer
|
|
13
|
+
* `sous repo link` uses, so running the scaffold over an existing ignore file
|
|
14
|
+
* never duplicates an entry.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { ConfigError } from "../errors.js";
|
|
20
|
+
import {
|
|
21
|
+
CONFIG_FILE_NAMES,
|
|
22
|
+
ENV_DEFAULTS_NAME,
|
|
23
|
+
ENV_LOCAL_NAME,
|
|
24
|
+
SOUS_DIR_NAME,
|
|
25
|
+
findConfigInSousDir,
|
|
26
|
+
resolveConfigFlag,
|
|
27
|
+
} from "../config-discovery.js";
|
|
28
|
+
import { applyManagedIgnoreBlock } from "../repos/links.js";
|
|
29
|
+
import { loadSettings } from "../settings.js";
|
|
30
|
+
import {
|
|
31
|
+
PROJECT_CONFIG_FORMATS,
|
|
32
|
+
STARTER_PROMPT_RELATIVE_PATH,
|
|
33
|
+
buildConfigJs,
|
|
34
|
+
buildConfigJson,
|
|
35
|
+
buildEnvDefaults,
|
|
36
|
+
buildEnvLocalExample,
|
|
37
|
+
buildStarterPrompt,
|
|
38
|
+
type ProjectConfigFormat,
|
|
39
|
+
type ProjectScaffoldContext,
|
|
40
|
+
} from "./templates.js";
|
|
41
|
+
|
|
42
|
+
export * from "./templates.js";
|
|
43
|
+
|
|
44
|
+
/** What to scaffold, and where. */
|
|
45
|
+
export type ProjectScaffoldOptions = {
|
|
46
|
+
/**
|
|
47
|
+
* Absolute path to the `.sous/` directory to create. Its parent is the
|
|
48
|
+
* project root, which is where the starter prompt compiles to.
|
|
49
|
+
*/
|
|
50
|
+
sousDir: string;
|
|
51
|
+
/** Which config format to write. Defaults to the first of `PROJECT_CONFIG_FORMATS`. */
|
|
52
|
+
format?: ProjectConfigFormat;
|
|
53
|
+
/** The project's display name. Defaults to the project root's own directory name. */
|
|
54
|
+
name?: string;
|
|
55
|
+
/** Work out every file, check the target, but write nothing. */
|
|
56
|
+
dryRun?: boolean;
|
|
57
|
+
/** The version of sous doing the scaffolding, named in the generated files. */
|
|
58
|
+
sousVersion: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** What a scaffold produced. */
|
|
62
|
+
export type ProjectScaffoldResult = {
|
|
63
|
+
/** The `.sous/` directory the scaffold was written into. */
|
|
64
|
+
sousDir: string;
|
|
65
|
+
/** The project root: the parent of `sousDir`. */
|
|
66
|
+
projectRoot: string;
|
|
67
|
+
/** Absolute path of the primary config that was written. */
|
|
68
|
+
configPath: string;
|
|
69
|
+
/** The format the config was written in. */
|
|
70
|
+
format: ProjectConfigFormat;
|
|
71
|
+
/** The display name written into the config. */
|
|
72
|
+
name: string;
|
|
73
|
+
/** Paths of every file written, relative to the project root, in the order written. */
|
|
74
|
+
files: string[];
|
|
75
|
+
/** True when nothing was actually written. */
|
|
76
|
+
dryRun: boolean;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** One planned file: where it goes, and what goes in it. */
|
|
80
|
+
type PlannedFile = {
|
|
81
|
+
/** Path relative to the project root. */
|
|
82
|
+
relativePath: string;
|
|
83
|
+
/** The complete file contents. */
|
|
84
|
+
contents: string;
|
|
85
|
+
/**
|
|
86
|
+
* True for a file whose existing contents were merged into `contents`
|
|
87
|
+
* rather than a file the scaffold refuses to write over.
|
|
88
|
+
*/
|
|
89
|
+
merged?: boolean;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** The file name a config of the given format is written under. */
|
|
93
|
+
export function configFileNameFor(format: ProjectConfigFormat): string {
|
|
94
|
+
const name = `sous.config.${format}`;
|
|
95
|
+
/* c8 ignore next 5 */
|
|
96
|
+
if (!(CONFIG_FILE_NAMES as readonly string[]).includes(name)) {
|
|
97
|
+
throw new ConfigError(
|
|
98
|
+
`sous cannot write a '${format}' config: discovery does not recognize ${name}.`
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return name;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Creates a project's `.sous/` directory: a primary config, the starter prompt
|
|
106
|
+
* it compiles, the two answers files, and the sous-managed block in
|
|
107
|
+
* `.sous/.gitignore`.
|
|
108
|
+
*
|
|
109
|
+
* @param options - What to scaffold, and where.
|
|
110
|
+
* @throws ConfigError when the target already holds a primary config, or any
|
|
111
|
+
* other file the scaffold would write, or when the written config does not
|
|
112
|
+
* load.
|
|
113
|
+
*/
|
|
114
|
+
export async function scaffoldProject(
|
|
115
|
+
options: ProjectScaffoldOptions
|
|
116
|
+
): Promise<ProjectScaffoldResult> {
|
|
117
|
+
const sousDir = path.resolve(options.sousDir);
|
|
118
|
+
const projectRoot = path.dirname(sousDir);
|
|
119
|
+
const format = options.format ?? PROJECT_CONFIG_FORMATS[0];
|
|
120
|
+
const name = (options.name ?? path.basename(projectRoot)).trim() || path.basename(projectRoot);
|
|
121
|
+
const dryRun = options.dryRun === true;
|
|
122
|
+
|
|
123
|
+
assertNoExistingConfig(sousDir);
|
|
124
|
+
|
|
125
|
+
const context: ProjectScaffoldContext = { name, sousVersion: options.sousVersion };
|
|
126
|
+
const files = planFiles(sousDir, projectRoot, format, context);
|
|
127
|
+
|
|
128
|
+
assertNothingWouldBeOverwritten(projectRoot, files);
|
|
129
|
+
|
|
130
|
+
const configPath = path.join(sousDir, configFileNameFor(format));
|
|
131
|
+
|
|
132
|
+
if (!dryRun) {
|
|
133
|
+
for (const file of files) {
|
|
134
|
+
const target = path.join(projectRoot, file.relativePath);
|
|
135
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
136
|
+
fs.writeFileSync(target, file.contents, "utf8");
|
|
137
|
+
}
|
|
138
|
+
await verifyScaffold(configPath, projectRoot);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
sousDir,
|
|
143
|
+
projectRoot,
|
|
144
|
+
configPath,
|
|
145
|
+
format,
|
|
146
|
+
name,
|
|
147
|
+
files: files.map((file) => file.relativePath),
|
|
148
|
+
dryRun,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Builds every file the scaffold writes, in the order they are written. */
|
|
153
|
+
function planFiles(
|
|
154
|
+
sousDir: string,
|
|
155
|
+
projectRoot: string,
|
|
156
|
+
format: ProjectConfigFormat,
|
|
157
|
+
context: ProjectScaffoldContext
|
|
158
|
+
): PlannedFile[] {
|
|
159
|
+
const inSousDir = (name: string): string =>
|
|
160
|
+
path.relative(projectRoot, path.join(sousDir, name));
|
|
161
|
+
|
|
162
|
+
const gitignorePath = path.join(sousDir, ".gitignore");
|
|
163
|
+
const existingIgnore = fs.existsSync(gitignorePath)
|
|
164
|
+
? fs.readFileSync(gitignorePath, "utf8")
|
|
165
|
+
: undefined;
|
|
166
|
+
|
|
167
|
+
return [
|
|
168
|
+
{
|
|
169
|
+
relativePath: inSousDir(configFileNameFor(format)),
|
|
170
|
+
contents: format === "json" ? buildConfigJson(context) : buildConfigJs(context),
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
relativePath: inSousDir(STARTER_PROMPT_RELATIVE_PATH),
|
|
174
|
+
contents: buildStarterPrompt(context),
|
|
175
|
+
},
|
|
176
|
+
{ relativePath: inSousDir(ENV_DEFAULTS_NAME), contents: buildEnvDefaults(context) },
|
|
177
|
+
{
|
|
178
|
+
relativePath: inSousDir(`${ENV_LOCAL_NAME}.example`),
|
|
179
|
+
contents: buildEnvLocalExample(context),
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
relativePath: inSousDir(".gitignore"),
|
|
183
|
+
contents: applyManagedIgnoreBlock(existingIgnore, gitignorePath),
|
|
184
|
+
merged: true,
|
|
185
|
+
},
|
|
186
|
+
];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Refuses to scaffold a `.sous/` directory that already holds a primary
|
|
191
|
+
* config. The config is what makes a directory a sous project, so that is what
|
|
192
|
+
* is checked; a `.sous/` holding only, say, task files is a fine place to set
|
|
193
|
+
* one up.
|
|
194
|
+
*/
|
|
195
|
+
function assertNoExistingConfig(sousDir: string): void {
|
|
196
|
+
const existing = findConfigInSousDir(sousDir);
|
|
197
|
+
if (existing !== null) {
|
|
198
|
+
throw new ConfigError(
|
|
199
|
+
`${path.dirname(sousDir)} is already set up for sous.\n` +
|
|
200
|
+
` ${existing} exists, and sous will not overwrite it.\n` +
|
|
201
|
+
` Edit that config instead, or run 'sous init' in another directory.`
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Refuses to write over any file the scaffold produces, so a run that fails
|
|
208
|
+
* here has changed nothing. The merged ignore file is exempt: its existing
|
|
209
|
+
* lines are carried into what is written.
|
|
210
|
+
*/
|
|
211
|
+
function assertNothingWouldBeOverwritten(projectRoot: string, files: PlannedFile[]): void {
|
|
212
|
+
const clashes = files
|
|
213
|
+
.filter((file) => file.merged !== true)
|
|
214
|
+
.map((file) => path.join(projectRoot, file.relativePath))
|
|
215
|
+
.filter((target) => fs.existsSync(target));
|
|
216
|
+
|
|
217
|
+
if (clashes.length === 0) return;
|
|
218
|
+
|
|
219
|
+
throw new ConfigError(
|
|
220
|
+
`${projectRoot} already holds ${clashes.length === 1 ? "a file" : "files"} that ` +
|
|
221
|
+
`'sous init' would write, and sous will not overwrite ${clashes.length === 1 ? "it" : "them"}:\n` +
|
|
222
|
+
clashes.map((target) => ` ${target}`).join("\n") +
|
|
223
|
+
`\n Move ${clashes.length === 1 ? "it" : "them"} aside, or run 'sous init' in another directory.`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Loads the written config through the same loader every command uses, so
|
|
229
|
+
* the scaffold is never reported as a success unless sous can actually read
|
|
230
|
+
* it. A failure here is a bug in the scaffold, and is reported as one.
|
|
231
|
+
*/
|
|
232
|
+
async function verifyScaffold(configPath: string, projectRoot: string): Promise<void> {
|
|
233
|
+
try {
|
|
234
|
+
await loadSettings(resolveConfigFlag(configPath, projectRoot));
|
|
235
|
+
} catch (error) {
|
|
236
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
237
|
+
throw new ConfigError(
|
|
238
|
+
`The config 'sous init' wrote at ${configPath} does not load.\n` +
|
|
239
|
+
` This is a bug in sous; please report it. The loader said:\n` +
|
|
240
|
+
`${reason
|
|
241
|
+
.split("\n")
|
|
242
|
+
.map((line) => ` ${line}`)
|
|
243
|
+
.join("\n")}`
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The `.sous/` directory a project root would hold. */
|
|
249
|
+
export function sousDirFor(projectRoot: string): string {
|
|
250
|
+
return path.join(path.resolve(projectRoot), SOUS_DIR_NAME);
|
|
251
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The files `sous init` writes, as plain string builders.
|
|
3
|
+
*
|
|
4
|
+
* As with the repository scaffold, these are deliberately not templates run
|
|
5
|
+
* through a template engine. A scaffolded project is read by a person before
|
|
6
|
+
* it is read by a machine, and the comments are what make the first config
|
|
7
|
+
* legible; a rendering step would only stand between the author of the
|
|
8
|
+
* scaffold and the person who has to live with the result.
|
|
9
|
+
*
|
|
10
|
+
* Every builder returns a complete file, ending in a newline. The JSON config
|
|
11
|
+
* is the one file that cannot carry a comment: the config schema is strict and
|
|
12
|
+
* accepts `$schema` but no comment key, so what the JS variant explains in
|
|
13
|
+
* comments the JSON variant leaves to the documentation.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
CONFD_DIR_NAME,
|
|
18
|
+
ENV_DEFAULTS_NAME,
|
|
19
|
+
ENV_LOCAL_NAME,
|
|
20
|
+
SOUS_DIR_NAME,
|
|
21
|
+
} from "../config-discovery.js";
|
|
22
|
+
|
|
23
|
+
/** The config formats `sous init` can write. The first one is the default. */
|
|
24
|
+
export const PROJECT_CONFIG_FORMATS = ["js", "json"] as const;
|
|
25
|
+
|
|
26
|
+
/** One of the config formats `sous init` can write. */
|
|
27
|
+
export type ProjectConfigFormat = (typeof PROJECT_CONFIG_FORMATS)[number];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The path, relative to `.sous/`, of the starter prompt the scaffolded config
|
|
31
|
+
* compiles. It lives under `prompts/` so the source and its compiled output
|
|
32
|
+
* (`AGENTS.md` at the project root) are never mistaken for one another.
|
|
33
|
+
*/
|
|
34
|
+
export const STARTER_PROMPT_RELATIVE_PATH = "prompts/AGENTS.md";
|
|
35
|
+
|
|
36
|
+
/** The name of the file the starter prompt is compiled into, at the project root. */
|
|
37
|
+
export const STARTER_OUTPUT_NAME = "AGENTS.md";
|
|
38
|
+
|
|
39
|
+
/** What every builder needs to know about the project being scaffolded. */
|
|
40
|
+
export type ProjectScaffoldContext = {
|
|
41
|
+
/** The project's display name, written into the config's `name`. */
|
|
42
|
+
name: string;
|
|
43
|
+
/** The version of sous doing the scaffolding, named in the generated files. */
|
|
44
|
+
sousVersion: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Where the JSON Schema for a config of this sous version is published. The
|
|
49
|
+
* schema ships inside the package too, but an editor resolving `$schema` wants
|
|
50
|
+
* a URL, and the tagged copy on GitHub is the one that matches this version
|
|
51
|
+
* for as long as the tag exists.
|
|
52
|
+
*
|
|
53
|
+
* @param sousVersion - The running sous version.
|
|
54
|
+
*/
|
|
55
|
+
export function configSchemaUrl(sousVersion: string): string {
|
|
56
|
+
return `https://raw.githubusercontent.com/sous-io/sous/v${sousVersion}/sous.config.schema.json`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The primary config as a JavaScript module, commented so a first-time reader
|
|
61
|
+
* can see what each block is for without opening the documentation.
|
|
62
|
+
*
|
|
63
|
+
* @param context - The project being scaffolded.
|
|
64
|
+
*/
|
|
65
|
+
export function buildConfigJs(context: ProjectScaffoldContext): string {
|
|
66
|
+
return `// The primary sous config for ${context.name}.
|
|
67
|
+
//
|
|
68
|
+
// sous finds this file by walking up from the directory it was run in until it
|
|
69
|
+
// meets a \`${SOUS_DIR_NAME}/\` directory holding a config. Everything a build needs
|
|
70
|
+
// starts here. Drop-in layers under \`${SOUS_DIR_NAME}/${CONFD_DIR_NAME}/\` merge over it, and
|
|
71
|
+
// the layers sous writes for itself go into that directory, never into this
|
|
72
|
+
// file. Written by \`sous init\` (sous ${context.sousVersion}); it is yours now.
|
|
73
|
+
|
|
74
|
+
export const config = {
|
|
75
|
+
// Shown in command output. Any string.
|
|
76
|
+
name: ${JSON.stringify(context.name)},
|
|
77
|
+
|
|
78
|
+
// Variables for the rest of this file. \`\${sousDir}\` is this \`${SOUS_DIR_NAME}/\` directory,
|
|
79
|
+
// injected by sous, so nothing here depends on where the project is checked
|
|
80
|
+
// out. Reference a variable anywhere below as \`\${name}\`.
|
|
81
|
+
_vars: {
|
|
82
|
+
projectRoot: "\${sousDir}/..",
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
// What to compile. Each target reads one source and writes it somewhere. The
|
|
86
|
+
// starter target renders \`${SOUS_DIR_NAME}/${STARTER_PROMPT_RELATIVE_PATH}\` to the project root.
|
|
87
|
+
// A line holding only \`@path/to/file.md\` in a source pulls that file in, and a
|
|
88
|
+
// \`.tpl.\` in a file name turns Liquid templating on for it. Compiled files
|
|
89
|
+
// are build output: ignore them or commit them, as your team prefers.
|
|
90
|
+
compilation: {
|
|
91
|
+
targets: [
|
|
92
|
+
{
|
|
93
|
+
entryPoint: "\${sousDir}/${STARTER_PROMPT_RELATIVE_PATH}",
|
|
94
|
+
outputs: [{ destinationFile: "\${projectRoot}/${STARTER_OUTPUT_NAME}" }],
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
// Where the recipes this project subscribes to write their files. Every
|
|
100
|
+
// project subscribes to the \`core\` namespace on its own, which is how the
|
|
101
|
+
// skills that teach an agent about sous reach \`.claude/skills\`. Memories and
|
|
102
|
+
// prompts have no default home; name one to receive them.
|
|
103
|
+
recipeOutputs: {
|
|
104
|
+
skills: ["\${projectRoot}/.claude/skills"],
|
|
105
|
+
// memories: ["\${projectRoot}/.claude/memories"],
|
|
106
|
+
// prompts: ["\${projectRoot}/.claude/prompts"],
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The same primary config as strict JSON, bound to the shipped schema through
|
|
114
|
+
* `$schema` so an editor can validate and complete it.
|
|
115
|
+
*
|
|
116
|
+
* @param context - The project being scaffolded.
|
|
117
|
+
*/
|
|
118
|
+
export function buildConfigJson(context: ProjectScaffoldContext): string {
|
|
119
|
+
const config = {
|
|
120
|
+
$schema: configSchemaUrl(context.sousVersion),
|
|
121
|
+
name: context.name,
|
|
122
|
+
_vars: {
|
|
123
|
+
projectRoot: "${sousDir}/..",
|
|
124
|
+
},
|
|
125
|
+
compilation: {
|
|
126
|
+
targets: [
|
|
127
|
+
{
|
|
128
|
+
entryPoint: `\${sousDir}/${STARTER_PROMPT_RELATIVE_PATH}`,
|
|
129
|
+
outputs: [{ destinationFile: `\${projectRoot}/${STARTER_OUTPUT_NAME}` }],
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
recipeOutputs: {
|
|
134
|
+
skills: ["${projectRoot}/.claude/skills"],
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
return `${JSON.stringify(config, null, 2)}\n`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The starter prompt the config compiles: a short, real instruction file, so
|
|
142
|
+
* the first build produces something worth reading rather than a placeholder.
|
|
143
|
+
*
|
|
144
|
+
* @param context - The project being scaffolded.
|
|
145
|
+
*/
|
|
146
|
+
export function buildStarterPrompt(context: ProjectScaffoldContext): string {
|
|
147
|
+
return `# ${context.name}
|
|
148
|
+
|
|
149
|
+
This file is compiled by sous into \`${STARTER_OUTPUT_NAME}\` at the project root. Edit this
|
|
150
|
+
source, run \`sous build\`, and the compiled copy follows; never edit the compiled copy.
|
|
151
|
+
|
|
152
|
+
## Working in this project
|
|
153
|
+
|
|
154
|
+
- Describe the project here: what it is, how it is built, and how it is tested.
|
|
155
|
+
- Split long sections into their own files under \`${SOUS_DIR_NAME}/prompts/\` and pull
|
|
156
|
+
each one in with a line holding only \`@sections/name.md\`.
|
|
157
|
+
- The skills under \`.claude/skills\` are written by sous from the recipes this
|
|
158
|
+
project subscribes to. \`sous recipe list\` shows what is available.
|
|
159
|
+
`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The committed answers file: the layer for what the whole team shares.
|
|
164
|
+
*
|
|
165
|
+
* @param context - The project being scaffolded.
|
|
166
|
+
*/
|
|
167
|
+
export function buildEnvDefaults(context: ProjectScaffoldContext): string {
|
|
168
|
+
return `# ${SOUS_DIR_NAME}/${ENV_DEFAULTS_NAME}
|
|
169
|
+
#
|
|
170
|
+
# Answers to recipe variables that the whole team shares. Commit this file.
|
|
171
|
+
#
|
|
172
|
+
# A recipe this project subscribes to may ask questions (a board name, a
|
|
173
|
+
# ticket prefix, where task files go), and the answers live here as
|
|
174
|
+
# \`KEY=VALUE\` lines. \`sous vars ask\` writes them one question at a time, and
|
|
175
|
+
# \`sous vars list\` shows every variable in play with where its answer came from.
|
|
176
|
+
#
|
|
177
|
+
# Never put a secret, a login or a machine-specific path here. Those belong in
|
|
178
|
+
# \`${SOUS_DIR_NAME}/${ENV_LOCAL_NAME}\`, which is gitignored and overrides this file
|
|
179
|
+
# key for key. Precedence, highest first: your shell, then \`${ENV_LOCAL_NAME}\`,
|
|
180
|
+
# then this file.
|
|
181
|
+
#
|
|
182
|
+
# Written by \`sous init\` (sous ${context.sousVersion}).
|
|
183
|
+
`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The example for the gitignored answers file, explaining what belongs in the
|
|
188
|
+
* local layer and how to start one.
|
|
189
|
+
*
|
|
190
|
+
* @param context - The project being scaffolded.
|
|
191
|
+
*/
|
|
192
|
+
export function buildEnvLocalExample(context: ProjectScaffoldContext): string {
|
|
193
|
+
return `# ${SOUS_DIR_NAME}/${ENV_LOCAL_NAME}.example
|
|
194
|
+
#
|
|
195
|
+
# Copy this file to \`${SOUS_DIR_NAME}/${ENV_LOCAL_NAME}\` and fill in your own values:
|
|
196
|
+
#
|
|
197
|
+
# cp ${SOUS_DIR_NAME}/${ENV_LOCAL_NAME}.example ${SOUS_DIR_NAME}/${ENV_LOCAL_NAME}
|
|
198
|
+
#
|
|
199
|
+
# ...or let sous write it for you, one question at a time:
|
|
200
|
+
#
|
|
201
|
+
# sous vars ask
|
|
202
|
+
#
|
|
203
|
+
# \`${SOUS_DIR_NAME}/${ENV_LOCAL_NAME}\` is gitignored. It is the layer for anything that
|
|
204
|
+
# differs between people or machines, or must not be committed: your own login
|
|
205
|
+
# and name, absolute paths outside the repository, API tokens.
|
|
206
|
+
#
|
|
207
|
+
# The COMMITTED layer is \`${SOUS_DIR_NAME}/${ENV_DEFAULTS_NAME}\`. Same syntax, but it holds
|
|
208
|
+
# the answers the whole team shares. Never put a secret there.
|
|
209
|
+
#
|
|
210
|
+
# How both files are loaded:
|
|
211
|
+
# - \`KEY=VALUE\` per line. \`#\` starts a comment. \`export KEY=VALUE\` also works.
|
|
212
|
+
# - Loaded into the environment before the config resolves any variables.
|
|
213
|
+
# - Precedence, highest first: your shell, then \`${ENV_LOCAL_NAME}\`, then \`${ENV_DEFAULTS_NAME}\`.
|
|
214
|
+
# So \`FOO=bar sous build\` overrides \`FOO\` from either file for that one run,
|
|
215
|
+
# and a key in \`${ENV_LOCAL_NAME}\` overrides the same key in \`${ENV_DEFAULTS_NAME}\`.
|
|
216
|
+
# - A recipe's variable answers reach its templates on their own. \`sous vars
|
|
217
|
+
# list\` shows each one, with the environment variable that supplied it.
|
|
218
|
+
# - The config's own \`_env\` block still works for anything a recipe does not
|
|
219
|
+
# ask about: \`_env: { myPath: "MY_PATH" }\` makes \`\${myPath}\` available
|
|
220
|
+
# throughout the config from \`MY_PATH\` in either file.
|
|
221
|
+
#
|
|
222
|
+
# NOT here: the config-location variables \`SOUS_CONFIG\`, \`SOUS_DIR\` and
|
|
223
|
+
# \`SOUS_CONFD\`. Those decide where sous looks for \`${SOUS_DIR_NAME}/\`, so they are
|
|
224
|
+
# read from the real shell environment only; this file is not found until
|
|
225
|
+
# discovery has already run.
|
|
226
|
+
#
|
|
227
|
+
# Written by \`sous init\` (sous ${context.sousVersion}). Add a line per answer below,
|
|
228
|
+
# with a comment saying what it is for.
|
|
229
|
+
`;
|
|
230
|
+
}
|
package/src/lib/repos/links.ts
CHANGED
|
@@ -49,12 +49,14 @@ export const IGNORE_BLOCK_END = "# <<< sous managed";
|
|
|
49
49
|
/**
|
|
50
50
|
* The entries sous keeps inside its managed block in `.sous/.gitignore`. All of
|
|
51
51
|
* them are machine-local: the links map, the build state file, the watcher's
|
|
52
|
-
* PID file, and the directory linked checkouts are
|
|
52
|
+
* PID file, the local answers file, and the directory linked checkouts are
|
|
53
|
+
* cloned into.
|
|
53
54
|
*/
|
|
54
55
|
export const IGNORE_BLOCK_ENTRIES = [
|
|
55
56
|
LINKS_FILENAME,
|
|
56
57
|
"sous.state.json",
|
|
57
58
|
"sous.pid",
|
|
59
|
+
".env.local",
|
|
58
60
|
`${REPOS_DIRNAME}/`,
|
|
59
61
|
] as const;
|
|
60
62
|
|