@sous-io/sous 0.1.0 → 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 +121 -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 +73 -9
- 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/bin/xcv +0 -5
- 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
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { ConfigError } from "./errors.js";
|
|
4
|
+
import {
|
|
5
|
+
listRecipeConfigLayers,
|
|
6
|
+
type RecipeConfigLayer,
|
|
7
|
+
} from "./repos/recipe-config-layers.js";
|
|
3
8
|
|
|
4
9
|
/**
|
|
5
10
|
* The directory name sous looks for when walking up from the working directory.
|
|
@@ -7,15 +12,33 @@ import path from "node:path";
|
|
|
7
12
|
export const SOUS_DIR_NAME = ".sous";
|
|
8
13
|
|
|
9
14
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
15
|
+
* Primary config file names searched inside a `.sous/` directory. Exactly ONE of
|
|
16
|
+
* these may exist per `.sous/`; more than one is a hard error (no silent
|
|
17
|
+
* first-match-wins).
|
|
12
18
|
*/
|
|
13
19
|
export const CONFIG_FILE_NAMES = [
|
|
14
20
|
"sous.config.js",
|
|
15
21
|
"sous.config.mjs",
|
|
16
22
|
"sous.config.json",
|
|
23
|
+
"sous.config.jsonc",
|
|
24
|
+
"sous.config.yaml",
|
|
17
25
|
] as const;
|
|
18
26
|
|
|
27
|
+
/**
|
|
28
|
+
* The drop-in config layer directory inside `.sous/`. Every
|
|
29
|
+
* `conf.d/*.{js,mjs,json,jsonc,yaml}` file (non-recursive) is loaded after the
|
|
30
|
+
* primary config and deep-merged in bytewise filename order.
|
|
31
|
+
*/
|
|
32
|
+
export const CONFD_DIR_NAME = "conf.d";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* File extensions recognised as config layers inside `conf.d/`. A `.jsonc`
|
|
36
|
+
* layer is JSON with comments: line comments, block comments and trailing
|
|
37
|
+
* commas are all allowed in it, which is why the layers sous manages for a
|
|
38
|
+
* project are written that way.
|
|
39
|
+
*/
|
|
40
|
+
export const LAYER_EXTENSIONS = [".js", ".mjs", ".json", ".jsonc", ".yaml"] as const;
|
|
41
|
+
|
|
19
42
|
/**
|
|
20
43
|
* The name of the optional shared-defaults env file inside `.sous/`. This file
|
|
21
44
|
* is meant to be committed: it holds values a whole team shares, never secrets.
|
|
@@ -27,7 +50,7 @@ export const ENV_LOCAL_NAME = ".env.local";
|
|
|
27
50
|
|
|
28
51
|
/** A located sous configuration. */
|
|
29
52
|
export type DiscoveredConfig = {
|
|
30
|
-
/** Absolute path to the config file itself. */
|
|
53
|
+
/** Absolute path to the primary config file itself. */
|
|
31
54
|
configPath: string;
|
|
32
55
|
/**
|
|
33
56
|
* Absolute path to the `.sous/` directory holding the config, when the config
|
|
@@ -35,6 +58,30 @@ export type DiscoveredConfig = {
|
|
|
35
58
|
* file's parent directory, whatever it is called.
|
|
36
59
|
*/
|
|
37
60
|
sousDir: string;
|
|
61
|
+
/** Absolute path to the `conf.d/` drop-in directory (may not exist). */
|
|
62
|
+
confDir: string;
|
|
63
|
+
/**
|
|
64
|
+
* Ordered absolute paths of every config layer: the primary config first,
|
|
65
|
+
* then any config layers subscribed recipes contribute, then the `conf.d/`
|
|
66
|
+
* layers in bytewise filename order. Recipes sit in the middle so a recipe can
|
|
67
|
+
* supply defaults and the project always wins over them.
|
|
68
|
+
*/
|
|
69
|
+
layerPaths: string[];
|
|
70
|
+
/** The subset of `layerPaths` that came from subscribed recipes. */
|
|
71
|
+
recipeLayerPaths: string[];
|
|
72
|
+
/**
|
|
73
|
+
* The recipe layers themselves, already read and already filtered down to the
|
|
74
|
+
* keys a recipe is allowed to set. Sous reads these instead of handing their
|
|
75
|
+
* paths to the config kernel, so a recipe cannot set a key that would change
|
|
76
|
+
* what sous trusts or what sous runs. See `repos/recipe-config-layers.ts`.
|
|
77
|
+
*/
|
|
78
|
+
recipeLayers: RecipeConfigLayer[];
|
|
79
|
+
/**
|
|
80
|
+
* Complete, plain-language sentences about anything a recipe contributed that
|
|
81
|
+
* sous declined to load. Printed by the command, since discovery runs before
|
|
82
|
+
* there is anywhere good to print.
|
|
83
|
+
*/
|
|
84
|
+
recipeLayerWarnings: string[];
|
|
38
85
|
/** How the config was located. */
|
|
39
86
|
source: "flag" | "walk-up";
|
|
40
87
|
};
|
|
@@ -58,16 +105,152 @@ export function candidateDirs(startDir: string): string[] {
|
|
|
58
105
|
}
|
|
59
106
|
|
|
60
107
|
/**
|
|
61
|
-
* Returns the
|
|
108
|
+
* Returns the primary config file inside `sousDir`, or null when none exists.
|
|
109
|
+
*
|
|
110
|
+
* @throws ConfigError when MORE THAN ONE primary candidate exists — sous never
|
|
111
|
+
* silently picks one of several `sous.config.*` files.
|
|
62
112
|
*/
|
|
63
113
|
export function findConfigInSousDir(sousDir: string): string | null {
|
|
114
|
+
const found: string[] = [];
|
|
64
115
|
for (const name of CONFIG_FILE_NAMES) {
|
|
65
116
|
const candidate = path.join(sousDir, name);
|
|
66
117
|
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
|
67
|
-
|
|
118
|
+
found.push(candidate);
|
|
68
119
|
}
|
|
69
120
|
}
|
|
70
|
-
|
|
121
|
+
|
|
122
|
+
if (found.length > 1) {
|
|
123
|
+
throw new ConfigError(
|
|
124
|
+
`Multiple primary sous config files found in ${sousDir}:\n` +
|
|
125
|
+
found.map((f) => ` ${f}`).join("\n") +
|
|
126
|
+
`\n A .sous/ directory may hold exactly one of: ${CONFIG_FILE_NAMES.join(", ")}.\n` +
|
|
127
|
+
` Keep one primary config and move the rest into ${CONFD_DIR_NAME}/ (with unique names) or delete them.`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return found[0] ?? null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Compares two strings bytewise (plain `<` on the string), locale-independent
|
|
136
|
+
* so layer order is identical on every machine. Note this is NOT numeric:
|
|
137
|
+
* `10-` sorts before `2-` (zero-pad layer prefixes if that matters).
|
|
138
|
+
*/
|
|
139
|
+
function bytewiseCompare(a: string, b: string): number {
|
|
140
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Lists the config layer files inside a `conf.d/` directory: every
|
|
145
|
+
* `*.{js,mjs,json,jsonc,yaml}` file directly inside it (non-recursive), sorted
|
|
146
|
+
* bytewise by filename. A missing directory yields an empty list.
|
|
147
|
+
*/
|
|
148
|
+
export function listConfDirLayers(confDir: string): string[] {
|
|
149
|
+
if (!fs.existsSync(confDir) || !fs.statSync(confDir).isDirectory()) return [];
|
|
150
|
+
|
|
151
|
+
return fs
|
|
152
|
+
.readdirSync(confDir)
|
|
153
|
+
.filter((name) => {
|
|
154
|
+
const ext = path.extname(name).toLowerCase();
|
|
155
|
+
if (!(LAYER_EXTENSIONS as readonly string[]).includes(ext)) return false;
|
|
156
|
+
const full = path.join(confDir, name);
|
|
157
|
+
return fs.statSync(full).isFile();
|
|
158
|
+
})
|
|
159
|
+
.sort(bytewiseCompare)
|
|
160
|
+
.map((name) => path.join(confDir, name));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Asserts that every loaded config file (primary + conf.d layers) has a unique
|
|
165
|
+
* baseName; the filename minus its FINAL extension. Two layers named
|
|
166
|
+
* `500-repos.json` and `500-repos.jsonc` would otherwise merge in an order that
|
|
167
|
+
* depends on their extensions, which is never what the author meant. It is also
|
|
168
|
+
* what stops a managed layer from existing under both its old `.json` name and
|
|
169
|
+
* its `.jsonc` one.
|
|
170
|
+
*
|
|
171
|
+
* @throws ConfigError naming both conflicting files.
|
|
172
|
+
*/
|
|
173
|
+
export function assertUniqueLayerBaseNames(layerPaths: string[]): void {
|
|
174
|
+
const seen = new Map<string, string>();
|
|
175
|
+
for (const layerPath of layerPaths) {
|
|
176
|
+
const base = path.basename(layerPath, path.extname(layerPath));
|
|
177
|
+
const existing = seen.get(base);
|
|
178
|
+
if (existing !== undefined) {
|
|
179
|
+
throw new ConfigError(
|
|
180
|
+
`Duplicate config layer baseName '${base}':\n` +
|
|
181
|
+
` ${existing}\n` +
|
|
182
|
+
` ${layerPath}\n` +
|
|
183
|
+
` Every loaded config file (the primary config and all ${CONFD_DIR_NAME}/ layers) must have a\n` +
|
|
184
|
+
` unique filename once its final extension is removed. Rename one of them.`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
seen.set(base, layerPath);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Builds a full DiscoveredConfig from a located primary config: computes the
|
|
193
|
+
* conf.d directory, enumerates its layers and the layers subscribed recipes
|
|
194
|
+
* contribute, and runs the duplicate-baseName check.
|
|
195
|
+
*
|
|
196
|
+
* Recipe layers load after the primary config and before the `conf.d/` layers,
|
|
197
|
+
* so a recipe supplies defaults and the project always wins over them. They are
|
|
198
|
+
* left out of the duplicate-baseName check deliberately: that check exists so a
|
|
199
|
+
* person never has to guess which of two files they wrote merges last, and a
|
|
200
|
+
* recipe's file names are not theirs to rename.
|
|
201
|
+
*
|
|
202
|
+
* @param confDirOverride - Absolute path to use as the conf.d directory instead
|
|
203
|
+
* of `<sousDir>/conf.d`. Set from the `--sous-confd` flag or `SOUS_CONFD` env
|
|
204
|
+
* var; discovery then builds its layers from the overridden directory.
|
|
205
|
+
*/
|
|
206
|
+
function buildDiscoveredConfig(
|
|
207
|
+
configPath: string,
|
|
208
|
+
sousDir: string,
|
|
209
|
+
source: DiscoveredConfig["source"],
|
|
210
|
+
confDirOverride?: string
|
|
211
|
+
): DiscoveredConfig {
|
|
212
|
+
const confDir = confDirOverride ?? path.join(sousDir, CONFD_DIR_NAME);
|
|
213
|
+
const projectLayers = [configPath, ...listConfDirLayers(confDir)];
|
|
214
|
+
assertUniqueLayerBaseNames(projectLayers);
|
|
215
|
+
|
|
216
|
+
const recipes = listRecipeConfigLayers(sousDir);
|
|
217
|
+
const layerPaths = [
|
|
218
|
+
configPath,
|
|
219
|
+
...recipes.paths,
|
|
220
|
+
...projectLayers.slice(1),
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
configPath,
|
|
225
|
+
sousDir,
|
|
226
|
+
confDir,
|
|
227
|
+
layerPaths,
|
|
228
|
+
recipeLayerPaths: recipes.paths,
|
|
229
|
+
recipeLayers: recipes.layers,
|
|
230
|
+
recipeLayerWarnings: recipes.warnings,
|
|
231
|
+
source,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Re-runs the conf.d enumeration, the recipe layer enumeration and the
|
|
237
|
+
* duplicate-baseName check for an existing discovery.
|
|
238
|
+
*
|
|
239
|
+
* Two callers need it. Watch mode calls it because layer files can appear or
|
|
240
|
+
* disappear while watching. `BaseCommand.init()` calls it once after the
|
|
241
|
+
* `.sous/` env files are loaded, because `SOUS_HOME` is file-settable and it
|
|
242
|
+
* decides where the store holding the recipe layers is.
|
|
243
|
+
*
|
|
244
|
+
* The existing `confDir` is preserved (not recomputed from `sousDir`), so a
|
|
245
|
+
* `SOUS_CONFD` / `--sous-confd` override survives across watch reloads.
|
|
246
|
+
*/
|
|
247
|
+
export function refreshDiscoveredConfig(discovered: DiscoveredConfig): DiscoveredConfig {
|
|
248
|
+
return buildDiscoveredConfig(
|
|
249
|
+
discovered.configPath,
|
|
250
|
+
discovered.sousDir,
|
|
251
|
+
discovered.source,
|
|
252
|
+
discovered.confDir
|
|
253
|
+
);
|
|
71
254
|
}
|
|
72
255
|
|
|
73
256
|
/**
|
|
@@ -76,15 +259,22 @@ export function findConfigInSousDir(sousDir: string): string | null {
|
|
|
76
259
|
* without a config file does not stop the walk.
|
|
77
260
|
*
|
|
78
261
|
* @param startDir - Directory to start from (normally `process.cwd()`).
|
|
262
|
+
* @param confDirOverride - Absolute conf.d directory to use instead of
|
|
263
|
+
* `<sousDir>/conf.d` (from `--sous-confd` / `SOUS_CONFD`).
|
|
79
264
|
* @returns The discovered config, or null when nothing was found.
|
|
265
|
+
* @throws ConfigError when a `.sous/` holds several primary configs, or when
|
|
266
|
+
* loaded layer baseNames collide.
|
|
80
267
|
*/
|
|
81
|
-
export function discoverConfig(
|
|
268
|
+
export function discoverConfig(
|
|
269
|
+
startDir: string = process.cwd(),
|
|
270
|
+
confDirOverride?: string
|
|
271
|
+
): DiscoveredConfig | null {
|
|
82
272
|
for (const dir of candidateDirs(startDir)) {
|
|
83
273
|
const sousDir = path.join(dir, SOUS_DIR_NAME);
|
|
84
274
|
if (!fs.existsSync(sousDir) || !fs.statSync(sousDir).isDirectory()) continue;
|
|
85
275
|
|
|
86
276
|
const configPath = findConfigInSousDir(sousDir);
|
|
87
|
-
if (configPath) return
|
|
277
|
+
if (configPath) return buildDiscoveredConfig(configPath, sousDir, "walk-up", confDirOverride);
|
|
88
278
|
}
|
|
89
279
|
|
|
90
280
|
return null;
|
|
@@ -99,40 +289,47 @@ export function discoverConfig(startDir: string = process.cwd()): DiscoveredConf
|
|
|
99
289
|
*
|
|
100
290
|
* @param configFlag - The raw `--config` value (relative paths resolve against cwd).
|
|
101
291
|
* @param cwd - Base directory for relative paths.
|
|
292
|
+
* @param confDirOverride - Absolute conf.d directory to use instead of
|
|
293
|
+
* `<sousDir>/conf.d` (from `--sous-confd` / `SOUS_CONFD`).
|
|
294
|
+
* @param sourceLabel - How the caller supplied the value (`--config`,
|
|
295
|
+
* `--sous-config`, `SOUS_CONFIG`, `--sous-dir`, `SOUS_DIR`). Used only in error
|
|
296
|
+
* messages so a user who set `SOUS_DIR` is not told to fix `--config`.
|
|
102
297
|
* @returns The resolved config.
|
|
103
298
|
* @throws When the path does not exist or holds no recognised config file.
|
|
104
299
|
*/
|
|
105
300
|
export function resolveConfigFlag(
|
|
106
301
|
configFlag: string,
|
|
107
|
-
cwd: string = process.cwd()
|
|
302
|
+
cwd: string = process.cwd(),
|
|
303
|
+
confDirOverride?: string,
|
|
304
|
+
sourceLabel = "--config"
|
|
108
305
|
): DiscoveredConfig {
|
|
109
306
|
const resolved = path.resolve(cwd, expandHome(configFlag));
|
|
110
307
|
|
|
111
308
|
if (!fs.existsSync(resolved)) {
|
|
112
|
-
throw new Error(
|
|
309
|
+
throw new Error(`${sourceLabel} path not found: ${resolved}`);
|
|
113
310
|
}
|
|
114
311
|
|
|
115
312
|
if (fs.statSync(resolved).isDirectory()) {
|
|
116
313
|
const direct = findConfigInSousDir(resolved);
|
|
117
314
|
if (direct) {
|
|
118
|
-
return
|
|
315
|
+
return buildDiscoveredConfig(direct, resolved, "flag", confDirOverride);
|
|
119
316
|
}
|
|
120
317
|
|
|
121
318
|
const nested = path.join(resolved, SOUS_DIR_NAME);
|
|
122
319
|
if (fs.existsSync(nested) && fs.statSync(nested).isDirectory()) {
|
|
123
320
|
const nestedConfig = findConfigInSousDir(nested);
|
|
124
321
|
if (nestedConfig) {
|
|
125
|
-
return
|
|
322
|
+
return buildDiscoveredConfig(nestedConfig, nested, "flag", confDirOverride);
|
|
126
323
|
}
|
|
127
324
|
}
|
|
128
325
|
|
|
129
326
|
throw new Error(
|
|
130
|
-
|
|
327
|
+
`${sourceLabel} points at a directory with no sous config file: ${resolved}\n` +
|
|
131
328
|
`Looked for: ${CONFIG_FILE_NAMES.join(", ")}`
|
|
132
329
|
);
|
|
133
330
|
}
|
|
134
331
|
|
|
135
|
-
return
|
|
332
|
+
return buildDiscoveredConfig(resolved, path.dirname(resolved), "flag", confDirOverride);
|
|
136
333
|
}
|
|
137
334
|
|
|
138
335
|
/**
|
|
@@ -175,24 +372,20 @@ export function formatNotFoundMessage(startDir: string = process.cwd()): string
|
|
|
175
372
|
"",
|
|
176
373
|
" To fix this, either:",
|
|
177
374
|
` 1. Create ${SOUS_DIR_NAME}/${CONFIG_FILE_NAMES[0]} in your project root, or`,
|
|
178
|
-
" 2. Pass the config explicitly:
|
|
375
|
+
" 2. Pass the config explicitly: sous <command> --config <path>",
|
|
179
376
|
"",
|
|
180
377
|
` A minimal ${CONFIG_FILE_NAMES[0]}:`,
|
|
181
378
|
"",
|
|
182
379
|
" export const config = {",
|
|
183
|
-
'
|
|
184
|
-
|
|
185
|
-
"
|
|
186
|
-
|
|
187
|
-
"
|
|
188
|
-
|
|
189
|
-
"
|
|
190
|
-
' entryPoint: "${sousDir}/AGENTS.md",',
|
|
191
|
-
' outputs: [{ destinationFile: "${projectRoot}/AGENTS.md" }],',
|
|
192
|
-
" },",
|
|
193
|
-
" ],",
|
|
380
|
+
' name: "My Project",',
|
|
381
|
+
' _vars: { projectRoot: "${sousDir}/.." },',
|
|
382
|
+
" compilation: {",
|
|
383
|
+
" targets: [",
|
|
384
|
+
" {",
|
|
385
|
+
' entryPoint: "${sousDir}/AGENTS.md",',
|
|
386
|
+
' outputs: [{ destinationFile: "${projectRoot}/AGENTS.md" }],',
|
|
194
387
|
" },",
|
|
195
|
-
"
|
|
388
|
+
" ],",
|
|
196
389
|
" },",
|
|
197
390
|
" };",
|
|
198
391
|
].join("\n");
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { styleText } from "node:util";
|
|
2
|
+
import { ConfigError } from "./errors.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared helpers for the `sous config *` inspection commands: dot-path lookup
|
|
6
|
+
* with `[n]` array indexing, colorized pretty-JSON rendering, and value
|
|
7
|
+
* truncation for the `--layers` provenance view.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Sentinel returned by lookupPath when a path segment does not exist. */
|
|
11
|
+
export const NOT_FOUND = Symbol("sous.config.path.not-found");
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Splits a dot-path with optional `[n]` array indices into ordered segments.
|
|
15
|
+
* `compilation.targets[0].entryPoint` → `["compilation", "targets", 0, "entryPoint"]`.
|
|
16
|
+
* Numeric-looking bracket segments become numbers; everything else is a string key.
|
|
17
|
+
*
|
|
18
|
+
* @throws ConfigError on a malformed path (empty, unbalanced brackets, etc.).
|
|
19
|
+
*/
|
|
20
|
+
export function parsePath(path: string): (string | number)[] {
|
|
21
|
+
const trimmed = path.trim();
|
|
22
|
+
if (trimmed === "") {
|
|
23
|
+
throw new ConfigError("Empty config path. Provide a dot-path like 'compilation.targets[0].entryPoint'.");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const segments: (string | number)[] = [];
|
|
27
|
+
// Consume, in order: bare keys, `[n]` array indices, and `.` separators
|
|
28
|
+
// between them. Any character that fits none of these (e.g. `a..b`, `a[]`,
|
|
29
|
+
// `a[x]`) leaves a gap the scanner reports as malformed.
|
|
30
|
+
const tokenRe = /([^.[\]]+)|\[(\d+)\]|(\.)/g;
|
|
31
|
+
let lastIndex = 0;
|
|
32
|
+
let expectKey = false; // a `.` was just consumed, so a key must follow
|
|
33
|
+
let match: RegExpExecArray | null;
|
|
34
|
+
|
|
35
|
+
while ((match = tokenRe.exec(trimmed)) !== null) {
|
|
36
|
+
if (match.index !== lastIndex) {
|
|
37
|
+
throw new ConfigError(`Malformed config path near '${trimmed.slice(lastIndex)}' in '${path}'.`);
|
|
38
|
+
}
|
|
39
|
+
if (match[1] !== undefined) {
|
|
40
|
+
segments.push(match[1]);
|
|
41
|
+
expectKey = false;
|
|
42
|
+
} else if (match[2] !== undefined) {
|
|
43
|
+
if (expectKey) {
|
|
44
|
+
throw new ConfigError(`Malformed config path near '${trimmed.slice(match.index)}' in '${path}'.`);
|
|
45
|
+
}
|
|
46
|
+
segments.push(Number(match[2]));
|
|
47
|
+
} else {
|
|
48
|
+
// A `.` separator: a key must follow it, and one may not lead the path or
|
|
49
|
+
// directly follow another `.` (i.e. `.a`, `a..b` are malformed).
|
|
50
|
+
if (expectKey || segments.length === 0) {
|
|
51
|
+
throw new ConfigError(`Malformed config path near '${trimmed.slice(match.index)}' in '${path}'.`);
|
|
52
|
+
}
|
|
53
|
+
expectKey = true;
|
|
54
|
+
}
|
|
55
|
+
lastIndex = tokenRe.lastIndex;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (lastIndex !== trimmed.length || expectKey) {
|
|
59
|
+
throw new ConfigError(`Malformed config path near '${trimmed.slice(lastIndex) || "end of path"}' in '${path}'.`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return segments;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Walks `root` following `segments`, returning the value found or NOT_FOUND when
|
|
67
|
+
* any segment is missing (or the path descends into a non-indexable value).
|
|
68
|
+
*/
|
|
69
|
+
export function lookupPath(root: unknown, segments: (string | number)[]): unknown {
|
|
70
|
+
let current: unknown = root;
|
|
71
|
+
for (const segment of segments) {
|
|
72
|
+
if (current === null || current === undefined || typeof current !== "object") {
|
|
73
|
+
return NOT_FOUND;
|
|
74
|
+
}
|
|
75
|
+
if (typeof segment === "number") {
|
|
76
|
+
if (!Array.isArray(current) || segment < 0 || segment >= current.length) {
|
|
77
|
+
return NOT_FOUND;
|
|
78
|
+
}
|
|
79
|
+
current = current[segment];
|
|
80
|
+
} else {
|
|
81
|
+
// Use hasOwnProperty, NOT the `in` operator: `in` walks the prototype
|
|
82
|
+
// chain, so inherited Object.prototype members (`constructor`, `toString`,
|
|
83
|
+
// `hasOwnProperty`, `__proto__`, …) would falsely "resolve" and print
|
|
84
|
+
// garbage instead of NOT_FOUND.
|
|
85
|
+
if (!Object.prototype.hasOwnProperty.call(current, segment)) {
|
|
86
|
+
return NOT_FOUND;
|
|
87
|
+
}
|
|
88
|
+
current = (current as Record<string, unknown>)[segment];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return current;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** True for JSON scalar values (string, finite/any number, boolean, null). */
|
|
95
|
+
export function isScalar(value: unknown): value is string | number | boolean | null {
|
|
96
|
+
return (
|
|
97
|
+
value === null ||
|
|
98
|
+
typeof value === "string" ||
|
|
99
|
+
typeof value === "number" ||
|
|
100
|
+
typeof value === "boolean"
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Colorizes an already-serialized pretty-JSON string using node:util styleText:
|
|
106
|
+
* object keys cyan, string values green, numbers yellow, booleans/null magenta.
|
|
107
|
+
* Operates purely on JSON token syntax, so it never mangles structure.
|
|
108
|
+
*
|
|
109
|
+
* Callers should only invoke this when writing to a TTY; piped output must stay
|
|
110
|
+
* plain so it parses as JSON.
|
|
111
|
+
*/
|
|
112
|
+
export function colorizeJson(json: string): string {
|
|
113
|
+
const tokenRe = /"(?:\\.|[^"\\])*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|\b(?:true|false|null)\b/g;
|
|
114
|
+
return json.replace(tokenRe, (match, offset: number, whole: string) => {
|
|
115
|
+
if (match[0] === '"') {
|
|
116
|
+
const rest = whole.slice(offset + match.length);
|
|
117
|
+
// A string immediately followed by a colon is an object key.
|
|
118
|
+
return /^\s*:/.test(rest)
|
|
119
|
+
? styleText("cyan", match)
|
|
120
|
+
: styleText("green", match);
|
|
121
|
+
}
|
|
122
|
+
if (match === "true" || match === "false" || match === "null") {
|
|
123
|
+
return styleText("magenta", match);
|
|
124
|
+
}
|
|
125
|
+
return styleText("yellow", match);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Renders a value as pretty JSON (2-space), colorized when `useColor` is true.
|
|
131
|
+
*/
|
|
132
|
+
export function renderJson(value: unknown, useColor: boolean): string {
|
|
133
|
+
const json = JSON.stringify(value, null, 2);
|
|
134
|
+
return useColor ? colorizeJson(json) : json;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* JSON-encodes a value on a single line and truncates it to `max` characters
|
|
139
|
+
* (with an ellipsis) for the compact `--layers` provenance lines.
|
|
140
|
+
*/
|
|
141
|
+
export function truncateJson(value: unknown, max = 80): string {
|
|
142
|
+
const encoded = JSON.stringify(value);
|
|
143
|
+
if (encoded === undefined) return "undefined";
|
|
144
|
+
return encoded.length > max ? `${encoded.slice(0, max - 1)}…` : encoded;
|
|
145
|
+
}
|