@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,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
+ }