@sous-io/sous 0.1.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.
Files changed (82) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +154 -0
  3. package/bin/run.js +17 -0
  4. package/bin/xcv +5 -0
  5. package/package.json +81 -0
  6. package/shared-prompts/_partials/resume-task.md +51 -0
  7. package/shared-prompts/_partials/sub-agent-delegation.md +32 -0
  8. package/shared-prompts/_partials/update-task-file.md +52 -0
  9. package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +52 -0
  10. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +102 -0
  11. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +81 -0
  12. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +126 -0
  13. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +92 -0
  14. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +61 -0
  15. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +65 -0
  16. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +96 -0
  17. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +104 -0
  18. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +243 -0
  19. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +148 -0
  20. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +383 -0
  21. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +267 -0
  22. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +56 -0
  23. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +169 -0
  24. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +59 -0
  25. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +25 -0
  26. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +140 -0
  27. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +140 -0
  28. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +1 -0
  29. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +185 -0
  30. package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +52 -0
  31. package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +59 -0
  32. package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +47 -0
  33. package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +26 -0
  34. package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +58 -0
  35. package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +27 -0
  36. package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +34 -0
  37. package/shared-prompts/skills/sous-skills/about-agent-skills/SKILL.tpl.md +177 -0
  38. package/shared-prompts/skills/sous-skills/about-agent-skills/examples/about-something.md +45 -0
  39. package/shared-prompts/skills/sous-skills/about-agent-skills/examples/do-something.md +33 -0
  40. package/shared-prompts/skills/sous-skills/about-agent-skills/references/advanced-patterns.md +87 -0
  41. package/shared-prompts/skills/sous-skills/about-agent-skills/references/commands.md +46 -0
  42. package/shared-prompts/skills/sous-skills/about-agent-skills/references/frontmatter.md +25 -0
  43. package/shared-prompts/skills/sous-skills/about-agent-skills/references/substitutions.md +50 -0
  44. package/shared-prompts/skills/sous-skills/about-liquid-templates/SKILL.tpl.md +268 -0
  45. package/shared-prompts/skills/sous-skills/about-liquid-templates/references/liquid-filters.md +82 -0
  46. package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +51 -0
  47. package/shared-prompts/skills/sous-skills/create-skill/SKILL.tpl.md +114 -0
  48. package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +122 -0
  49. package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +80 -0
  50. package/shared-prompts/skills/task-files/go/SKILL.tpl.md +14 -0
  51. package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +13 -0
  52. package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +93 -0
  53. package/shared-prompts/skills/task-files/update/SKILL.tpl.md +14 -0
  54. package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +13 -0
  55. package/src/base-command.ts +163 -0
  56. package/src/commands/build.ts +196 -0
  57. package/src/commands/clear.ts +71 -0
  58. package/src/commands/compile.ts +95 -0
  59. package/src/commands/launch.ts +111 -0
  60. package/src/commands/prune.ts +48 -0
  61. package/src/lib/build-service.ts +258 -0
  62. package/src/lib/config-discovery.ts +199 -0
  63. package/src/lib/env-local.ts +195 -0
  64. package/src/lib/include-resolver.ts +146 -0
  65. package/src/lib/markdown-compiler.ts +580 -0
  66. package/src/lib/pid-service.ts +88 -0
  67. package/src/lib/settings.ts +695 -0
  68. package/src/lib/state.ts +135 -0
  69. package/src/lib/watch-service.ts +115 -0
  70. package/src/templating/filters/bullet-list.ts +9 -0
  71. package/src/templating/filters/index.ts +8 -0
  72. package/src/templating/init-liquid-engine.ts +82 -0
  73. package/src/templating/lib/glob-files.ts +74 -0
  74. package/src/templating/lib/import-export.ts +32 -0
  75. package/src/templating/lib/tag-args.ts +19 -0
  76. package/src/templating/tags/exportScalarVarsJs.ts +43 -0
  77. package/src/templating/tags/getFiles.ts +89 -0
  78. package/src/templating/tags/index.ts +14 -0
  79. package/src/templating/tags/listFiles.ts +54 -0
  80. package/src/templating/tags/showVars.ts +22 -0
  81. package/src/utils/formatting.ts +338 -0
  82. package/src/utils/prompts.ts +19 -0
@@ -0,0 +1,258 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import type { ConfigContext, Settings } from "./settings.js";
4
+ import { resolveProjectCompilation, resolveRootScope, resolveScope } from "./settings.js";
5
+ import { resolveIncludeCandidates } from "./include-resolver.js";
6
+ import { CompilationService } from "./markdown-compiler.js";
7
+ import type { CompilationConfig, CompilationTarget } from "./markdown-compiler.js";
8
+ import { StateService } from "./state.js";
9
+ import { log } from "../utils/formatting.js";
10
+
11
+ export type BuildOptions = {
12
+ strict?: boolean;
13
+ rebuild?: boolean;
14
+ dryRun?: boolean;
15
+ noCompile?: boolean;
16
+ noPrune?: boolean;
17
+ /**
18
+ * When set, only targets that transitively include this file are compiled.
19
+ * All other targets are skipped. If no targets include this file, compilation
20
+ * is skipped entirely.
21
+ */
22
+ changedFile?: string;
23
+ /**
24
+ * Where the active config was discovered. Threaded into variable resolution so
25
+ * `${sousDir}` resolves and so state/PID paths default into `.sous/`.
26
+ */
27
+ configContext?: ConfigContext;
28
+ };
29
+
30
+ /**
31
+ * Recursively collects all file paths reachable from `filePath` via @include chains.
32
+ * Returns a Set of absolute paths. The `visited` set prevents infinite loops.
33
+ *
34
+ * Matches the same @<path>.md include lines as CompilationService.processIncludes,
35
+ * including alias/`${var}` paths, resolving each via the same candidate logic
36
+ * (first existing wins).
37
+ */
38
+ function collectIncludeGraph(
39
+ filePath: string,
40
+ resolveOpts: { aliases?: Record<string, string[]>; scope?: Record<string, string> } = {},
41
+ visited: Set<string> = new Set()
42
+ ): Set<string> {
43
+ if (visited.has(filePath)) return visited;
44
+ visited.add(filePath);
45
+
46
+ if (!fs.existsSync(filePath)) return visited;
47
+
48
+ let content: string;
49
+ try {
50
+ content = fs.readFileSync(filePath, "utf8");
51
+ } catch {
52
+ return visited;
53
+ }
54
+
55
+ const includePattern = /^@([~a-zA-Z0-9_${}][a-zA-Z0-9_\-/.:${}]*\.md)$/gm;
56
+ const baseDir = path.dirname(filePath);
57
+ let match: RegExpExecArray | null;
58
+
59
+ while ((match = includePattern.exec(content)) !== null) {
60
+ const includePath = match[1].trim();
61
+ const candidates = resolveIncludeCandidates(includePath, {
62
+ aliases: resolveOpts.aliases,
63
+ scope: resolveOpts.scope,
64
+ baseDir,
65
+ });
66
+ const fullPath = candidates.find((c) => fs.existsSync(c)) ?? candidates[0];
67
+ collectIncludeGraph(fullPath, resolveOpts, visited);
68
+ }
69
+
70
+ return visited;
71
+ }
72
+
73
+ /**
74
+ * Returns the subset of compilation targets that transitively include `filePath`.
75
+ * A target is affected if its `rootInputPath` equals `filePath`, or if `filePath`
76
+ * is reachable via @include chains from `rootInputPath`.
77
+ *
78
+ * Uses a simple recursive file scan — reads each .md file and checks for
79
+ * @<path> include lines. Does not compile; just walks the include graph.
80
+ */
81
+ export function findAffectedTargets(
82
+ filePath: string,
83
+ config: CompilationConfig
84
+ ): CompilationTarget[] {
85
+ return config.targets.filter(target => {
86
+ const graph = collectIncludeGraph(target.rootInputPath, {
87
+ aliases: config.aliases,
88
+ scope: config.includeScope,
89
+ });
90
+ return graph.has(filePath);
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Resolves the state file path for a project.
96
+ *
97
+ * The path is derived from the PROJECT scope (root vars → project `_vars`), so a
98
+ * `stateFilePath` or `sousDir` defined at either level is honoured. Resolving
99
+ * from the root scope alone was a bug: project-level values were ignored and
100
+ * state silently landed in cwd.
101
+ *
102
+ * @param projectKey - The project's key in `settings.projects`.
103
+ * @param settings - The loaded root settings.
104
+ * @param configContext - Where the config was discovered (supplies `sousDir`).
105
+ * @returns Absolute path to the project's state file.
106
+ */
107
+ export function resolveStateFilePath(
108
+ projectKey: string,
109
+ settings: Settings,
110
+ configContext?: ConfigContext
111
+ ): string {
112
+ const rootScope = resolveRootScope(settings, configContext);
113
+ const project = settings.projects[projectKey];
114
+ const projectScope = resolveScope(project?._vars ?? {}, rootScope);
115
+ const projectCount = Object.keys(settings.projects ?? {}).length;
116
+ return new StateService().getFilePath(projectKey, projectScope, projectCount);
117
+ }
118
+
119
+ export class BuildService {
120
+ /**
121
+ * Runs compile + prune for a project.
122
+ * Returns true if all steps succeeded.
123
+ */
124
+ async build(
125
+ projectKey: string,
126
+ settings: Settings,
127
+ options: BuildOptions = {}
128
+ ): Promise<boolean> {
129
+ const rootScope = resolveRootScope(settings, options.configContext);
130
+ const project = settings.projects[projectKey];
131
+
132
+ if (!project) {
133
+ throw new Error(`Project '${projectKey}' not found in settings`);
134
+ }
135
+
136
+ const stateService = new StateService();
137
+ const stateFilePath = resolveStateFilePath(projectKey, settings, options.configContext);
138
+
139
+ let success = true;
140
+
141
+ // When --rebuild, clear all previously written files before compiling so that
142
+ // orphaned outputs (files no longer produced by the current config) are removed.
143
+ // Prune cannot catch these because compile overwrites the state file before prune runs.
144
+ if (options.rebuild && !options.dryRun && !options.noCompile) {
145
+ const existingState = await stateService.load(stateFilePath);
146
+ if (existingState?.files.length) {
147
+ stateService.deleteTrackedFiles(existingState.files, existingState.dirs);
148
+ }
149
+ }
150
+
151
+ // Compile step
152
+ if (!options.noCompile) {
153
+ const config = resolveProjectCompilation(project, rootScope, settings, projectKey);
154
+ if (config) {
155
+ let effectiveConfig: CompilationConfig = config;
156
+
157
+ if (options.changedFile) {
158
+ const affectedTargets = findAffectedTargets(options.changedFile, config);
159
+ if (affectedTargets.length === 0) {
160
+ log(` ⊘ No targets affected by change to ${options.changedFile} — skipping compilation`);
161
+ } else {
162
+ effectiveConfig = { ...config, targets: affectedTargets };
163
+ const compiler = new CompilationService({
164
+ strict: options.strict,
165
+ rebuild: options.rebuild,
166
+ dryRun: options.dryRun,
167
+ });
168
+ const compileOk = await compiler.compile(effectiveConfig, stateFilePath);
169
+ if (!compileOk) success = false;
170
+ }
171
+ } else {
172
+ const compiler = new CompilationService({
173
+ strict: options.strict,
174
+ rebuild: options.rebuild,
175
+ dryRun: options.dryRun,
176
+ });
177
+ const compileOk = await compiler.compile(effectiveConfig, stateFilePath);
178
+ if (!compileOk) success = false;
179
+ }
180
+ }
181
+ }
182
+
183
+ // Prune step
184
+ if (!options.noPrune && success) {
185
+ await this.prune(
186
+ projectKey,
187
+ settings,
188
+ stateFilePath,
189
+ options.dryRun,
190
+ options.configContext
191
+ );
192
+ }
193
+
194
+ return success;
195
+ }
196
+
197
+ /**
198
+ * Removes output files that are tracked in state but no longer in the current config.
199
+ * Also removes Sous-created directories that are now empty.
200
+ */
201
+ async prune(
202
+ projectKey: string,
203
+ settings: Settings,
204
+ stateFilePath: string,
205
+ dryRun = false,
206
+ configContext?: ConfigContext
207
+ ): Promise<void> {
208
+ const stateService = new StateService();
209
+ const state = await stateService.load(stateFilePath);
210
+ if (!state || state.files.length === 0) return;
211
+
212
+ const rootScope = resolveRootScope(settings, configContext);
213
+ const project = settings.projects[projectKey];
214
+ const config = project
215
+ ? resolveProjectCompilation(project, rootScope, settings, projectKey)
216
+ : null;
217
+
218
+ // Collect the current output set: explicit files and active destinationDir prefixes
219
+ const currentOutputFiles = new Set<string>();
220
+ const currentOutputDirs = new Set<string>();
221
+ if (config) {
222
+ for (const target of config.targets) {
223
+ for (const output of target.outputs) {
224
+ if (output.destinationFile) currentOutputFiles.add(output.destinationFile);
225
+ if (output.destinationDir) currentOutputDirs.add(output.destinationDir);
226
+ }
227
+ }
228
+ }
229
+
230
+ // A state entry is current if it matches an explicit destinationFile, or if its dest
231
+ // path falls under an active destinationDir (glob target output).
232
+ function isCurrentOutput(dest: string): boolean {
233
+ if (currentOutputFiles.has(dest)) return true;
234
+ for (const dir of currentOutputDirs) {
235
+ if (dest.startsWith(dir + path.sep) || dest.startsWith(dir + "/")) return true;
236
+ }
237
+ return false;
238
+ }
239
+
240
+ // Find files to prune
241
+ const toDelete = state.files.filter(f => !isCurrentOutput(f.dest));
242
+
243
+ if (dryRun) {
244
+ for (const entry of toDelete) {
245
+ console.log(` ○ would prune: ${entry.dest}`);
246
+ }
247
+ return;
248
+ }
249
+
250
+ stateService.deleteTrackedFiles(toDelete, state.dirs);
251
+ for (const entry of toDelete) console.log(` ✗ pruned: ${entry.dest}`);
252
+
253
+ // Update state: remove pruned entries and any dirs that no longer exist
254
+ state.files = state.files.filter(f => isCurrentOutput(f.dest));
255
+ state.dirs = state.dirs.filter(d => fs.existsSync(d));
256
+ await stateService.save(stateFilePath, state);
257
+ }
258
+ }
@@ -0,0 +1,199 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * The directory name sous looks for when walking up from the working directory.
6
+ */
7
+ export const SOUS_DIR_NAME = ".sous";
8
+
9
+ /**
10
+ * Config file names searched inside a `.sous/` directory, in priority order.
11
+ * The first one that exists wins.
12
+ */
13
+ export const CONFIG_FILE_NAMES = [
14
+ "sous.config.js",
15
+ "sous.config.mjs",
16
+ "sous.config.json",
17
+ ] as const;
18
+
19
+ /**
20
+ * The name of the optional shared-defaults env file inside `.sous/`. This file
21
+ * is meant to be committed: it holds values a whole team shares, never secrets.
22
+ */
23
+ export const ENV_DEFAULTS_NAME = ".env";
24
+
25
+ /** The name of the optional machine-specific env file inside `.sous/`. */
26
+ export const ENV_LOCAL_NAME = ".env.local";
27
+
28
+ /** A located sous configuration. */
29
+ export type DiscoveredConfig = {
30
+ /** Absolute path to the config file itself. */
31
+ configPath: string;
32
+ /**
33
+ * Absolute path to the `.sous/` directory holding the config, when the config
34
+ * was found by walking up. For an explicit `--config` path this is the config
35
+ * file's parent directory, whatever it is called.
36
+ */
37
+ sousDir: string;
38
+ /** How the config was located. */
39
+ source: "flag" | "walk-up";
40
+ };
41
+
42
+ /**
43
+ * Returns the list of directories to check, starting at `startDir` and walking
44
+ * up to the filesystem root. Used by discovery and by the not-found error message.
45
+ */
46
+ export function candidateDirs(startDir: string): string[] {
47
+ const dirs: string[] = [];
48
+ let current = path.resolve(startDir);
49
+
50
+ for (;;) {
51
+ dirs.push(current);
52
+ const parent = path.dirname(current);
53
+ if (parent === current) break;
54
+ current = parent;
55
+ }
56
+
57
+ return dirs;
58
+ }
59
+
60
+ /**
61
+ * Returns the first config file that exists inside `sousDir`, or null.
62
+ */
63
+ export function findConfigInSousDir(sousDir: string): string | null {
64
+ for (const name of CONFIG_FILE_NAMES) {
65
+ const candidate = path.join(sousDir, name);
66
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
67
+ return candidate;
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * Walks up from `startDir` looking for a `.sous/` directory that contains one of
75
+ * CONFIG_FILE_NAMES. The first directory with a match wins; a `.sous/` directory
76
+ * without a config file does not stop the walk.
77
+ *
78
+ * @param startDir - Directory to start from (normally `process.cwd()`).
79
+ * @returns The discovered config, or null when nothing was found.
80
+ */
81
+ export function discoverConfig(startDir: string = process.cwd()): DiscoveredConfig | null {
82
+ for (const dir of candidateDirs(startDir)) {
83
+ const sousDir = path.join(dir, SOUS_DIR_NAME);
84
+ if (!fs.existsSync(sousDir) || !fs.statSync(sousDir).isDirectory()) continue;
85
+
86
+ const configPath = findConfigInSousDir(sousDir);
87
+ if (configPath) return { configPath, sousDir, source: "walk-up" };
88
+ }
89
+
90
+ return null;
91
+ }
92
+
93
+ /**
94
+ * Resolves an explicit `--config` value into a DiscoveredConfig.
95
+ *
96
+ * The value may point at a config file or at a directory. A directory is searched
97
+ * for CONFIG_FILE_NAMES, and if the directory itself is not named `.sous`, its
98
+ * `.sous/` child is searched too — so `--config .` works inside a project root.
99
+ *
100
+ * @param configFlag - The raw `--config` value (relative paths resolve against cwd).
101
+ * @param cwd - Base directory for relative paths.
102
+ * @returns The resolved config.
103
+ * @throws When the path does not exist or holds no recognised config file.
104
+ */
105
+ export function resolveConfigFlag(
106
+ configFlag: string,
107
+ cwd: string = process.cwd()
108
+ ): DiscoveredConfig {
109
+ const resolved = path.resolve(cwd, expandHome(configFlag));
110
+
111
+ if (!fs.existsSync(resolved)) {
112
+ throw new Error(`--config path not found: ${resolved}`);
113
+ }
114
+
115
+ if (fs.statSync(resolved).isDirectory()) {
116
+ const direct = findConfigInSousDir(resolved);
117
+ if (direct) {
118
+ return { configPath: direct, sousDir: resolved, source: "flag" };
119
+ }
120
+
121
+ const nested = path.join(resolved, SOUS_DIR_NAME);
122
+ if (fs.existsSync(nested) && fs.statSync(nested).isDirectory()) {
123
+ const nestedConfig = findConfigInSousDir(nested);
124
+ if (nestedConfig) {
125
+ return { configPath: nestedConfig, sousDir: nested, source: "flag" };
126
+ }
127
+ }
128
+
129
+ throw new Error(
130
+ `--config points at a directory with no sous config file: ${resolved}\n` +
131
+ `Looked for: ${CONFIG_FILE_NAMES.join(", ")}`
132
+ );
133
+ }
134
+
135
+ return { configPath: resolved, sousDir: path.dirname(resolved), source: "flag" };
136
+ }
137
+
138
+ /**
139
+ * Expands a leading `~` to the user's home directory. Leaves other paths alone.
140
+ */
141
+ export function expandHome(inputPath: string): string {
142
+ if (inputPath === "~") return process.env.HOME ?? inputPath;
143
+ if (inputPath.startsWith("~/")) {
144
+ const home = process.env.HOME;
145
+ if (home) return path.join(home, inputPath.slice(2));
146
+ }
147
+ return inputPath;
148
+ }
149
+
150
+ /**
151
+ * Builds the message shown when no config could be found. Names every directory
152
+ * that was checked and shows how to fix it.
153
+ *
154
+ * @param startDir - The directory discovery started from.
155
+ */
156
+ export function formatNotFoundMessage(startDir: string = process.cwd()): string {
157
+ const dirs = candidateDirs(startDir);
158
+ const shown = dirs.slice(0, 8);
159
+ const omitted = dirs.length - shown.length;
160
+
161
+ const checked = shown
162
+ .map((dir) => ` ${path.join(dir, SOUS_DIR_NAME)}/`)
163
+ .join("\n");
164
+
165
+ const more = omitted > 0 ? `\n ... and ${omitted} more parent director${omitted === 1 ? "y" : "ies"}` : "";
166
+
167
+ return [
168
+ "No sous config found.",
169
+ "",
170
+ `Starting at ${startDir}, sous walked up looking for a ${SOUS_DIR_NAME}/ directory`,
171
+ `containing one of: ${CONFIG_FILE_NAMES.join(", ")}`,
172
+ "",
173
+ " Checked:",
174
+ checked + more,
175
+ "",
176
+ " To fix this, either:",
177
+ ` 1. Create ${SOUS_DIR_NAME}/${CONFIG_FILE_NAMES[0]} in your project root, or`,
178
+ " 2. Pass the config explicitly: xcv <command> --config <path>",
179
+ "",
180
+ ` A minimal ${CONFIG_FILE_NAMES[0]}:`,
181
+ "",
182
+ " export const config = {",
183
+ ' defaultProject: "myproject",',
184
+ " projects: {",
185
+ " myproject: {",
186
+ ' name: "My Project",',
187
+ " compilation: {",
188
+ " targets: [",
189
+ " {",
190
+ ' entryPoint: "${sousDir}/AGENTS.md",',
191
+ ' outputs: [{ destinationFile: "${projectRoot}/AGENTS.md" }],',
192
+ " },",
193
+ " ],",
194
+ " },",
195
+ " },",
196
+ " },",
197
+ " };",
198
+ ].join("\n");
199
+ }
@@ -0,0 +1,195 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { ENV_DEFAULTS_NAME, ENV_LOCAL_NAME } from "./config-discovery.js";
4
+
5
+ /**
6
+ * Parses `.env` / `.env.local` style content into a flat key/value map.
7
+ *
8
+ * Supported syntax (deliberately small — this is not a shell):
9
+ * - `KEY=value`
10
+ * - `export KEY=value` (the `export ` prefix is ignored)
11
+ * - `# comment` lines and blank lines are skipped
12
+ * - single- or double-quoted values are unquoted; `\n` and `\t` inside
13
+ * double quotes become real newlines/tabs
14
+ * - an unquoted value has a trailing ` # comment` stripped and is trimmed
15
+ *
16
+ * Lines that do not contain `=` are ignored rather than treated as errors, so a
17
+ * stray note in the file cannot break a build.
18
+ *
19
+ * @param content - Raw file contents.
20
+ * @returns Parsed variables in file order (later duplicates win).
21
+ */
22
+ export function parseEnvLocal(content: string): Record<string, string> {
23
+ const result: Record<string, string> = {};
24
+
25
+ for (const rawLine of content.split(/\r?\n/)) {
26
+ const line = rawLine.trim();
27
+ if (line === "" || line.startsWith("#")) continue;
28
+
29
+ const withoutExport = line.startsWith("export ") ? line.slice(7).trim() : line;
30
+
31
+ const eq = withoutExport.indexOf("=");
32
+ if (eq <= 0) continue;
33
+
34
+ const key = withoutExport.slice(0, eq).trim();
35
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
36
+
37
+ result[key] = parseValue(withoutExport.slice(eq + 1));
38
+ }
39
+
40
+ return result;
41
+ }
42
+
43
+ /**
44
+ * Unquotes and cleans up the right-hand side of a `KEY=value` line.
45
+ */
46
+ function parseValue(raw: string): string {
47
+ const trimmed = raw.trim();
48
+
49
+ if (trimmed.startsWith('"')) {
50
+ const end = findClosingQuote(trimmed, '"');
51
+ if (end !== -1) {
52
+ return trimmed
53
+ .slice(1, end)
54
+ .replace(/\\n/g, "\n")
55
+ .replace(/\\t/g, "\t")
56
+ .replace(/\\"/g, '"');
57
+ }
58
+ }
59
+
60
+ if (trimmed.startsWith("'")) {
61
+ const end = findClosingQuote(trimmed, "'");
62
+ if (end !== -1) return trimmed.slice(1, end);
63
+ }
64
+
65
+ // Unquoted: strip an inline comment, then trim.
66
+ const commentAt = trimmed.search(/\s#/);
67
+ const body = commentAt === -1 ? trimmed : trimmed.slice(0, commentAt);
68
+ return body.trim();
69
+ }
70
+
71
+ /**
72
+ * Finds the index of the closing quote for a value that starts with `quote`,
73
+ * skipping backslash-escaped quotes. Returns -1 when unterminated.
74
+ */
75
+ function findClosingQuote(value: string, quote: string): number {
76
+ for (let i = 1; i < value.length; i++) {
77
+ if (value[i] === "\\") {
78
+ i++;
79
+ continue;
80
+ }
81
+ if (value[i] === quote) return i;
82
+ }
83
+ return -1;
84
+ }
85
+
86
+ /** The outcome of an attempted env file load. */
87
+ export type EnvLocalLoadResult = {
88
+ /** Absolute path checked. */
89
+ filePath: string;
90
+ /** True when the file existed and was read. */
91
+ loaded: boolean;
92
+ /** Names of variables that were injected into process.env. */
93
+ applied: string[];
94
+ /** Names present in the file but already set in the environment (left alone). */
95
+ skipped: string[];
96
+ };
97
+
98
+ /** The outcome of loading both env layers. */
99
+ export type EnvFilesLoadResult = {
100
+ /** Result for the gitignored `.sous/.env.local` layer (loaded first, so it wins). */
101
+ local: EnvLocalLoadResult;
102
+ /** Result for the committed `.sous/.env` defaults layer (loaded second). */
103
+ defaults: EnvLocalLoadResult;
104
+ };
105
+
106
+ /**
107
+ * Loads a single env file into `env`, if it exists.
108
+ *
109
+ * A variable already present in `env` is never overwritten, which is what makes
110
+ * layering work: whoever gets there first wins.
111
+ *
112
+ * @param filePath - Absolute path to the env file.
113
+ * @param env - The environment object to mutate.
114
+ * @returns What was found and what was applied.
115
+ */
116
+ function loadEnvFile(filePath: string, env: NodeJS.ProcessEnv): EnvLocalLoadResult {
117
+ if (!fs.existsSync(filePath)) {
118
+ return { filePath, loaded: false, applied: [], skipped: [] };
119
+ }
120
+
121
+ const parsed = parseEnvLocal(fs.readFileSync(filePath, "utf8"));
122
+ const applied: string[] = [];
123
+ const skipped: string[] = [];
124
+
125
+ for (const [key, value] of Object.entries(parsed)) {
126
+ if (env[key] !== undefined) {
127
+ skipped.push(key);
128
+ continue;
129
+ }
130
+ env[key] = value;
131
+ applied.push(key);
132
+ }
133
+
134
+ return { filePath, loaded: true, applied, skipped };
135
+ }
136
+
137
+ /**
138
+ * Loads `<sousDir>/.env` (committed shared defaults) into `process.env`, if it
139
+ * exists. Anything already set is left alone, so both your shell and
140
+ * `.env.local` outrank it.
141
+ *
142
+ * @param sousDir - The discovered `.sous/` directory.
143
+ * @param env - The environment object to mutate (injectable for tests).
144
+ * @returns What was found and what was applied.
145
+ */
146
+ export function loadEnvDefaults(
147
+ sousDir: string,
148
+ env: NodeJS.ProcessEnv = process.env
149
+ ): EnvLocalLoadResult {
150
+ return loadEnvFile(path.join(sousDir, ENV_DEFAULTS_NAME), env);
151
+ }
152
+
153
+ /**
154
+ * Loads `<sousDir>/.env.local` (machine-specific values and secrets) into
155
+ * `process.env`, if it exists.
156
+ *
157
+ * Existing environment values always win: a variable already set in the real
158
+ * environment is never overwritten, so `FOO=bar xcv build` behaves as expected.
159
+ *
160
+ * @param sousDir - The discovered `.sous/` directory.
161
+ * @param env - The environment object to mutate (injectable for tests).
162
+ * @returns What was found and what was applied.
163
+ */
164
+ export function loadEnvLocal(
165
+ sousDir: string,
166
+ env: NodeJS.ProcessEnv = process.env
167
+ ): EnvLocalLoadResult {
168
+ return loadEnvFile(path.join(sousDir, ENV_LOCAL_NAME), env);
169
+ }
170
+
171
+ /**
172
+ * Loads both env layers out of `sousDir`. Precedence, highest first:
173
+ *
174
+ * real shell environment > `.sous/.env.local` > `.sous/.env`
175
+ *
176
+ * Because no load ever overwrites a value that is already set, the FIRST writer
177
+ * of a key wins. So the higher-precedence file is loaded first: `.env.local`,
178
+ * then `.env` fills in only the keys nobody else supplied. The real shell
179
+ * environment is already populated before either runs, so it outranks both.
180
+ *
181
+ * Must be called before any config or variable resolution so the `_env` block
182
+ * sees the injected values.
183
+ *
184
+ * @param sousDir - The discovered `.sous/` directory.
185
+ * @param env - The environment object to mutate (injectable for tests).
186
+ * @returns The per-file results.
187
+ */
188
+ export function loadEnvFiles(
189
+ sousDir: string,
190
+ env: NodeJS.ProcessEnv = process.env
191
+ ): EnvFilesLoadResult {
192
+ const local = loadEnvLocal(sousDir, env);
193
+ const defaults = loadEnvDefaults(sousDir, env);
194
+ return { local, defaults };
195
+ }