@sous-io/sous 0.1.1 → 0.2.0
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 +115 -35
- package/bin/run.js +10 -1
- package/docs/markdown/README.md +27 -0
- package/docs/markdown/_sidebar.md +18 -0
- package/docs/markdown/commands.md +308 -0
- package/docs/markdown/config-discovery.md +74 -0
- package/docs/markdown/config-inspection.md +69 -0
- package/docs/markdown/config-layers.md +92 -0
- package/docs/markdown/config-variables.md +79 -0
- package/docs/markdown/configuration.md +71 -0
- package/docs/markdown/design-principles.md +59 -0
- package/docs/markdown/repositories-authoring.md +408 -0
- package/docs/markdown/repositories-consuming.md +580 -0
- package/docs/markdown/repositories-file-formats.md +1084 -0
- package/docs/markdown/repositories-variables.md +387 -0
- package/docs/markdown/repositories.md +303 -0
- package/docs/markdown/skill-categories.md +58 -0
- package/package.json +72 -8
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/SKILL.tpl.md +20 -20
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/about-something.md +2 -2
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/do-something.md +1 -1
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/advanced-patterns.md +6 -6
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/commands.md +5 -5
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/frontmatter.md +3 -3
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/SKILL.tpl.md +40 -25
- package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +70 -0
- package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +75 -0
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/create-skill/SKILL.tpl.md +8 -9
- package/recipes/core/sous-skills/sous.recipe.yaml +45 -0
- package/sous.config.schema.json +337 -0
- package/src/base-command.ts +220 -67
- package/src/commands/build.ts +150 -73
- package/src/commands/clear.ts +23 -15
- package/src/commands/compile.ts +74 -16
- package/src/commands/config/get.ts +110 -0
- package/src/commands/config/show.ts +32 -0
- package/src/commands/config/validate.ts +53 -0
- package/src/commands/help.ts +46 -0
- package/src/commands/launch.ts +36 -14
- package/src/commands/lock/rebuild.ts +241 -0
- package/src/commands/lock/show.ts +115 -0
- package/src/commands/namespace/list.ts +117 -0
- package/src/commands/namespace/show.ts +110 -0
- package/src/commands/prune.ts +3 -11
- package/src/commands/recipe/list.ts +95 -0
- package/src/commands/recipe/show.ts +301 -0
- package/src/commands/repo/add.ts +145 -0
- package/src/commands/repo/gc.ts +172 -0
- package/src/commands/repo/init.ts +136 -0
- package/src/commands/repo/link.ts +500 -0
- package/src/commands/repo/list.ts +179 -0
- package/src/commands/repo/release.ts +619 -0
- package/src/commands/repo/remove.ts +193 -0
- package/src/commands/repo/search.ts +189 -0
- package/src/commands/repo/submit.ts +133 -0
- package/src/commands/repo/unlink.ts +147 -0
- package/src/commands/subscription/add.ts +285 -0
- package/src/commands/subscription/list.ts +129 -0
- package/src/commands/subscription/remove.ts +181 -0
- package/src/commands/vars/ask.ts +374 -0
- package/src/commands/vars/index.ts +79 -0
- package/src/commands/vars/list.ts +67 -0
- package/src/commands/vars/show.ts +77 -0
- package/src/config-command.ts +30 -0
- package/src/lib/build-service.ts +206 -54
- package/src/lib/config-discovery.ts +220 -27
- package/src/lib/config-inspect.ts +145 -0
- package/src/lib/config-kernel.mjs +377 -0
- package/src/lib/config-schema.ts +361 -0
- package/src/lib/env-file.ts +328 -0
- package/src/lib/env-local.ts +18 -1
- package/src/lib/errors.ts +32 -0
- package/src/lib/include-resolver.ts +108 -15
- package/src/lib/interactive.ts +165 -0
- package/src/lib/markdown-compiler.ts +118 -37
- package/src/lib/package-info.ts +25 -0
- package/src/lib/pid-service.ts +32 -21
- package/src/lib/refs/find.ts +589 -0
- package/src/lib/refs/index.ts +12 -0
- package/src/lib/refs/pick.ts +147 -0
- package/src/lib/refs/scopes.ts +61 -0
- package/src/lib/repos/catalog-display.ts +116 -0
- package/src/lib/repos/catalog-inputs.ts +160 -0
- package/src/lib/repos/catalog.ts +722 -0
- package/src/lib/repos/core-recipe.ts +105 -0
- package/src/lib/repos/defaults.ts +175 -0
- package/src/lib/repos/formats/common.ts +389 -0
- package/src/lib/repos/formats/index-file.ts +215 -0
- package/src/lib/repos/formats/links-map.ts +96 -0
- package/src/lib/repos/formats/lockfile.ts +167 -0
- package/src/lib/repos/formats/patterns.ts +57 -0
- package/src/lib/repos/formats/recipe-manifest.ts +395 -0
- package/src/lib/repos/formats/repo-manifest.ts +88 -0
- package/src/lib/repos/formats/store-entry.ts +84 -0
- package/src/lib/repos/freshness.ts +208 -0
- package/src/lib/repos/git-clone.ts +312 -0
- package/src/lib/repos/identity.ts +89 -0
- package/src/lib/repos/index.ts +58 -0
- package/src/lib/repos/links.ts +353 -0
- package/src/lib/repos/load-manifest.ts +236 -0
- package/src/lib/repos/lock-service.ts +453 -0
- package/src/lib/repos/locked-namespace-resolver.ts +90 -0
- package/src/lib/repos/locked-recipes.ts +254 -0
- package/src/lib/repos/managed-layer.ts +422 -0
- package/src/lib/repos/namespace-resolver.ts +370 -0
- package/src/lib/repos/providers/base.ts +206 -0
- package/src/lib/repos/providers/git.ts +233 -0
- package/src/lib/repos/providers/github.ts +294 -0
- package/src/lib/repos/providers/gitlab.ts +263 -0
- package/src/lib/repos/providers/http.ts +102 -0
- package/src/lib/repos/providers/index-cache.ts +382 -0
- package/src/lib/repos/providers/index.ts +106 -0
- package/src/lib/repos/providers/local.ts +391 -0
- package/src/lib/repos/providers/provider.ts +401 -0
- package/src/lib/repos/recipe-config-layers.ts +287 -0
- package/src/lib/repos/recipe-targets.ts +223 -0
- package/src/lib/repos/ref-search.ts +46 -0
- package/src/lib/repos/ref.ts +513 -0
- package/src/lib/repos/reference-report.ts +122 -0
- package/src/lib/repos/release/bump.ts +161 -0
- package/src/lib/repos/release/git-state.ts +305 -0
- package/src/lib/repos/release/index-builder.ts +635 -0
- package/src/lib/repos/release/index.ts +16 -0
- package/src/lib/repos/release/plan.ts +512 -0
- package/src/lib/repos/release/submit-service.ts +496 -0
- package/src/lib/repos/release/tags.ts +243 -0
- package/src/lib/repos/release/validate.ts +463 -0
- package/src/lib/repos/resolver.ts +789 -0
- package/src/lib/repos/scaffold/index.ts +238 -0
- package/src/lib/repos/scaffold/templates.ts +413 -0
- package/src/lib/repos/seed.ts +414 -0
- package/src/lib/repos/store/contract.ts +64 -0
- package/src/lib/repos/store/hash.ts +114 -0
- package/src/lib/repos/store/recipe-store.ts +599 -0
- package/src/lib/repos/store/settings.ts +58 -0
- package/src/lib/repos/subscription-service.ts +2678 -0
- package/src/lib/repos/trust.ts +447 -0
- package/src/lib/settings.ts +546 -189
- package/src/lib/sous-home.ts +104 -0
- package/src/lib/state.ts +52 -20
- package/src/lib/vars/ask.ts +1152 -0
- package/src/lib/vars/definition-source.ts +252 -0
- package/src/lib/vars/display.ts +233 -0
- package/src/lib/vars/index.ts +18 -0
- package/src/lib/vars/ladder.ts +282 -0
- package/src/lib/vars/mappings.ts +265 -0
- package/src/lib/vars/names.ts +94 -0
- package/src/lib/vars/preanswers.ts +395 -0
- package/src/lib/vars/question-plan.ts +218 -0
- package/src/lib/vars/report.ts +228 -0
- package/src/lib/vars/safe-regex.ts +235 -0
- package/src/lib/vars/validate.ts +312 -0
- package/src/lib/watch-loop.ts +148 -0
- package/src/templating/init-liquid-engine.ts +58 -16
- package/src/utils/choice-prompt.ts +143 -0
- package/src/utils/command-errors.ts +186 -0
- package/src/utils/command-help.ts +45 -0
- package/src/utils/confirm-prompt.ts +110 -0
- package/src/utils/flags.ts +153 -0
- package/src/utils/formatting.ts +540 -55
- package/src/utils/prompts.ts +35 -1
- package/src/utils/sous-directory.ts +245 -0
- package/src/utils/table.ts +603 -0
- package/src/utils/value-prompt.ts +119 -0
- package/shared-prompts/_partials/resume-task.md +0 -51
- package/shared-prompts/_partials/sub-agent-delegation.md +0 -32
- package/shared-prompts/_partials/update-task-file.md +0 -52
- package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +0 -52
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +0 -102
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +0 -81
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +0 -126
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +0 -92
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +0 -61
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +0 -65
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +0 -96
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +0 -104
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +0 -243
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +0 -148
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +0 -383
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +0 -267
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +0 -56
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +0 -169
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +0 -59
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +0 -25
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +0 -140
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +0 -140
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +0 -1
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +0 -185
- package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +0 -52
- package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +0 -59
- package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +0 -47
- package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +0 -26
- package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +0 -58
- package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +0 -27
- package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +0 -34
- package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +0 -51
- package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +0 -122
- package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +0 -80
- package/shared-prompts/skills/task-files/go/SKILL.tpl.md +0 -14
- package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +0 -13
- package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +0 -93
- package/shared-prompts/skills/task-files/update/SKILL.tpl.md +0 -14
- package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +0 -13
- /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/substitutions.md +0 -0
- /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/references/liquid-filters.md +0 -0
package/src/lib/settings.ts
CHANGED
|
@@ -2,23 +2,33 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
|
-
import { fileURLToPath
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { globSync } from "glob";
|
|
7
7
|
import { inferGlobBase, type CompilationConfig, type CompilationTarget, type ResolvedRuntimeContext } from "./markdown-compiler.js";
|
|
8
|
-
import { buildAliasMap, type AliasMap } from "./include-resolver.js";
|
|
9
|
-
import {
|
|
8
|
+
import { buildAliasMap, resolveAliasPrefix, type AliasMap } from "./include-resolver.js";
|
|
9
|
+
import {
|
|
10
|
+
CONFD_DIR_NAME,
|
|
11
|
+
ENV_DEFAULTS_NAME,
|
|
12
|
+
ENV_LOCAL_NAME,
|
|
13
|
+
SOUS_DIR_NAME,
|
|
14
|
+
type DiscoveredConfig,
|
|
15
|
+
} from "./config-discovery.js";
|
|
16
|
+
import { ConfigError } from "./errors.js";
|
|
17
|
+
import { resolveSousHome } from "./sous-home.js";
|
|
18
|
+
import { validateSettings } from "./config-schema.js";
|
|
19
|
+
import { applyRepoDefaults } from "./repos/defaults.js";
|
|
20
|
+
import type { RecipeConfigLayer } from "./repos/recipe-config-layers.js";
|
|
10
21
|
import { warning } from "../utils/formatting.js";
|
|
11
22
|
|
|
12
|
-
|
|
23
|
+
// Re-exported for backwards compatibility: ConfigError moved to ./errors.ts so
|
|
24
|
+
// config-discovery.ts can throw it without importing this module (cycle).
|
|
25
|
+
export { ConfigError, isConfigError } from "./errors.js";
|
|
13
26
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
fs.readFileSync(path.join(CLI_ROOT, "package.json"), "utf8")
|
|
20
|
-
) as { version: string };
|
|
21
|
-
export const SOUS_VERSION: string = _pkgJson.version;
|
|
27
|
+
// CLI_ROOT and SOUS_VERSION live in their own module so that modules this one
|
|
28
|
+
// imports can read them without importing this one back. Re-exported here under
|
|
29
|
+
// the names everything already uses.
|
|
30
|
+
import { CLI_ROOT, SOUS_VERSION } from "./package-info.js";
|
|
31
|
+
export { CLI_ROOT, SOUS_VERSION };
|
|
22
32
|
|
|
23
33
|
// --- Variable scope ------------------------------------------------------------------------------
|
|
24
34
|
|
|
@@ -70,6 +80,88 @@ type RawProjectCompilation = {
|
|
|
70
80
|
targets: RawTarget[];
|
|
71
81
|
};
|
|
72
82
|
|
|
83
|
+
/**
|
|
84
|
+
* One trusted repository, keyed in `Settings.repos` by the short name refs use
|
|
85
|
+
* in their `repo:` qualifier. Adding a repo is what trusts it.
|
|
86
|
+
*/
|
|
87
|
+
export type RepoEntry = {
|
|
88
|
+
/** Where the repository lives. */
|
|
89
|
+
url: string;
|
|
90
|
+
/**
|
|
91
|
+
* Whether the repository takes part in anything. Defaults to true; setting it
|
|
92
|
+
* to false is how a project opts out of a repository sous provides itself,
|
|
93
|
+
* without deleting an entry it does not own.
|
|
94
|
+
*/
|
|
95
|
+
enabled?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Which provider handles it. Inferred from the URL when omitted. `local` is a
|
|
98
|
+
* repository on this machine, for local development and tests.
|
|
99
|
+
*/
|
|
100
|
+
provider?: "github" | "gitlab" | "local";
|
|
101
|
+
/** Install a newer in-range version whenever one exists, rather than holding the lock. */
|
|
102
|
+
alwaysPull?: boolean;
|
|
103
|
+
/** When the repo was added. */
|
|
104
|
+
addedAt?: string;
|
|
105
|
+
/** Who required it: "user", or the ref of the recipe whose dependency pulled it in. */
|
|
106
|
+
addedBy?: string;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* One subscription, keyed in `Settings.subscriptions` by a ref key: a bare
|
|
111
|
+
* namespace, or `namespace/recipe`.
|
|
112
|
+
*/
|
|
113
|
+
export type SubscriptionEntry = {
|
|
114
|
+
/**
|
|
115
|
+
* Whether the subscription takes part in anything. Defaults to true; setting
|
|
116
|
+
* it to false is how a project opts out of the `core` namespace sous
|
|
117
|
+
* subscribes it to.
|
|
118
|
+
*/
|
|
119
|
+
enabled?: boolean;
|
|
120
|
+
/** The semantic version range to resolve within. Defaults to "*". */
|
|
121
|
+
range?: string;
|
|
122
|
+
/** Let prerelease versions take part in range matching. */
|
|
123
|
+
prerelease?: boolean;
|
|
124
|
+
/** Per-subscription form of the repo-level always-pull flag. */
|
|
125
|
+
alwaysPull?: boolean;
|
|
126
|
+
/** When the subscription was added. */
|
|
127
|
+
addedAt?: string;
|
|
128
|
+
/** Who required it: "user", or the ref of the recipe that co-subscribed it. */
|
|
129
|
+
addedBy?: string;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Knobs for the machine-wide recipe store. Defaults are applied by the store
|
|
134
|
+
* itself, not here; see config-schema.ts for the values sous ships.
|
|
135
|
+
*/
|
|
136
|
+
type StoreConfig = {
|
|
137
|
+
/** Size cap, past which least-recently-used entries are collected. */
|
|
138
|
+
maxBytes?: number;
|
|
139
|
+
/** How long a fetched index stays fresh before sous re-checks upstream. */
|
|
140
|
+
freshnessSeconds?: number;
|
|
141
|
+
/** How often watch mode polls upstream. */
|
|
142
|
+
watchPollSeconds?: number;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Where the files a subscribed recipe contributes are written, one list of
|
|
147
|
+
* destination directories per content kind. Each destination is `${var}`
|
|
148
|
+
* substituted like any other config path, and a kind may name several so the
|
|
149
|
+
* same recipe feeds more than one agent directory.
|
|
150
|
+
*
|
|
151
|
+
* Only `skills` has a default (`<project root>/.claude/skills`, the project root
|
|
152
|
+
* being the parent of `.sous/`). A kind with no destination is skipped, with one
|
|
153
|
+
* warning naming this config key, because sous cannot guess where a project
|
|
154
|
+
* wants its memories or its prompts.
|
|
155
|
+
*/
|
|
156
|
+
type RecipeOutputs = {
|
|
157
|
+
/** Where recipe skill bundles are written. */
|
|
158
|
+
skills?: string[];
|
|
159
|
+
/** Where recipe memory files are written. */
|
|
160
|
+
memories?: string[];
|
|
161
|
+
/** Where recipe prompt files are written. */
|
|
162
|
+
prompts?: string[];
|
|
163
|
+
};
|
|
164
|
+
|
|
73
165
|
/** Configuration for a launchable tool (e.g. claude, codex). */
|
|
74
166
|
type ToolConfig = {
|
|
75
167
|
/** The executable command to run. */
|
|
@@ -83,72 +175,139 @@ type ToolConfig = {
|
|
|
83
175
|
promptFile?: string;
|
|
84
176
|
};
|
|
85
177
|
|
|
86
|
-
export type
|
|
178
|
+
export type Settings = {
|
|
179
|
+
/** Config schema version. Optional; when present must be 1 (validated at load). */
|
|
180
|
+
version?: number;
|
|
181
|
+
_env?: Record<string, string>;
|
|
87
182
|
/**
|
|
88
|
-
*
|
|
183
|
+
* Config variables. A few names are read by Sous itself:
|
|
89
184
|
* `stateFilePath` overrides where the build state file is written (see
|
|
90
185
|
* StateService.getFilePath), and `pidFilePath` does the same for the watcher
|
|
91
|
-
* PID file. Both resolve through the
|
|
186
|
+
* PID file. Both resolve through the settings scope.
|
|
92
187
|
*/
|
|
93
188
|
_vars?: Record<string, string>;
|
|
94
189
|
_aliases?: Record<string, string | string[]>;
|
|
95
|
-
name
|
|
190
|
+
/** Optional display name for the configured project. */
|
|
191
|
+
name?: string;
|
|
96
192
|
compilation?: RawProjectCompilation;
|
|
97
193
|
runtimeContext?: RawRuntimeContext;
|
|
98
194
|
tools?: Record<string, ToolConfig>;
|
|
195
|
+
/** Trusted repositories, keyed by the short name refs use. */
|
|
196
|
+
repos?: Record<string, RepoEntry>;
|
|
197
|
+
/** Subscriptions, keyed by ref key (`namespace` or `namespace/recipe`). */
|
|
198
|
+
subscriptions?: Record<string, SubscriptionEntry>;
|
|
199
|
+
/** Knobs for the machine-wide recipe store. */
|
|
200
|
+
store?: StoreConfig;
|
|
201
|
+
/**
|
|
202
|
+
* Where the files subscribed recipes contribute are written, per content kind.
|
|
203
|
+
*/
|
|
204
|
+
recipeOutputs?: RecipeOutputs;
|
|
205
|
+
/**
|
|
206
|
+
* Variable mapping records, keyed by environment variable name, each bound to
|
|
207
|
+
* one recipe variable written as `namespace/recipe/variableName` with an
|
|
208
|
+
* optional `repo:` qualifier. The top rung of the answer resolution ladder.
|
|
209
|
+
*/
|
|
210
|
+
varMappings?: Record<string, string>;
|
|
99
211
|
};
|
|
100
212
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
213
|
+
// --- Loader -------------------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
/** Options accepted by loadSettings / loadSettingsWithLayers. */
|
|
216
|
+
export type LoadSettingsOptions = {
|
|
217
|
+
/**
|
|
218
|
+
* When true, the kernel returns one cumulative-config snapshot per top-level
|
|
219
|
+
* layer (the provenance seam behind `sous config get --layers`).
|
|
220
|
+
*/
|
|
221
|
+
trace?: boolean;
|
|
107
222
|
};
|
|
108
223
|
|
|
109
|
-
|
|
224
|
+
/** One trace-mode snapshot: the cumulative config AFTER `path` was merged. */
|
|
225
|
+
export type SettingsLayer = {
|
|
226
|
+
path: string;
|
|
227
|
+
config: unknown;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/** Absolute path to the config kernel subprocess entry (plain .mjs, ships in src/). */
|
|
231
|
+
const CONFIG_KERNEL_PATH = path.join(CLI_ROOT, "src", "lib", "config-kernel.mjs");
|
|
110
232
|
|
|
111
233
|
/**
|
|
112
|
-
* Loads settings from
|
|
113
|
-
*
|
|
114
|
-
* and .json (plain JSON matching the Settings shape).
|
|
234
|
+
* Loads settings from a discovered config (primary file + conf.d layers) and
|
|
235
|
+
* returns the merged result together with any trace-mode layer snapshots.
|
|
115
236
|
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
237
|
+
* Every layer — .js, .mjs, .json and .yaml alike — is loaded by ONE kernel
|
|
238
|
+
* subprocess (src/lib/config-kernel.mjs), which parses/imports each file in
|
|
239
|
+
* order, JSON-forces it, deep-merges it into a live cumulative config, runs
|
|
240
|
+
* `configure(currentConfig, builder)` exports, and serialises the final JSON
|
|
241
|
+
* once. Uniform kernel semantics beat the spawn cost, so there is no
|
|
242
|
+
* parent-side shortcut for plain JSON.
|
|
243
|
+
*
|
|
244
|
+
* Two spawn attempts are made, in this order:
|
|
118
245
|
*
|
|
119
246
|
* 1. Plain Node, no loader. This is what a normal ESM config needs, and it is
|
|
120
247
|
* the only thing that works for a `.sous/sous.config.js` sitting in a repo
|
|
121
248
|
* whose package.json has no `"type": "module"` (under the tsx loader such a
|
|
122
249
|
* file is treated as CJS and dies with ERR_REQUIRE_CYCLE_MODULE).
|
|
123
250
|
* 2. The tsx loader, so a config may use TypeScript syntax and extensionless
|
|
124
|
-
* relative imports.
|
|
251
|
+
* relative imports. (The kernel itself is plain .mjs and runs under both.)
|
|
125
252
|
*
|
|
126
253
|
* The subprocess (rather than a direct `import()`) avoids the require(esm) cycle
|
|
127
|
-
* that tsx triggers in the parent process. Because
|
|
254
|
+
* that tsx triggers in the parent process. Because every layer is round-tripped
|
|
128
255
|
* through JSON, functions, RegExp, Date and undefined values are dropped.
|
|
256
|
+
*
|
|
257
|
+
* @param source - The full DiscoveredConfig, or a bare config file path. A bare
|
|
258
|
+
* string means exactly that one file: no conf.d scan is performed (back-compat
|
|
259
|
+
* for tests and direct callers).
|
|
260
|
+
* @param options - `{ trace }` — see LoadSettingsOptions.
|
|
129
261
|
*/
|
|
130
|
-
/* c8 ignore next
|
|
131
|
-
export async function
|
|
262
|
+
/* c8 ignore next 75 */
|
|
263
|
+
export async function loadSettingsWithLayers(
|
|
264
|
+
source: DiscoveredConfig | string,
|
|
265
|
+
options: LoadSettingsOptions = {}
|
|
266
|
+
): Promise<{ settings: Settings; layers: SettingsLayer[] }> {
|
|
267
|
+
let configPath: string;
|
|
268
|
+
let sousDir: string;
|
|
269
|
+
let confDir: string;
|
|
270
|
+
let layerPaths: string[];
|
|
271
|
+
let recipeLayers: RecipeConfigLayer[];
|
|
272
|
+
|
|
273
|
+
if (typeof source === "string") {
|
|
274
|
+
configPath = path.resolve(source);
|
|
275
|
+
sousDir = path.dirname(configPath);
|
|
276
|
+
confDir = path.join(sousDir, CONFD_DIR_NAME);
|
|
277
|
+
layerPaths = [configPath];
|
|
278
|
+
recipeLayers = [];
|
|
279
|
+
} else {
|
|
280
|
+
({ configPath, sousDir, confDir, layerPaths } = source);
|
|
281
|
+
recipeLayers = source.recipeLayers ?? [];
|
|
282
|
+
}
|
|
283
|
+
|
|
132
284
|
if (!fs.existsSync(configPath)) {
|
|
133
285
|
throw new Error(`Settings file not found: ${configPath}`);
|
|
134
286
|
}
|
|
135
287
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
288
|
+
// A recipe layer is handed to the kernel as content, not as a path, because
|
|
289
|
+
// it has already been read and filtered down to the keys a recipe may set
|
|
290
|
+
// (see `repos/recipe-config-layers.ts`). The kernel never opens the file, so
|
|
291
|
+
// there is no second, unfiltered reading of it.
|
|
292
|
+
const recipeLayerByPath = new Map(recipeLayers.map((layer) => [layer.path, layer]));
|
|
293
|
+
const sources = layerPaths.map((layerPath) => {
|
|
294
|
+
const recipeLayer = recipeLayerByPath.get(layerPath);
|
|
295
|
+
if (recipeLayer === undefined) return layerPath;
|
|
296
|
+
return { path: recipeLayer.path, config: recipeLayer.config };
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const kernelInput = JSON.stringify({
|
|
300
|
+
sources,
|
|
301
|
+
context: {
|
|
302
|
+
sousDir,
|
|
303
|
+
confDir,
|
|
304
|
+
sousRootPath: CLI_ROOT,
|
|
305
|
+
sousVersion: SOUS_VERSION,
|
|
306
|
+
configPath,
|
|
307
|
+
},
|
|
308
|
+
trace: options.trace === true,
|
|
309
|
+
});
|
|
145
310
|
|
|
146
|
-
const settingsUrl = pathToFileURL(configPath).href;
|
|
147
|
-
const loaderScript = `
|
|
148
|
-
const mod = await import(${JSON.stringify(settingsUrl)});
|
|
149
|
-
const raw = mod.config ?? mod.default ?? mod;
|
|
150
|
-
process.stdout.write(JSON.stringify(raw));
|
|
151
|
-
`;
|
|
152
311
|
// Resolve tsx via module resolution so this works when npm hoists the
|
|
153
312
|
// dependency (local install, npx) as well as when it nests it (global,
|
|
154
313
|
// repo clone).
|
|
@@ -160,67 +319,119 @@ export async function loadSettings(configPath: string): Promise<Settings> {
|
|
|
160
319
|
}
|
|
161
320
|
|
|
162
321
|
const attempts: { label: string; args: string[] }[] = [
|
|
163
|
-
{ label: "node", args: [
|
|
164
|
-
{ label: "tsx", args: ["--import", tsxPath,
|
|
322
|
+
{ label: "node", args: [CONFIG_KERNEL_PATH] },
|
|
323
|
+
{ label: "tsx", args: ["--import", tsxPath, CONFIG_KERNEL_PATH] },
|
|
165
324
|
];
|
|
166
325
|
|
|
167
|
-
const failures: string[] = [];
|
|
326
|
+
const failures: { label: string; message: string }[] = [];
|
|
168
327
|
|
|
169
328
|
for (const attempt of attempts) {
|
|
170
329
|
const result = spawnSync(process.execPath, attempt.args, {
|
|
171
|
-
input:
|
|
330
|
+
input: kernelInput,
|
|
172
331
|
encoding: "utf8",
|
|
173
332
|
});
|
|
174
333
|
|
|
175
334
|
if (result.status === 0) {
|
|
335
|
+
let parsed: { config: unknown; layers?: SettingsLayer[] };
|
|
176
336
|
try {
|
|
177
|
-
|
|
337
|
+
parsed = JSON.parse(result.stdout) as { config: unknown; layers?: SettingsLayer[] };
|
|
178
338
|
} catch {
|
|
179
339
|
throw new Error(
|
|
180
|
-
`Config at ${configPath} did not produce valid JSON.
|
|
181
|
-
`object (
|
|
340
|
+
`Config at ${configPath} did not produce valid JSON. Every layer must resolve ` +
|
|
341
|
+
`to a plain object (a \`config\`/default export, or a \`configure\` function).\n` +
|
|
342
|
+
` Got: ${result.stdout.slice(0, 300)}`
|
|
182
343
|
);
|
|
183
344
|
}
|
|
345
|
+
// assertFlatConfig runs FIRST: its multi-project migration message is more
|
|
346
|
+
// actionable than a generic unknown-key error. validateSettings then checks
|
|
347
|
+
// the MERGED config against the zod schema (version, strict keys, shapes).
|
|
348
|
+
const flat = assertFlatConfig(parsed.config, configPath);
|
|
349
|
+
// The built-in repository and the implicit `core` subscription are added
|
|
350
|
+
// UNDER whatever the layers produced, and BEFORE validation, so that the
|
|
351
|
+
// shortest opt-out a project can write (`{ enabled: false }`) is a
|
|
352
|
+
// complete, valid entry once the built-in fields are underneath it. See
|
|
353
|
+
// `repos/defaults.ts`.
|
|
354
|
+
return {
|
|
355
|
+
settings: validateSettings(applyRepoDefaults(flat), configPath),
|
|
356
|
+
layers: parsed.layers ?? [],
|
|
357
|
+
};
|
|
184
358
|
}
|
|
185
359
|
|
|
186
|
-
failures.push(
|
|
360
|
+
failures.push({ label: attempt.label, message: result.stderr?.trim() || "unknown error" });
|
|
187
361
|
}
|
|
188
362
|
|
|
189
|
-
|
|
363
|
+
// Kernel-side errors (bad JSON/YAML, old schema, configure() throw, cycle,
|
|
364
|
+
// bad builder var, non-object layer) are raised inside the .mjs kernel, which
|
|
365
|
+
// runs identically under both the `node` and `tsx` attempts — so both stderrs
|
|
366
|
+
// are the same. Collapse identical messages so a single config error is not
|
|
367
|
+
// printed twice as if it were two distinct failures.
|
|
368
|
+
//
|
|
369
|
+
// The tsx attempt re-imports an ESM `.js` config that the `node` attempt
|
|
370
|
+
// already loaded and reported a real error for; on the tsx attempt this
|
|
371
|
+
// surfaces as a spurious "Cannot require() ES Module … in a cycle"
|
|
372
|
+
// (ERR_REQUIRE_CYCLE_MODULE) that is never a real user-config problem. Drop it
|
|
373
|
+
// whenever another attempt produced a genuine error, so the real message is
|
|
374
|
+
// not buried under an unrelated require(esm)-cycle warning.
|
|
375
|
+
const isTsxCycleArtifact = (message: string): boolean =>
|
|
376
|
+
/ERR_REQUIRE_CYCLE_MODULE/.test(message) ||
|
|
377
|
+
/Cannot require\(\) ES Module .* in a cycle/.test(message);
|
|
378
|
+
const meaningfulFailures = failures.filter((f) => !isTsxCycleArtifact(f.message));
|
|
379
|
+
const effectiveFailures = meaningfulFailures.length > 0 ? meaningfulFailures : failures;
|
|
380
|
+
|
|
381
|
+
const uniqueMessages = [...new Set(effectiveFailures.map((f) => f.message))];
|
|
382
|
+
const detail =
|
|
383
|
+
uniqueMessages.length === 1
|
|
384
|
+
? ` ${uniqueMessages[0]}`
|
|
385
|
+
: effectiveFailures.map((f) => ` [via ${f.label}] ${f.message}`).join("\n\n");
|
|
386
|
+
|
|
387
|
+
throw new ConfigError(`Failed to load config from ${configPath}\n${detail}`);
|
|
190
388
|
}
|
|
191
389
|
|
|
192
|
-
// --- Variable Resolution -------------------------------------------------------------------------
|
|
193
|
-
|
|
194
390
|
/**
|
|
195
|
-
*
|
|
196
|
-
*
|
|
391
|
+
* Loads the merged settings for a discovered config (or a bare config file
|
|
392
|
+
* path). Thin wrapper over loadSettingsWithLayers for callers that do not need
|
|
393
|
+
* the trace-mode layer snapshots.
|
|
197
394
|
*/
|
|
198
|
-
export function
|
|
199
|
-
|
|
395
|
+
export async function loadSettings(
|
|
396
|
+
source: DiscoveredConfig | string,
|
|
397
|
+
options: LoadSettingsOptions = {}
|
|
398
|
+
): Promise<Settings> {
|
|
399
|
+
const { settings } = await loadSettingsWithLayers(source, options);
|
|
400
|
+
return settings;
|
|
200
401
|
}
|
|
201
402
|
|
|
202
403
|
/**
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
404
|
+
* Rejects configs written in the removed multi-project schema. One config now
|
|
405
|
+
* describes exactly one project; the fields that used to live inside a
|
|
406
|
+
* `projects.<key>` entry sit at the top level instead.
|
|
206
407
|
*/
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
408
|
+
function assertFlatConfig(raw: unknown, configPath: string): Settings {
|
|
409
|
+
if (
|
|
410
|
+
raw !== null &&
|
|
411
|
+
typeof raw === "object" &&
|
|
412
|
+
("projects" in raw || "defaultProject" in raw)
|
|
413
|
+
) {
|
|
414
|
+
throw new ConfigError(
|
|
415
|
+
`Config at ${configPath} uses the removed multi-project schema ` +
|
|
416
|
+
`('projects' / 'defaultProject').\n` +
|
|
417
|
+
` A sous config now describes exactly one project. To migrate:\n` +
|
|
418
|
+
` 1. Move your single project's fields (name, _vars, _aliases, compilation,\n` +
|
|
419
|
+
` runtimeContext, tools) to the top level of the config.\n` +
|
|
420
|
+
` 2. Delete the 'projects' and 'defaultProject' keys.\n` +
|
|
421
|
+
` A config with several projects must be split into one config per project.`
|
|
422
|
+
);
|
|
213
423
|
}
|
|
424
|
+
return raw as Settings;
|
|
214
425
|
}
|
|
215
426
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
);
|
|
427
|
+
// --- Variable Resolution -------------------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Substitutes ${varName} references in a string using the provided scope.
|
|
431
|
+
* Unknown variable references are left as-is.
|
|
432
|
+
*/
|
|
433
|
+
export function substituteVars(str: string, scope: VarScope): string {
|
|
434
|
+
return str.replace(/\$\{([^}]+)\}/g, (match, name: string) => scope[name] ?? match);
|
|
224
435
|
}
|
|
225
436
|
|
|
226
437
|
/** Returns the names of every `${var}` reference left unresolved in a string. */
|
|
@@ -258,7 +469,7 @@ export function normalizeConfigPath(value: string): string {
|
|
|
258
469
|
* @param str - The raw value from the config.
|
|
259
470
|
* @param scope - The resolved variable scope.
|
|
260
471
|
* @param context - Where the value came from, e.g.
|
|
261
|
-
* `
|
|
472
|
+
* `compilation.targets[0].entryPoint`. Named in the error.
|
|
262
473
|
* @returns The fully substituted string.
|
|
263
474
|
* @throws When one or more `${var}` references are unresolved.
|
|
264
475
|
*/
|
|
@@ -284,10 +495,20 @@ export function substituteVarsStrict(str: string, scope: VarScope, context: stri
|
|
|
284
495
|
}
|
|
285
496
|
|
|
286
497
|
/**
|
|
287
|
-
* Resolves a _vars block into a new scope
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
498
|
+
* Resolves a _vars block into a new scope with a FIXPOINT loop:
|
|
499
|
+
*
|
|
500
|
+
* 1. Start from the inherited scope. Every block entry begins unresolved.
|
|
501
|
+
* 2. Each round, substitute every still-unresolved entry against the current
|
|
502
|
+
* scope (inherited vars + entries already finalized this pass). An entry
|
|
503
|
+
* whose `${refs}` all resolve is finalized and added to the scope.
|
|
504
|
+
* 3. Repeat until a round finalizes nothing.
|
|
505
|
+
*
|
|
506
|
+
* Because each round re-scans every unresolved entry, declaration order does not
|
|
507
|
+
* matter: `{ file: "${root}/x", root: "/data" }` resolves as readily as the
|
|
508
|
+
* reverse. When progress stops with entries still unresolved, that is a hard
|
|
509
|
+
* error (see buildUnresolvedScopeError): a `${ref}` that never resolves — a typo,
|
|
510
|
+
* a cycle, or a name defined nowhere — is a config mistake, not a literal to be
|
|
511
|
+
* passed through silently.
|
|
291
512
|
*/
|
|
292
513
|
export function resolveScope(block: Record<string, string>, inherited: VarScope): VarScope {
|
|
293
514
|
const blockKeys = Object.keys(block);
|
|
@@ -299,45 +520,158 @@ export function resolveScope(block: Record<string, string>, inherited: VarScope)
|
|
|
299
520
|
}
|
|
300
521
|
}
|
|
301
522
|
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
523
|
+
const scope: VarScope = { ...inherited };
|
|
524
|
+
const unresolved = new Set(blockKeys);
|
|
525
|
+
|
|
526
|
+
let progressed = true;
|
|
527
|
+
while (progressed && unresolved.size > 0) {
|
|
528
|
+
progressed = false;
|
|
529
|
+
for (const key of unresolved) {
|
|
530
|
+
const substituted = substituteVars(block[key], scope);
|
|
531
|
+
if (findUnresolvedVars(substituted).length === 0) {
|
|
532
|
+
scope[key] = substituted;
|
|
533
|
+
unresolved.delete(key);
|
|
534
|
+
progressed = true;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (unresolved.size > 0) {
|
|
540
|
+
throw buildUnresolvedScopeError(block, scope, unresolved);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return scope;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Builds the ConfigError thrown when resolveScope's fixpoint stops with entries
|
|
548
|
+
* still unresolved. The message names each unresolved entry and the exact
|
|
549
|
+
* `${names}` it still needs, then separates the two failure modes: reference
|
|
550
|
+
* CYCLES (entries that depend on each other, no starting point) and UNDEFINED
|
|
551
|
+
* references (names defined nowhere). It closes with the variables that ARE in
|
|
552
|
+
* scope, so a typo is obvious at a glance.
|
|
553
|
+
*
|
|
554
|
+
* @param block - The raw _vars block being resolved.
|
|
555
|
+
* @param scope - The working scope (inherited vars + every entry that DID
|
|
556
|
+
* resolve); its keys are the "in scope" list.
|
|
557
|
+
* @param unresolved - The block keys that never resolved.
|
|
558
|
+
*/
|
|
559
|
+
function buildUnresolvedScopeError(
|
|
560
|
+
block: Record<string, string>,
|
|
561
|
+
scope: VarScope,
|
|
562
|
+
unresolved: Set<string>
|
|
563
|
+
): ConfigError {
|
|
564
|
+
// For each unresolved entry, the ${names} still missing after fixpoint. Every
|
|
565
|
+
// such name is either another unresolved block key (an intra-block edge) or a
|
|
566
|
+
// name defined nowhere (resolvable block keys and inherited vars are already
|
|
567
|
+
// in `scope`, so they never appear here).
|
|
568
|
+
const needs = new Map<string, string[]>();
|
|
569
|
+
for (const key of unresolved) {
|
|
570
|
+
needs.set(key, findUnresolvedVars(substituteVars(block[key], scope)));
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Dependency graph among unresolved entries: u -> v when u still needs the
|
|
574
|
+
// still-unresolved block key v. SCCs of this graph are the reference cycles.
|
|
575
|
+
const edges = new Map<string, string[]>();
|
|
576
|
+
for (const key of unresolved) {
|
|
577
|
+
edges.set(key, (needs.get(key) ?? []).filter((n) => unresolved.has(n)));
|
|
578
|
+
}
|
|
579
|
+
const cycles = findCycles([...unresolved], edges);
|
|
580
|
+
|
|
581
|
+
// Undefined references: needed names that are not block keys at all.
|
|
582
|
+
const undefinedRefs = new Map<string, string[]>();
|
|
583
|
+
for (const key of unresolved) {
|
|
584
|
+
for (const name of needs.get(key) ?? []) {
|
|
585
|
+
if (!unresolved.has(name)) {
|
|
586
|
+
const referrers = undefinedRefs.get(name) ?? [];
|
|
587
|
+
referrers.push(key);
|
|
588
|
+
undefinedRefs.set(name, referrers);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const lines: string[] = [
|
|
594
|
+
"Unresolved variables after fixpoint resolution of a _vars block:",
|
|
595
|
+
];
|
|
596
|
+
for (const key of [...unresolved].sort()) {
|
|
597
|
+
const names = (needs.get(key) ?? []).map((n) => `\${${n}}`).join(", ");
|
|
598
|
+
lines.push(` - ${key} still needs ${names || "(nothing resolvable)"}`);
|
|
307
599
|
}
|
|
308
600
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
if (visited.has(key)) return;
|
|
316
|
-
if (visiting.has(key)) {
|
|
317
|
-
// Circular dep — add as-is to avoid infinite loop
|
|
318
|
-
sorted.push(key);
|
|
319
|
-
return;
|
|
601
|
+
if (cycles.length > 0) {
|
|
602
|
+
lines.push("");
|
|
603
|
+
lines.push("Reference cycles (these variables reference each other):");
|
|
604
|
+
for (const cycle of cycles) {
|
|
605
|
+
const sorted = [...cycle].sort();
|
|
606
|
+
lines.push(` - ${[...sorted, sorted[0]].join(" -> ")}`);
|
|
320
607
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (undefinedRefs.size > 0) {
|
|
611
|
+
lines.push("");
|
|
612
|
+
lines.push("Undefined references (names defined nowhere — no _vars, _env, or auto-var):");
|
|
613
|
+
for (const name of [...undefinedRefs.keys()].sort()) {
|
|
614
|
+
const referrers = [...new Set(undefinedRefs.get(name) ?? [])].sort();
|
|
615
|
+
lines.push(` - \${${name}} (needed by ${referrers.join(", ")})`);
|
|
324
616
|
}
|
|
325
|
-
visiting.delete(key);
|
|
326
|
-
visited.add(key);
|
|
327
|
-
sorted.push(key);
|
|
328
617
|
}
|
|
329
618
|
|
|
330
|
-
|
|
331
|
-
|
|
619
|
+
const inScope = Object.keys(scope).sort();
|
|
620
|
+
lines.push("");
|
|
621
|
+
lines.push(`Variables in scope here: ${inScope.length > 0 ? inScope.join(", ") : "(none)"}`);
|
|
622
|
+
|
|
623
|
+
return new ConfigError(lines.join("\n"));
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Finds reference cycles in a directed graph via Tarjan's strongly-connected-
|
|
628
|
+
* component algorithm. A cycle is an SCC with more than one member, or a single
|
|
629
|
+
* node that references itself. Returns each cycle as its member list.
|
|
630
|
+
*/
|
|
631
|
+
function findCycles(nodes: string[], edges: Map<string, string[]>): string[][] {
|
|
632
|
+
const index = new Map<string, number>();
|
|
633
|
+
const low = new Map<string, number>();
|
|
634
|
+
const onStack = new Set<string>();
|
|
635
|
+
const stack: string[] = [];
|
|
636
|
+
const sccs: string[][] = [];
|
|
637
|
+
let counter = 0;
|
|
638
|
+
|
|
639
|
+
function strongconnect(v: string): void {
|
|
640
|
+
index.set(v, counter);
|
|
641
|
+
low.set(v, counter);
|
|
642
|
+
counter++;
|
|
643
|
+
stack.push(v);
|
|
644
|
+
onStack.add(v);
|
|
645
|
+
|
|
646
|
+
for (const w of edges.get(v) ?? []) {
|
|
647
|
+
if (!index.has(w)) {
|
|
648
|
+
strongconnect(w);
|
|
649
|
+
low.set(v, Math.min(low.get(v)!, low.get(w)!));
|
|
650
|
+
} else if (onStack.has(w)) {
|
|
651
|
+
low.set(v, Math.min(low.get(v)!, index.get(w)!));
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (low.get(v) === index.get(v)) {
|
|
656
|
+
const component: string[] = [];
|
|
657
|
+
let w: string;
|
|
658
|
+
do {
|
|
659
|
+
w = stack.pop()!;
|
|
660
|
+
onStack.delete(w);
|
|
661
|
+
component.push(w);
|
|
662
|
+
} while (w !== v);
|
|
663
|
+
sccs.push(component);
|
|
664
|
+
}
|
|
332
665
|
}
|
|
333
666
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
for (const key of sorted) {
|
|
337
|
-
scope[key] = substituteVars(block[key], scope);
|
|
667
|
+
for (const v of nodes) {
|
|
668
|
+
if (!index.has(v)) strongconnect(v);
|
|
338
669
|
}
|
|
339
670
|
|
|
340
|
-
return
|
|
671
|
+
return sccs.filter(
|
|
672
|
+
(component) =>
|
|
673
|
+
component.length > 1 || (edges.get(component[0]) ?? []).includes(component[0])
|
|
674
|
+
);
|
|
341
675
|
}
|
|
342
676
|
|
|
343
677
|
/**
|
|
@@ -348,8 +682,12 @@ export function resolveScope(block: Record<string, string>, inherited: VarScope)
|
|
|
348
682
|
export type ConfigContext = {
|
|
349
683
|
/** Absolute path to the `.sous/` directory holding the config. */
|
|
350
684
|
sousDir: string;
|
|
351
|
-
/** Absolute path to the config file itself. */
|
|
685
|
+
/** Absolute path to the primary config file itself. */
|
|
352
686
|
configPath: string;
|
|
687
|
+
/** Absolute path to the `conf.d/` drop-in directory (may not exist). */
|
|
688
|
+
confDir?: string;
|
|
689
|
+
/** Ordered absolute paths of every loaded config layer (primary first). */
|
|
690
|
+
layerPaths?: string[];
|
|
353
691
|
};
|
|
354
692
|
|
|
355
693
|
/**
|
|
@@ -357,18 +695,26 @@ export type ConfigContext = {
|
|
|
357
695
|
* and injected first, before _env and _vars.
|
|
358
696
|
* The 'sous*' namespace is reserved — warns if user defines a var starting with 'sous'.
|
|
359
697
|
*
|
|
698
|
+
* `sousHome` is resolved from `process.env` on every call rather than captured
|
|
699
|
+
* once, because `SOUS_HOME` is file-settable: `.sous/.env.local` and
|
|
700
|
+
* `.sous/.env` are loaded into `process.env` before settings resolve.
|
|
701
|
+
*
|
|
360
702
|
* @param context - The discovered config location. When supplied, adds
|
|
361
|
-
* `sousDir` and `sousConfigPath`
|
|
362
|
-
* their own `.sous/` directory.
|
|
703
|
+
* `sousDir` and `sousConfigPath` (plus `sousConfDir` when known) so configs
|
|
704
|
+
* can build paths relative to their own `.sous/` directory.
|
|
363
705
|
*/
|
|
364
706
|
export function buildAutoVars(context?: ConfigContext): VarScope {
|
|
365
707
|
return {
|
|
366
708
|
sousRootPath: CLI_ROOT,
|
|
367
709
|
sousVersion: SOUS_VERSION,
|
|
710
|
+
sousHome: resolveSousHome(),
|
|
368
711
|
...(context !== undefined && {
|
|
369
712
|
sousDir: context.sousDir,
|
|
370
713
|
sousConfigPath: context.configPath,
|
|
371
714
|
}),
|
|
715
|
+
...(context?.confDir !== undefined && {
|
|
716
|
+
sousConfDir: context.confDir,
|
|
717
|
+
}),
|
|
372
718
|
};
|
|
373
719
|
}
|
|
374
720
|
|
|
@@ -428,39 +774,36 @@ export function resolveRootScope(settings: Settings, context?: ConfigContext): V
|
|
|
428
774
|
/**
|
|
429
775
|
* Built-in `@include` aliases, always available and reserved (their names begin
|
|
430
776
|
* with `~` so user `_aliases` can never shadow them). Add new entries here as
|
|
431
|
-
* needed
|
|
777
|
+
* needed; keep names kebab-case.
|
|
432
778
|
*
|
|
433
|
-
* - `~
|
|
434
|
-
*
|
|
435
|
-
*
|
|
779
|
+
* - `~project` → the consuming project's root (`projectRoot`).
|
|
780
|
+
*
|
|
781
|
+
* There is exactly one, on purpose. Files that used to be reached through a
|
|
782
|
+
* built-in alias pointing inside the sous package are published as recipes now,
|
|
783
|
+
* and a recipe's files are addressed by its namespace (`@~workflow/task-files/
|
|
784
|
+
* _partials/resume-task.md`), resolved against what the project has pinned. A
|
|
785
|
+
* `~namespace` reference is NOT an alias: it is resolved separately, after the
|
|
786
|
+
* alias map has been tried; see `locked-namespace-resolver.ts`.
|
|
436
787
|
*/
|
|
437
788
|
export function buildBuiltInAliases(scope: VarScope): AliasMap {
|
|
438
|
-
const
|
|
439
|
-
const builtIns: AliasMap = {
|
|
440
|
-
"~sous-shared": [path.join(sousRoot, "shared-prompts")],
|
|
441
|
-
};
|
|
789
|
+
const builtIns: AliasMap = {};
|
|
442
790
|
if (scope.projectRoot) builtIns["~project"] = [scope.projectRoot];
|
|
443
791
|
return builtIns;
|
|
444
792
|
}
|
|
445
793
|
|
|
446
794
|
/**
|
|
447
|
-
* Resolve the full `@include` alias map
|
|
448
|
-
* `_aliases
|
|
449
|
-
*
|
|
795
|
+
* Resolve the full `@include` alias map: built-ins, then the config's
|
|
796
|
+
* `_aliases` block (user entries prepend, so they are tried first and fall
|
|
797
|
+
* through to built-in bases of the same name). User alias names starting
|
|
450
798
|
* with `~` are rejected (reserved).
|
|
451
799
|
*
|
|
452
|
-
* @param settings - The
|
|
453
|
-
* @param
|
|
454
|
-
* @param scope - The resolved project scope (for ${var} substitution + projectRoot).
|
|
800
|
+
* @param settings - The loaded settings (for the `_aliases` block).
|
|
801
|
+
* @param scope - The resolved settings scope (for ${var} substitution + projectRoot).
|
|
455
802
|
*/
|
|
456
|
-
export function resolveAliases(
|
|
457
|
-
settings: Settings,
|
|
458
|
-
project: RawProject,
|
|
459
|
-
scope: VarScope
|
|
460
|
-
): AliasMap {
|
|
803
|
+
export function resolveAliases(settings: Settings, scope: VarScope): AliasMap {
|
|
461
804
|
return buildAliasMap({
|
|
462
805
|
builtIns: buildBuiltInAliases(scope),
|
|
463
|
-
userAliases: [settings._aliases
|
|
806
|
+
userAliases: [settings._aliases],
|
|
464
807
|
scope,
|
|
465
808
|
onError: warning,
|
|
466
809
|
});
|
|
@@ -474,20 +817,20 @@ export type ResolvedToolConfig = {
|
|
|
474
817
|
};
|
|
475
818
|
|
|
476
819
|
/**
|
|
477
|
-
* Resolves
|
|
820
|
+
* Resolves the config's tools block, substituting vars in promptFile paths.
|
|
478
821
|
* Returns an empty object if no tools are configured.
|
|
822
|
+
*
|
|
823
|
+
* @param settings - The loaded settings.
|
|
824
|
+
* @param scope - The resolved settings scope (from resolveRootScope).
|
|
479
825
|
*/
|
|
480
|
-
export function
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
projectKey = project.name
|
|
826
|
+
export function resolveTools(
|
|
827
|
+
settings: Settings,
|
|
828
|
+
scope: VarScope = {}
|
|
484
829
|
): Record<string, ResolvedToolConfig> {
|
|
485
|
-
if (!
|
|
486
|
-
|
|
487
|
-
const projectScope = resolveScope(project._vars ?? {}, rootScope);
|
|
830
|
+
if (!settings.tools) return {};
|
|
488
831
|
|
|
489
832
|
return Object.fromEntries(
|
|
490
|
-
Object.entries(
|
|
833
|
+
Object.entries(settings.tools).map(([name, tool]) => [
|
|
491
834
|
name,
|
|
492
835
|
{
|
|
493
836
|
command: tool.command,
|
|
@@ -495,8 +838,8 @@ export function resolveProjectTools(
|
|
|
495
838
|
...(tool.promptFile !== undefined && {
|
|
496
839
|
promptFile: substituteVarsStrict(
|
|
497
840
|
tool.promptFile,
|
|
498
|
-
|
|
499
|
-
`
|
|
841
|
+
scope,
|
|
842
|
+
`tools.${name}.promptFile`
|
|
500
843
|
),
|
|
501
844
|
}),
|
|
502
845
|
},
|
|
@@ -524,30 +867,27 @@ function resolveRuntimeContext(
|
|
|
524
867
|
}
|
|
525
868
|
|
|
526
869
|
/**
|
|
527
|
-
* Resolves
|
|
528
|
-
* Walks the config tree resolving _vars at each level (
|
|
529
|
-
* Pass
|
|
530
|
-
* Returns null if the
|
|
870
|
+
* Resolves the config's compilation block into the shape the compiler expects.
|
|
871
|
+
* Walks the config tree resolving _vars at each level (settings → compilation → target → output).
|
|
872
|
+
* Pass scope from resolveRootScope(settings) to thread the settings vars down.
|
|
873
|
+
* Returns null if the config has no compilation block.
|
|
531
874
|
*/
|
|
532
|
-
export function
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
settings: Settings = { projects: {} },
|
|
536
|
-
projectKey = project.name
|
|
875
|
+
export function resolveCompilation(
|
|
876
|
+
settings: Settings,
|
|
877
|
+
scope: VarScope = {}
|
|
537
878
|
): CompilationConfig | null {
|
|
538
|
-
if (!
|
|
879
|
+
if (!settings.compilation) return null;
|
|
539
880
|
|
|
540
|
-
const
|
|
541
|
-
const
|
|
542
|
-
const aliases = resolveAliases(settings, project, projectScope);
|
|
881
|
+
const compilationScope = resolveScope(settings.compilation._vars ?? {}, scope);
|
|
882
|
+
const aliases = resolveAliases(settings, scope);
|
|
543
883
|
|
|
544
884
|
return {
|
|
545
|
-
includeSourceComments:
|
|
885
|
+
includeSourceComments: settings.compilation.includeSourceComments,
|
|
546
886
|
aliases,
|
|
547
|
-
includeScope:
|
|
548
|
-
targets:
|
|
887
|
+
includeScope: scope,
|
|
888
|
+
targets: settings.compilation.targets.flatMap((target, targetIndex): CompilationTarget[] => {
|
|
549
889
|
const targetScope = resolveScope(target._vars ?? {}, compilationScope);
|
|
550
|
-
const where = `
|
|
890
|
+
const where = `compilation.targets[${targetIndex}]`;
|
|
551
891
|
const hasSingle = target.entryPoint !== undefined;
|
|
552
892
|
const hasGlob = target.entryGlob !== undefined;
|
|
553
893
|
|
|
@@ -600,12 +940,8 @@ export function resolveProjectCompilation(
|
|
|
600
940
|
|
|
601
941
|
if (hasSingle) {
|
|
602
942
|
const runtimeContext =
|
|
603
|
-
target.generateRuntimeContext &&
|
|
604
|
-
? resolveRuntimeContext(
|
|
605
|
-
project.runtimeContext,
|
|
606
|
-
projectScope,
|
|
607
|
-
`project '${projectKey}' → runtimeContext`
|
|
608
|
-
)
|
|
943
|
+
target.generateRuntimeContext && settings.runtimeContext
|
|
944
|
+
? resolveRuntimeContext(settings.runtimeContext, scope, "runtimeContext")
|
|
609
945
|
: undefined;
|
|
610
946
|
return [{
|
|
611
947
|
rootInputPath: normalizeConfigPath(
|
|
@@ -617,13 +953,26 @@ export function resolveProjectCompilation(
|
|
|
617
953
|
}
|
|
618
954
|
|
|
619
955
|
/* c8 ignore start */
|
|
620
|
-
// Glob target: expand pattern into one CompilationTarget per matched file, skipping dirs
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
956
|
+
// Glob target: expand pattern into one CompilationTarget per matched file, skipping dirs.
|
|
957
|
+
// A leading alias (`~project/skills/**`) expands to one candidate pattern per alias
|
|
958
|
+
// base; the first base that matches any files wins, mirroring the first-existing-wins
|
|
959
|
+
// rule of @include resolution.
|
|
960
|
+
const rawPattern = substituteVarsStrict(target.entryGlob!, targetScope, `${where}.entryGlob`);
|
|
961
|
+
const patternCandidates = resolveAliasPrefix(rawPattern, aliases);
|
|
962
|
+
let pattern = patternCandidates[0];
|
|
963
|
+
let matchedFiles: string[] = [];
|
|
964
|
+
for (const candidate of patternCandidates) {
|
|
965
|
+
const matches = globSync(candidate, { absolute: true })
|
|
966
|
+
.filter(filePath => fs.statSync(filePath).isFile());
|
|
967
|
+
if (matches.length > 0) {
|
|
968
|
+
pattern = candidate;
|
|
969
|
+
matchedFiles = matches;
|
|
970
|
+
break;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
624
973
|
|
|
625
974
|
if (matchedFiles.length === 0) {
|
|
626
|
-
warning(`Glob pattern matched no files:\n${
|
|
975
|
+
warning(`Glob pattern matched no files:\n${patternCandidates.join("\n")}`);
|
|
627
976
|
}
|
|
628
977
|
|
|
629
978
|
const globBase = normalizeConfigPath(
|
|
@@ -658,33 +1007,41 @@ export type WatchConfig = {
|
|
|
658
1007
|
};
|
|
659
1008
|
|
|
660
1009
|
/**
|
|
661
|
-
* Returns the watch configuration for
|
|
1010
|
+
* Returns the watch configuration for the config's compilation targets.
|
|
662
1011
|
*
|
|
663
1012
|
* - entryPoint targets → exact file path in `files`.
|
|
664
1013
|
* - entryGlob targets → resolved glob string in `globs`.
|
|
1014
|
+
*
|
|
1015
|
+
* Alias prefixes in entryGlob patterns are expanded (every base of the alias
|
|
1016
|
+
* is watched, matching compile's fall-through resolution).
|
|
665
1017
|
*/
|
|
666
|
-
export function resolveWatchConfig(
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
if (!project.compilation) return { files: [], globs: [] };
|
|
672
|
-
|
|
673
|
-
const projectScope = resolveScope(project._vars ?? {}, rootScope);
|
|
674
|
-
const compilationScope = resolveScope(project.compilation._vars ?? {}, projectScope);
|
|
1018
|
+
export function resolveWatchConfig(settings: Settings, scope: VarScope = {}): WatchConfig {
|
|
1019
|
+
if (!settings.compilation) return { files: [], globs: [] };
|
|
1020
|
+
|
|
1021
|
+
const compilationScope = resolveScope(settings.compilation._vars ?? {}, scope);
|
|
1022
|
+
const aliases = resolveAliases(settings, scope);
|
|
675
1023
|
const files: string[] = [];
|
|
676
1024
|
const globs: string[] = [];
|
|
677
1025
|
|
|
678
|
-
for (const [targetIndex, target] of
|
|
1026
|
+
for (const [targetIndex, target] of settings.compilation.targets.entries()) {
|
|
679
1027
|
const targetScope = resolveScope(target._vars ?? {}, compilationScope);
|
|
680
|
-
const where = `
|
|
1028
|
+
const where = `compilation.targets[${targetIndex}]`;
|
|
681
1029
|
|
|
682
1030
|
if (target.entryPoint) {
|
|
683
|
-
|
|
1031
|
+
// Normalize to match the compilation path (rootInputPath) and, more
|
|
1032
|
+
// importantly, chokidar's change events: chokidar reports the OS-normalized
|
|
1033
|
+
// path, so an unnormalized `${sousDir}/../prompts/A.md` entry would never
|
|
1034
|
+
// string-match the `/prompts/A.md` event and the partial rebuild would never fire.
|
|
1035
|
+
files.push(
|
|
1036
|
+
normalizeConfigPath(
|
|
1037
|
+
substituteVarsStrict(target.entryPoint, targetScope, `${where}.entryPoint`)
|
|
1038
|
+
)
|
|
1039
|
+
);
|
|
684
1040
|
}
|
|
685
1041
|
|
|
686
1042
|
if (target.entryGlob) {
|
|
687
|
-
|
|
1043
|
+
const pattern = substituteVarsStrict(target.entryGlob, targetScope, `${where}.entryGlob`);
|
|
1044
|
+
globs.push(...resolveAliasPrefix(pattern, aliases).map(normalizeConfigPath));
|
|
688
1045
|
}
|
|
689
1046
|
}
|
|
690
1047
|
|