@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.
- package/LICENSE +201 -0
- package/README.md +154 -0
- package/bin/run.js +17 -0
- package/bin/xcv +5 -0
- package/package.json +81 -0
- package/shared-prompts/_partials/resume-task.md +51 -0
- package/shared-prompts/_partials/sub-agent-delegation.md +32 -0
- package/shared-prompts/_partials/update-task-file.md +52 -0
- package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +52 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +102 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +81 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +126 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +92 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +61 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +65 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +96 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +104 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +243 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +148 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +383 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +267 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +56 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +169 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +59 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +25 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +140 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +140 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +1 -0
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +185 -0
- package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +52 -0
- package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +59 -0
- package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +47 -0
- package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +26 -0
- package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +58 -0
- package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +27 -0
- package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +34 -0
- package/shared-prompts/skills/sous-skills/about-agent-skills/SKILL.tpl.md +177 -0
- package/shared-prompts/skills/sous-skills/about-agent-skills/examples/about-something.md +45 -0
- package/shared-prompts/skills/sous-skills/about-agent-skills/examples/do-something.md +33 -0
- package/shared-prompts/skills/sous-skills/about-agent-skills/references/advanced-patterns.md +87 -0
- package/shared-prompts/skills/sous-skills/about-agent-skills/references/commands.md +46 -0
- package/shared-prompts/skills/sous-skills/about-agent-skills/references/frontmatter.md +25 -0
- package/shared-prompts/skills/sous-skills/about-agent-skills/references/substitutions.md +50 -0
- package/shared-prompts/skills/sous-skills/about-liquid-templates/SKILL.tpl.md +268 -0
- package/shared-prompts/skills/sous-skills/about-liquid-templates/references/liquid-filters.md +82 -0
- package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +51 -0
- package/shared-prompts/skills/sous-skills/create-skill/SKILL.tpl.md +114 -0
- package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +122 -0
- package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +80 -0
- package/shared-prompts/skills/task-files/go/SKILL.tpl.md +14 -0
- package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +13 -0
- package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +93 -0
- package/shared-prompts/skills/task-files/update/SKILL.tpl.md +14 -0
- package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +13 -0
- package/src/base-command.ts +163 -0
- package/src/commands/build.ts +196 -0
- package/src/commands/clear.ts +71 -0
- package/src/commands/compile.ts +95 -0
- package/src/commands/launch.ts +111 -0
- package/src/commands/prune.ts +48 -0
- package/src/lib/build-service.ts +258 -0
- package/src/lib/config-discovery.ts +199 -0
- package/src/lib/env-local.ts +195 -0
- package/src/lib/include-resolver.ts +146 -0
- package/src/lib/markdown-compiler.ts +580 -0
- package/src/lib/pid-service.ts +88 -0
- package/src/lib/settings.ts +695 -0
- package/src/lib/state.ts +135 -0
- package/src/lib/watch-service.ts +115 -0
- package/src/templating/filters/bullet-list.ts +9 -0
- package/src/templating/filters/index.ts +8 -0
- package/src/templating/init-liquid-engine.ts +82 -0
- package/src/templating/lib/glob-files.ts +74 -0
- package/src/templating/lib/import-export.ts +32 -0
- package/src/templating/lib/tag-args.ts +19 -0
- package/src/templating/tags/exportScalarVarsJs.ts +43 -0
- package/src/templating/tags/getFiles.ts +89 -0
- package/src/templating/tags/index.ts +14 -0
- package/src/templating/tags/listFiles.ts +54 -0
- package/src/templating/tags/showVars.ts +22 -0
- package/src/utils/formatting.ts +338 -0
- 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
|
+
}
|