@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,580 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { get_encoding, type Tiktoken } from "tiktoken";
|
|
5
|
+
import { displayError, log, showVar, subheading, warning } from "../utils/formatting.js";
|
|
6
|
+
import { createLiquidEngine } from "../templating/init-liquid-engine.js";
|
|
7
|
+
import {
|
|
8
|
+
StateService,
|
|
9
|
+
type StateFile,
|
|
10
|
+
type StateFileEntry,
|
|
11
|
+
hashContent,
|
|
12
|
+
recordDirCreation,
|
|
13
|
+
} from "./state.js";
|
|
14
|
+
import { resolveIncludeCandidates, type AliasMap } from "./include-resolver.js";
|
|
15
|
+
|
|
16
|
+
export type ResolvedOutput = {
|
|
17
|
+
destinationFile?: string;
|
|
18
|
+
destinationDir?: string;
|
|
19
|
+
/** Resolved variable scope for this output; ${varName} references in compiled content are substituted. */
|
|
20
|
+
vars?: Record<string, string>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Resolved runtime context configuration for AGENTS-style compilation targets. */
|
|
24
|
+
export type ResolvedRuntimeContext = {
|
|
25
|
+
/** Absolute path to the git repo used for branch detection. */
|
|
26
|
+
gitRoot: string;
|
|
27
|
+
/** Absolute path where the generated session context file is written. */
|
|
28
|
+
outputPath: string;
|
|
29
|
+
/** Root directory for task files; branch name is appended to find the active task file. */
|
|
30
|
+
taskFileRoot: string;
|
|
31
|
+
/** Pattern used to determine whether the current branch has a task file. */
|
|
32
|
+
branchPattern: RegExp;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type CompilationTarget = {
|
|
36
|
+
rootInputPath: string;
|
|
37
|
+
outputs: ResolvedOutput[];
|
|
38
|
+
includeSourceComments?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Base directory used to compute relative output paths when mirroring source structure
|
|
41
|
+
* under a destinationDir. Populated by the settings resolver (inferred from the entryGlob
|
|
42
|
+
* pattern, or set explicitly via globBase in the target config).
|
|
43
|
+
*/
|
|
44
|
+
globBase?: string;
|
|
45
|
+
/**
|
|
46
|
+
* When set, generates a runtime session context file (branch name, task file) before
|
|
47
|
+
* compilation. Only set for AGENTS-style targets that have runtimeContext configured.
|
|
48
|
+
*/
|
|
49
|
+
runtimeContext?: ResolvedRuntimeContext;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type CompilationConfig = {
|
|
53
|
+
includeSourceComments?: boolean;
|
|
54
|
+
targets: CompilationTarget[];
|
|
55
|
+
/** Resolved `@include` alias map (name → ordered base dirs). */
|
|
56
|
+
aliases?: Record<string, string[]>;
|
|
57
|
+
/** Variable scope for `${var}` substitution in `@include` paths. */
|
|
58
|
+
includeScope?: Record<string, string>;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type CompilationServiceOptions = {
|
|
62
|
+
strict?: boolean;
|
|
63
|
+
rebuild?: boolean;
|
|
64
|
+
dryRun?: boolean;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Infers the static base directory from a glob pattern.
|
|
69
|
+
* Returns the longest path prefix before the first glob character (* ? { [).
|
|
70
|
+
*
|
|
71
|
+
* Examples:
|
|
72
|
+
* "/foo/bar/**\/*" → "/foo/bar"
|
|
73
|
+
* "/foo/bar/*\/baz.md" → "/foo/bar"
|
|
74
|
+
* "/**\/*" → "/"
|
|
75
|
+
*/
|
|
76
|
+
export function inferGlobBase(pattern: string): string {
|
|
77
|
+
const parts = pattern.split("/");
|
|
78
|
+
const staticParts: string[] = [];
|
|
79
|
+
for (const part of parts) {
|
|
80
|
+
if (/[*?{[]/.test(part)) break;
|
|
81
|
+
staticParts.push(part);
|
|
82
|
+
}
|
|
83
|
+
// Remove trailing empty string from a trailing slash (e.g. "/foo/bar/")
|
|
84
|
+
if (staticParts.length > 0 && staticParts[staticParts.length - 1] === "") {
|
|
85
|
+
staticParts.pop();
|
|
86
|
+
}
|
|
87
|
+
const joined = staticParts.join("/");
|
|
88
|
+
return joined || "/";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class CompilationService {
|
|
92
|
+
private strict: boolean;
|
|
93
|
+
private rebuild: boolean;
|
|
94
|
+
private dryRun: boolean;
|
|
95
|
+
private visited: Set<string>;
|
|
96
|
+
private includeStack: string[];
|
|
97
|
+
private errors: string[];
|
|
98
|
+
private includeSourceComments: boolean;
|
|
99
|
+
private currentIncludeSourceComments: boolean;
|
|
100
|
+
private encoder: Tiktoken | null;
|
|
101
|
+
private numberFormatter: Intl.NumberFormat;
|
|
102
|
+
private aliases: AliasMap;
|
|
103
|
+
private includeScope: Record<string, string>;
|
|
104
|
+
/**
|
|
105
|
+
* `.tpl.` outputs that were written without a variable scope, so LiquidJS never
|
|
106
|
+
* ran and the template shipped with its tags intact. Reported at the end of the
|
|
107
|
+
* compile run. Each entry is `<source> → <destination>`.
|
|
108
|
+
*/
|
|
109
|
+
private unrenderedTemplates: string[];
|
|
110
|
+
|
|
111
|
+
constructor(options: CompilationServiceOptions = {}) {
|
|
112
|
+
this.strict = options.strict ?? false;
|
|
113
|
+
this.rebuild = options.rebuild ?? false;
|
|
114
|
+
this.dryRun = options.dryRun ?? false;
|
|
115
|
+
this.visited = new Set();
|
|
116
|
+
this.includeStack = [];
|
|
117
|
+
this.errors = [];
|
|
118
|
+
this.includeSourceComments = false;
|
|
119
|
+
this.currentIncludeSourceComments = false;
|
|
120
|
+
this.encoder = null;
|
|
121
|
+
this.numberFormatter = new Intl.NumberFormat("en-US");
|
|
122
|
+
this.aliases = {};
|
|
123
|
+
this.includeScope = {};
|
|
124
|
+
this.unrenderedTemplates = [];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Lazily initialize the tokenizer. */
|
|
128
|
+
private initializeEncoder(): void {
|
|
129
|
+
if (this.encoder) return;
|
|
130
|
+
this.encoder = get_encoding("o200k_base");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Handle errors according to strict mode. */
|
|
134
|
+
private handleError(message: string): void {
|
|
135
|
+
this.errors.push(message);
|
|
136
|
+
displayError(message);
|
|
137
|
+
|
|
138
|
+
if (this.strict) {
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Process @<path> includes in content.
|
|
145
|
+
*
|
|
146
|
+
* Matches an `@`-prefixed `.md` path on its own line. The path may be:
|
|
147
|
+
* - relative to the including file (`@sections/intro.md`),
|
|
148
|
+
* - a `${var}`-substituted path (`@${sousRootPath}/x.md`),
|
|
149
|
+
* - or an alias path (`@~sous-shared/memories/x.md`, `@docs/x.md`), where the
|
|
150
|
+
* first segment (up to `/` or `:`) names a registered alias.
|
|
151
|
+
*
|
|
152
|
+
* Lines inside fenced code blocks (``` or ~~~, per CommonMark) are left
|
|
153
|
+
* verbatim, so include syntax can be documented without being executed.
|
|
154
|
+
*
|
|
155
|
+
* Resolution produces an ordered candidate list (see include-resolver); the
|
|
156
|
+
* first candidate that exists on disk is used. If none exist, it errors,
|
|
157
|
+
* listing every path tried.
|
|
158
|
+
*/
|
|
159
|
+
private processIncludes(content: string, baseDir: string, projectRoot: string): string {
|
|
160
|
+
// First segment allows ~, then path chars; separators / and :; allows ${...}.
|
|
161
|
+
const includePattern = /^@([~a-zA-Z0-9_${}][a-zA-Z0-9_\-/.:${}]*\.md)$/;
|
|
162
|
+
const fenceOpenPattern = /^ {0,3}(`{3,}|~{3,})/;
|
|
163
|
+
const fenceClosePattern = /^ {0,3}(`{3,}|~{3,})[ \t]*$/;
|
|
164
|
+
|
|
165
|
+
const out: string[] = [];
|
|
166
|
+
let fenceChar: string | null = null;
|
|
167
|
+
let fenceLength = 0;
|
|
168
|
+
|
|
169
|
+
for (const line of content.split("\n")) {
|
|
170
|
+
if (fenceChar !== null) {
|
|
171
|
+
// Inside a fence: emit verbatim; only a matching closing fence ends it.
|
|
172
|
+
const close = fenceClosePattern.exec(line);
|
|
173
|
+
if (close && close[1][0] === fenceChar && close[1].length >= fenceLength) {
|
|
174
|
+
fenceChar = null;
|
|
175
|
+
}
|
|
176
|
+
out.push(line);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const open = fenceOpenPattern.exec(line);
|
|
181
|
+
if (open) {
|
|
182
|
+
fenceChar = open[1][0];
|
|
183
|
+
fenceLength = open[1].length;
|
|
184
|
+
out.push(line);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const match = includePattern.exec(line);
|
|
189
|
+
if (!match) {
|
|
190
|
+
out.push(line);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const includePath = match[1].trim();
|
|
195
|
+
const candidates = resolveIncludeCandidates(includePath, {
|
|
196
|
+
aliases: this.aliases,
|
|
197
|
+
scope: this.includeScope,
|
|
198
|
+
baseDir,
|
|
199
|
+
});
|
|
200
|
+
const fullPath = candidates.find((c) => fs.existsSync(c));
|
|
201
|
+
|
|
202
|
+
if (!fullPath) {
|
|
203
|
+
this.handleError(
|
|
204
|
+
`Include not found: @${includePath}\n tried:\n${candidates.map((c) => ` - ${c}`).join("\n")}`
|
|
205
|
+
);
|
|
206
|
+
out.push("");
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (this.includeStack.includes(fullPath)) {
|
|
211
|
+
this.handleError(
|
|
212
|
+
`Circular dependency detected: ${this.includeStack.join(" -> ")} -> ${fullPath}`
|
|
213
|
+
);
|
|
214
|
+
out.push("");
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const includedContent = this.loadFile(fullPath, projectRoot);
|
|
219
|
+
if (includedContent !== null) {
|
|
220
|
+
const relativePath = path.relative(projectRoot, fullPath);
|
|
221
|
+
const sourceComment = this.currentIncludeSourceComments
|
|
222
|
+
? `<!-- from: ${relativePath} -->\n`
|
|
223
|
+
: "";
|
|
224
|
+
out.push(sourceComment + includedContent);
|
|
225
|
+
} else {
|
|
226
|
+
out.push("");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return out.join("\n");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Load a file and recursively process its includes. */
|
|
234
|
+
private loadFile(filePath: string, projectRoot: string): string | null {
|
|
235
|
+
if (!fs.existsSync(filePath)) {
|
|
236
|
+
this.handleError(`File not found: ${filePath}`);
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (this.visited.has(filePath)) {
|
|
241
|
+
return "";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
this.includeStack.push(filePath);
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
248
|
+
const baseDir = path.dirname(filePath);
|
|
249
|
+
const processedContent = this.processIncludes(content, baseDir, projectRoot);
|
|
250
|
+
this.visited.add(filePath);
|
|
251
|
+
return processedContent;
|
|
252
|
+
} catch (error) {
|
|
253
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
254
|
+
this.handleError(`Failed to read file ${filePath}: ${message}`);
|
|
255
|
+
return null;
|
|
256
|
+
} finally {
|
|
257
|
+
this.includeStack.pop();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Render template content using LiquidJS with the given variable scope. */
|
|
262
|
+
private async renderContent(content: string, vars: Record<string, string>, roots: string[]): Promise<string> {
|
|
263
|
+
const engine = createLiquidEngine(roots, {
|
|
264
|
+
aliases: this.aliases,
|
|
265
|
+
scope: { ...this.includeScope, ...vars },
|
|
266
|
+
});
|
|
267
|
+
try {
|
|
268
|
+
return await engine.parseAndRender(content, vars);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
271
|
+
this.handleError(`Template rendering error: ${message}`);
|
|
272
|
+
return content;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Generate and write the runtime session context include file. */
|
|
277
|
+
private generateRuntimeSessionContext(target: CompilationTarget): void {
|
|
278
|
+
const ctx = target.runtimeContext!;
|
|
279
|
+
const runtimeDir = path.dirname(ctx.outputPath);
|
|
280
|
+
|
|
281
|
+
if (!fs.existsSync(runtimeDir)) {
|
|
282
|
+
fs.mkdirSync(runtimeDir, { recursive: true });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let branchName = "unknown";
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
branchName = execFileSync(
|
|
289
|
+
"git",
|
|
290
|
+
["-C", ctx.gitRoot, "rev-parse", "--abbrev-ref", "HEAD"],
|
|
291
|
+
{ encoding: "utf8" }
|
|
292
|
+
).trim();
|
|
293
|
+
} catch (error) {
|
|
294
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
295
|
+
this.handleError(`Failed to resolve current git branch: ${message}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const runtimeHeader = `## Runtime Session Context
|
|
299
|
+
|
|
300
|
+
The following information is specific to this chat session. It was generated automatically and
|
|
301
|
+
embedded into this AGENTS.md file in order to save you and the user some effort in setting up
|
|
302
|
+
to begin work.
|
|
303
|
+
|
|
304
|
+
### Environment Info
|
|
305
|
+
|
|
306
|
+
Current git branch: \`${branchName}\`
|
|
307
|
+
|
|
308
|
+
### Current Task File
|
|
309
|
+
|
|
310
|
+
`;
|
|
311
|
+
|
|
312
|
+
let taskFileBlock = "";
|
|
313
|
+
|
|
314
|
+
if (ctx.branchPattern.test(branchName)) {
|
|
315
|
+
const taskFilePath = path.join(ctx.taskFileRoot, `${branchName}.md`);
|
|
316
|
+
|
|
317
|
+
if (!fs.existsSync(taskFilePath)) {
|
|
318
|
+
taskFileBlock = `
|
|
319
|
+
Although we're currently on a feature branch that is tied to a Jira ticket, this branch does not,
|
|
320
|
+
yet, have a task file. It's likely that one of the first things we'll be doing in this session is
|
|
321
|
+
instantiating a task file, but be sure to ask before you do that.
|
|
322
|
+
`;
|
|
323
|
+
} else {
|
|
324
|
+
const taskFileContents = fs.readFileSync(taskFilePath, "utf8").replace(/\n+$/u, "");
|
|
325
|
+
taskFileBlock = `
|
|
326
|
+
We're currently on a feature branch that has an existing task file. The full contents of the task
|
|
327
|
+
file are included below:
|
|
328
|
+
|
|
329
|
+
--- Start Task File: ${taskFilePath} ---
|
|
330
|
+
${taskFileContents}
|
|
331
|
+
--- End Task File: ${taskFilePath} ---
|
|
332
|
+
`;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
fs.writeFileSync(ctx.outputPath, `${runtimeHeader}${taskFileBlock}\n`, "utf8");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Compile a single target, writing to all of its outputs. */
|
|
340
|
+
private async compileTarget(
|
|
341
|
+
target: CompilationTarget,
|
|
342
|
+
state: StateFile,
|
|
343
|
+
stateFileEntries: StateFileEntry[]
|
|
344
|
+
): Promise<boolean> {
|
|
345
|
+
subheading(path.basename(target.rootInputPath), "▷");
|
|
346
|
+
showVar("Entry Point", target.rootInputPath);
|
|
347
|
+
|
|
348
|
+
this.initializeEncoder();
|
|
349
|
+
|
|
350
|
+
this.visited.clear();
|
|
351
|
+
this.includeStack = [];
|
|
352
|
+
this.currentIncludeSourceComments =
|
|
353
|
+
typeof target.includeSourceComments === "boolean"
|
|
354
|
+
? target.includeSourceComments
|
|
355
|
+
: this.includeSourceComments;
|
|
356
|
+
|
|
357
|
+
if (target.runtimeContext) {
|
|
358
|
+
this.generateRuntimeSessionContext(target);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const promptsRoot = path.dirname(target.rootInputPath);
|
|
362
|
+
const content = this.loadFile(target.rootInputPath, promptsRoot);
|
|
363
|
+
|
|
364
|
+
if (content === null) {
|
|
365
|
+
displayError(`Failed to compile ${target.rootInputPath}`);
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Compute source hash once per target from the assembled content
|
|
370
|
+
const srcHash = hashContent(content);
|
|
371
|
+
|
|
372
|
+
let allSucceeded = true;
|
|
373
|
+
|
|
374
|
+
const isTpl = path.basename(target.rootInputPath).includes(".tpl.");
|
|
375
|
+
|
|
376
|
+
for (const output of target.outputs) {
|
|
377
|
+
// Resolve destination path: prefer destinationFile, fall back to destinationDir mirroring
|
|
378
|
+
let destFile: string;
|
|
379
|
+
|
|
380
|
+
if (output.destinationFile) {
|
|
381
|
+
destFile = output.destinationFile;
|
|
382
|
+
} else if (output.destinationDir) {
|
|
383
|
+
// Mirror source path structure under destinationDir
|
|
384
|
+
const sourceRelative = target.globBase
|
|
385
|
+
? path.relative(target.globBase, target.rootInputPath)
|
|
386
|
+
: path.basename(target.rootInputPath);
|
|
387
|
+
|
|
388
|
+
// Strip .tpl. from the output filename (e.g. foo.tpl.md -> foo.md)
|
|
389
|
+
const outputRelative = sourceRelative.replace(/\.tpl\./, ".");
|
|
390
|
+
|
|
391
|
+
destFile = path.join(output.destinationDir, outputRelative);
|
|
392
|
+
} else {
|
|
393
|
+
// Neither set — skip
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Skip if content is unchanged and file already exists (unless --rebuild)
|
|
398
|
+
const existingEntry = state.files.find(f => f.dest === destFile);
|
|
399
|
+
if (
|
|
400
|
+
!this.rebuild &&
|
|
401
|
+
existingEntry?.srcHash === srcHash &&
|
|
402
|
+
fs.existsSync(destFile)
|
|
403
|
+
) {
|
|
404
|
+
log(` ⊘ ${destFile} (unchanged)`);
|
|
405
|
+
stateFileEntries.push(existingEntry);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Dry-run: report what would be written
|
|
410
|
+
if (this.dryRun) {
|
|
411
|
+
log(` ○ ${destFile} (would write)`);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// A `.tpl.` source with no variable scope never reaches LiquidJS, so its
|
|
416
|
+
// tags would ship verbatim. Record it; reported loudly after the run.
|
|
417
|
+
//
|
|
418
|
+
// NOTE: a config-driven build cannot reach this, because the settings
|
|
419
|
+
// resolver always sets `vars` on every output (at minimum the inherited
|
|
420
|
+
// scope). It is a real guard for callers that build a CompilationConfig
|
|
421
|
+
// directly, and it stays as a tripwire in case the resolver ever changes.
|
|
422
|
+
if (isTpl && !output.vars) {
|
|
423
|
+
this.unrenderedTemplates.push(`${target.rootInputPath} → ${destFile}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const resolvedContent = (isTpl && output.vars)
|
|
427
|
+
? await this.renderContent(content, {
|
|
428
|
+
...output.vars,
|
|
429
|
+
sousTemplatePath: target.rootInputPath,
|
|
430
|
+
sousTemplateDir: promptsRoot,
|
|
431
|
+
}, [promptsRoot])
|
|
432
|
+
: content;
|
|
433
|
+
const fileContent = resolvedContent;
|
|
434
|
+
const outputDir = path.dirname(destFile);
|
|
435
|
+
|
|
436
|
+
// Record all ancestor directories that Sous is about to create, from shallowest to deepest.
|
|
437
|
+
// mkdirSync({ recursive }) may create multiple levels; we must track each new one.
|
|
438
|
+
const dirsToCreate: string[] = [];
|
|
439
|
+
let walkDir = outputDir;
|
|
440
|
+
while (!fs.existsSync(walkDir)) {
|
|
441
|
+
dirsToCreate.unshift(walkDir);
|
|
442
|
+
const parent = path.dirname(walkDir);
|
|
443
|
+
if (parent === walkDir) break;
|
|
444
|
+
walkDir = parent;
|
|
445
|
+
}
|
|
446
|
+
if (dirsToCreate.length > 0) {
|
|
447
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
448
|
+
for (const dir of dirsToCreate) {
|
|
449
|
+
recordDirCreation(dir, state);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
try {
|
|
454
|
+
fs.writeFileSync(destFile, fileContent, "utf8");
|
|
455
|
+
|
|
456
|
+
if (destFile.endsWith(".sh")) {
|
|
457
|
+
fs.chmodSync(destFile, 0o755);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (!isTpl) {
|
|
461
|
+
try {
|
|
462
|
+
const srcStat = fs.statSync(target.rootInputPath);
|
|
463
|
+
fs.chmodSync(destFile, srcStat.mode);
|
|
464
|
+
} catch {
|
|
465
|
+
// Ignore chmod failures on unsupported platforms
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const destHash = hashContent(fileContent);
|
|
470
|
+
const entry: StateFileEntry = {
|
|
471
|
+
dest: destFile,
|
|
472
|
+
srcHash,
|
|
473
|
+
destHash,
|
|
474
|
+
size: Buffer.byteLength(fileContent, "utf8"),
|
|
475
|
+
builtAt: new Date().toISOString(),
|
|
476
|
+
};
|
|
477
|
+
stateFileEntries.push(entry);
|
|
478
|
+
|
|
479
|
+
const tokenCount = this.encoder!.encode(fileContent).length;
|
|
480
|
+
const formattedTokenCount = this.numberFormatter.format(tokenCount);
|
|
481
|
+
log(` ✓ ${destFile} (~${formattedTokenCount} tokens)`);
|
|
482
|
+
} catch (error) {
|
|
483
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
484
|
+
this.handleError(`Failed to write ${destFile}: ${message}`);
|
|
485
|
+
allSucceeded = false;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return allSucceeded;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Compile all targets from the given config.
|
|
494
|
+
* Returns true if all targets compiled successfully.
|
|
495
|
+
*/
|
|
496
|
+
async compile(config: CompilationConfig, stateFilePath?: string): Promise<boolean> {
|
|
497
|
+
const stateService = new StateService();
|
|
498
|
+
let state: StateFile = stateFilePath
|
|
499
|
+
? ((await stateService.load(stateFilePath)) ?? {
|
|
500
|
+
lastBuild: "",
|
|
501
|
+
resolvedVars: {},
|
|
502
|
+
dirs: [],
|
|
503
|
+
files: [],
|
|
504
|
+
})
|
|
505
|
+
: { lastBuild: "", resolvedVars: {}, dirs: [], files: [] };
|
|
506
|
+
|
|
507
|
+
try {
|
|
508
|
+
// Only complain about a value that is actually present and wrong. The
|
|
509
|
+
// settings resolver always sets the key (to undefined when the config omits
|
|
510
|
+
// it), so a hasOwnProperty check alone fired on every single build.
|
|
511
|
+
if (
|
|
512
|
+
config.includeSourceComments !== undefined &&
|
|
513
|
+
typeof config.includeSourceComments !== "boolean"
|
|
514
|
+
) {
|
|
515
|
+
this.handleError("Config option 'includeSourceComments' must be a boolean");
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
this.includeSourceComments = config.includeSourceComments === true;
|
|
519
|
+
this.aliases = config.aliases ?? {};
|
|
520
|
+
this.includeScope = config.includeScope ?? {};
|
|
521
|
+
|
|
522
|
+
this.initializeEncoder();
|
|
523
|
+
|
|
524
|
+
let allSucceeded = true;
|
|
525
|
+
const stateFileEntries: StateFileEntry[] = [];
|
|
526
|
+
this.unrenderedTemplates = [];
|
|
527
|
+
|
|
528
|
+
for (const target of config.targets) {
|
|
529
|
+
const success = await this.compileTarget(target, state, stateFileEntries);
|
|
530
|
+
if (!success) allSucceeded = false;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (this.unrenderedTemplates.length > 0) {
|
|
534
|
+
const count = this.unrenderedTemplates.length;
|
|
535
|
+
const message =
|
|
536
|
+
`${count} TEMPLATE FILE(S) WERE COPIED WITHOUT BEING RENDERED.\n` +
|
|
537
|
+
`Each of these is a '.tpl.' source written to an output that has no vars, so\n` +
|
|
538
|
+
`LiquidJS never ran and the {{ tags }} are still in the output file:\n` +
|
|
539
|
+
this.unrenderedTemplates.map((entry) => ` - ${entry}`).join("\n") +
|
|
540
|
+
`\nFix: add a '_vars' block to the output (an empty '_vars: {}' is enough to\n` +
|
|
541
|
+
`enable rendering), or rename the source so it does not contain '.tpl.'.`;
|
|
542
|
+
|
|
543
|
+
if (this.strict) {
|
|
544
|
+
// Recorded as a failure rather than exiting here, so state still gets
|
|
545
|
+
// written and the caller decides the exit code.
|
|
546
|
+
this.errors.push(message);
|
|
547
|
+
displayError(message);
|
|
548
|
+
allSucceeded = false;
|
|
549
|
+
} else {
|
|
550
|
+
warning(message);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (this.errors.length > 0) {
|
|
555
|
+
subheading(`Done with ${this.errors.length} error(s).`, "⚠");
|
|
556
|
+
} else {
|
|
557
|
+
subheading("Done.", "✓");
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Update state with fresh entries
|
|
561
|
+
state.files = stateFileEntries;
|
|
562
|
+
state.lastBuild = new Date().toISOString();
|
|
563
|
+
|
|
564
|
+
if (stateFilePath && !this.dryRun) {
|
|
565
|
+
await stateService.save(stateFilePath, state);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return allSucceeded;
|
|
569
|
+
} finally {
|
|
570
|
+
if (this.encoder) {
|
|
571
|
+
this.encoder.free();
|
|
572
|
+
this.encoder = null;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Backward-compat alias so existing imports of MarkdownCompiler keep working
|
|
579
|
+
export { CompilationService as MarkdownCompiler };
|
|
580
|
+
export type { CompilationServiceOptions as MarkdownCompilerOptions };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { VarScope } from "./settings.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Manages PID files for the Sous watcher, enforcing single-instance per project.
|
|
7
|
+
* The PID file lives at <sousDir>/sous.pid by default.
|
|
8
|
+
*/
|
|
9
|
+
export class PidService {
|
|
10
|
+
/**
|
|
11
|
+
* Returns the PID file path for a project.
|
|
12
|
+
*
|
|
13
|
+
* Precedence (mirrors StateService.getFilePath):
|
|
14
|
+
* 1. A `pidFilePath` variable in the PROJECT scope (explicit override).
|
|
15
|
+
* 2. `<sousDir>/sous.pid`, or `<sousDir>/<key>.sous.pid` when the config
|
|
16
|
+
* defines several projects.
|
|
17
|
+
* 3. `<cwd>/<key>.sous.pid` as a last resort.
|
|
18
|
+
*
|
|
19
|
+
* @param projectKey - The project's key in the config's `projects` map.
|
|
20
|
+
* @param projectVars - The resolved PROJECT-scope variables.
|
|
21
|
+
* @param projectCount - How many projects the active config defines.
|
|
22
|
+
*/
|
|
23
|
+
getFilePath(projectKey: string, projectVars?: VarScope, projectCount = 1): string {
|
|
24
|
+
if (projectVars?.pidFilePath) {
|
|
25
|
+
return projectVars.pidFilePath;
|
|
26
|
+
}
|
|
27
|
+
if (projectVars?.sousDir) {
|
|
28
|
+
const fileName = projectCount > 1 ? `${projectKey}.sous.pid` : "sous.pid";
|
|
29
|
+
return path.join(projectVars.sousDir, fileName);
|
|
30
|
+
}
|
|
31
|
+
return path.join(process.cwd(), `${projectKey}.sous.pid`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Checks if a watcher is already running for this project.
|
|
36
|
+
* - If a PID file exists and the process is alive, throws an Error.
|
|
37
|
+
* - If a PID file exists but the process is dead (stale), overwrites it.
|
|
38
|
+
* - If no PID file exists, creates one with the current process.pid.
|
|
39
|
+
*
|
|
40
|
+
* @param pidFilePath - Path from getFilePath().
|
|
41
|
+
* @param projectKey - Project key, named in the "already running" error.
|
|
42
|
+
*/
|
|
43
|
+
async acquire(pidFilePath: string, projectKey?: string): Promise<void> {
|
|
44
|
+
if (fs.existsSync(pidFilePath)) {
|
|
45
|
+
const raw = fs.readFileSync(pidFilePath, "utf8").trim();
|
|
46
|
+
const existingPid = parseInt(raw, 10);
|
|
47
|
+
|
|
48
|
+
if (!isNaN(existingPid)) {
|
|
49
|
+
const alive = this._isProcessAlive(existingPid);
|
|
50
|
+
if (alive) {
|
|
51
|
+
const label = projectKey ?? path.basename(path.dirname(pidFilePath));
|
|
52
|
+
throw new Error(
|
|
53
|
+
`A watcher is already running for project '${label}' (PID ${existingPid}). Stop it first or delete ${pidFilePath}.`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
// Stale PID file — fall through and overwrite
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const dir = path.dirname(pidFilePath);
|
|
61
|
+
if (!fs.existsSync(dir)) {
|
|
62
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
fs.writeFileSync(pidFilePath, String(process.pid), "utf8");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Removes the PID file. Called on clean exit.
|
|
69
|
+
*/
|
|
70
|
+
async release(pidFilePath: string): Promise<void> {
|
|
71
|
+
if (fs.existsSync(pidFilePath)) {
|
|
72
|
+
fs.unlinkSync(pidFilePath);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Returns true if the given PID corresponds to a running process.
|
|
78
|
+
* Uses signal 0 to probe without sending a real signal.
|
|
79
|
+
*/
|
|
80
|
+
_isProcessAlive(pid: number): boolean {
|
|
81
|
+
try {
|
|
82
|
+
process.kill(pid, 0);
|
|
83
|
+
return true;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|