@sous-io/sous 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/README.md +115 -35
  2. package/bin/run.js +10 -1
  3. package/docs/markdown/README.md +27 -0
  4. package/docs/markdown/_sidebar.md +18 -0
  5. package/docs/markdown/commands.md +308 -0
  6. package/docs/markdown/config-discovery.md +74 -0
  7. package/docs/markdown/config-inspection.md +69 -0
  8. package/docs/markdown/config-layers.md +92 -0
  9. package/docs/markdown/config-variables.md +79 -0
  10. package/docs/markdown/configuration.md +71 -0
  11. package/docs/markdown/design-principles.md +59 -0
  12. package/docs/markdown/repositories-authoring.md +408 -0
  13. package/docs/markdown/repositories-consuming.md +580 -0
  14. package/docs/markdown/repositories-file-formats.md +1084 -0
  15. package/docs/markdown/repositories-variables.md +387 -0
  16. package/docs/markdown/repositories.md +303 -0
  17. package/docs/markdown/skill-categories.md +58 -0
  18. package/package.json +72 -8
  19. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/SKILL.tpl.md +20 -20
  20. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/about-something.md +2 -2
  21. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/do-something.md +1 -1
  22. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/advanced-patterns.md +6 -6
  23. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/commands.md +5 -5
  24. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/frontmatter.md +3 -3
  25. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/SKILL.tpl.md +40 -25
  26. package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +70 -0
  27. package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +75 -0
  28. package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/create-skill/SKILL.tpl.md +8 -9
  29. package/recipes/core/sous-skills/sous.recipe.yaml +45 -0
  30. package/sous.config.schema.json +337 -0
  31. package/src/base-command.ts +220 -67
  32. package/src/commands/build.ts +150 -73
  33. package/src/commands/clear.ts +23 -15
  34. package/src/commands/compile.ts +74 -16
  35. package/src/commands/config/get.ts +110 -0
  36. package/src/commands/config/show.ts +32 -0
  37. package/src/commands/config/validate.ts +53 -0
  38. package/src/commands/help.ts +46 -0
  39. package/src/commands/launch.ts +36 -14
  40. package/src/commands/lock/rebuild.ts +241 -0
  41. package/src/commands/lock/show.ts +115 -0
  42. package/src/commands/namespace/list.ts +117 -0
  43. package/src/commands/namespace/show.ts +110 -0
  44. package/src/commands/prune.ts +3 -11
  45. package/src/commands/recipe/list.ts +95 -0
  46. package/src/commands/recipe/show.ts +301 -0
  47. package/src/commands/repo/add.ts +145 -0
  48. package/src/commands/repo/gc.ts +172 -0
  49. package/src/commands/repo/init.ts +136 -0
  50. package/src/commands/repo/link.ts +500 -0
  51. package/src/commands/repo/list.ts +179 -0
  52. package/src/commands/repo/release.ts +619 -0
  53. package/src/commands/repo/remove.ts +193 -0
  54. package/src/commands/repo/search.ts +189 -0
  55. package/src/commands/repo/submit.ts +133 -0
  56. package/src/commands/repo/unlink.ts +147 -0
  57. package/src/commands/subscription/add.ts +285 -0
  58. package/src/commands/subscription/list.ts +129 -0
  59. package/src/commands/subscription/remove.ts +181 -0
  60. package/src/commands/vars/ask.ts +374 -0
  61. package/src/commands/vars/index.ts +79 -0
  62. package/src/commands/vars/list.ts +67 -0
  63. package/src/commands/vars/show.ts +77 -0
  64. package/src/config-command.ts +30 -0
  65. package/src/lib/build-service.ts +206 -54
  66. package/src/lib/config-discovery.ts +220 -27
  67. package/src/lib/config-inspect.ts +145 -0
  68. package/src/lib/config-kernel.mjs +377 -0
  69. package/src/lib/config-schema.ts +361 -0
  70. package/src/lib/env-file.ts +328 -0
  71. package/src/lib/env-local.ts +18 -1
  72. package/src/lib/errors.ts +32 -0
  73. package/src/lib/include-resolver.ts +108 -15
  74. package/src/lib/interactive.ts +165 -0
  75. package/src/lib/markdown-compiler.ts +118 -37
  76. package/src/lib/package-info.ts +25 -0
  77. package/src/lib/pid-service.ts +32 -21
  78. package/src/lib/refs/find.ts +589 -0
  79. package/src/lib/refs/index.ts +12 -0
  80. package/src/lib/refs/pick.ts +147 -0
  81. package/src/lib/refs/scopes.ts +61 -0
  82. package/src/lib/repos/catalog-display.ts +116 -0
  83. package/src/lib/repos/catalog-inputs.ts +160 -0
  84. package/src/lib/repos/catalog.ts +722 -0
  85. package/src/lib/repos/core-recipe.ts +105 -0
  86. package/src/lib/repos/defaults.ts +175 -0
  87. package/src/lib/repos/formats/common.ts +389 -0
  88. package/src/lib/repos/formats/index-file.ts +215 -0
  89. package/src/lib/repos/formats/links-map.ts +96 -0
  90. package/src/lib/repos/formats/lockfile.ts +167 -0
  91. package/src/lib/repos/formats/patterns.ts +57 -0
  92. package/src/lib/repos/formats/recipe-manifest.ts +395 -0
  93. package/src/lib/repos/formats/repo-manifest.ts +88 -0
  94. package/src/lib/repos/formats/store-entry.ts +84 -0
  95. package/src/lib/repos/freshness.ts +208 -0
  96. package/src/lib/repos/git-clone.ts +312 -0
  97. package/src/lib/repos/identity.ts +89 -0
  98. package/src/lib/repos/index.ts +58 -0
  99. package/src/lib/repos/links.ts +353 -0
  100. package/src/lib/repos/load-manifest.ts +236 -0
  101. package/src/lib/repos/lock-service.ts +453 -0
  102. package/src/lib/repos/locked-namespace-resolver.ts +90 -0
  103. package/src/lib/repos/locked-recipes.ts +254 -0
  104. package/src/lib/repos/managed-layer.ts +422 -0
  105. package/src/lib/repos/namespace-resolver.ts +370 -0
  106. package/src/lib/repos/providers/base.ts +206 -0
  107. package/src/lib/repos/providers/git.ts +233 -0
  108. package/src/lib/repos/providers/github.ts +294 -0
  109. package/src/lib/repos/providers/gitlab.ts +263 -0
  110. package/src/lib/repos/providers/http.ts +102 -0
  111. package/src/lib/repos/providers/index-cache.ts +382 -0
  112. package/src/lib/repos/providers/index.ts +106 -0
  113. package/src/lib/repos/providers/local.ts +391 -0
  114. package/src/lib/repos/providers/provider.ts +401 -0
  115. package/src/lib/repos/recipe-config-layers.ts +287 -0
  116. package/src/lib/repos/recipe-targets.ts +223 -0
  117. package/src/lib/repos/ref-search.ts +46 -0
  118. package/src/lib/repos/ref.ts +513 -0
  119. package/src/lib/repos/reference-report.ts +122 -0
  120. package/src/lib/repos/release/bump.ts +161 -0
  121. package/src/lib/repos/release/git-state.ts +305 -0
  122. package/src/lib/repos/release/index-builder.ts +635 -0
  123. package/src/lib/repos/release/index.ts +16 -0
  124. package/src/lib/repos/release/plan.ts +512 -0
  125. package/src/lib/repos/release/submit-service.ts +496 -0
  126. package/src/lib/repos/release/tags.ts +243 -0
  127. package/src/lib/repos/release/validate.ts +463 -0
  128. package/src/lib/repos/resolver.ts +789 -0
  129. package/src/lib/repos/scaffold/index.ts +238 -0
  130. package/src/lib/repos/scaffold/templates.ts +413 -0
  131. package/src/lib/repos/seed.ts +414 -0
  132. package/src/lib/repos/store/contract.ts +64 -0
  133. package/src/lib/repos/store/hash.ts +114 -0
  134. package/src/lib/repos/store/recipe-store.ts +599 -0
  135. package/src/lib/repos/store/settings.ts +58 -0
  136. package/src/lib/repos/subscription-service.ts +2678 -0
  137. package/src/lib/repos/trust.ts +447 -0
  138. package/src/lib/settings.ts +546 -189
  139. package/src/lib/sous-home.ts +104 -0
  140. package/src/lib/state.ts +52 -20
  141. package/src/lib/vars/ask.ts +1152 -0
  142. package/src/lib/vars/definition-source.ts +252 -0
  143. package/src/lib/vars/display.ts +233 -0
  144. package/src/lib/vars/index.ts +18 -0
  145. package/src/lib/vars/ladder.ts +282 -0
  146. package/src/lib/vars/mappings.ts +265 -0
  147. package/src/lib/vars/names.ts +94 -0
  148. package/src/lib/vars/preanswers.ts +395 -0
  149. package/src/lib/vars/question-plan.ts +218 -0
  150. package/src/lib/vars/report.ts +228 -0
  151. package/src/lib/vars/safe-regex.ts +235 -0
  152. package/src/lib/vars/validate.ts +312 -0
  153. package/src/lib/watch-loop.ts +148 -0
  154. package/src/templating/init-liquid-engine.ts +58 -16
  155. package/src/utils/choice-prompt.ts +143 -0
  156. package/src/utils/command-errors.ts +186 -0
  157. package/src/utils/command-help.ts +45 -0
  158. package/src/utils/confirm-prompt.ts +110 -0
  159. package/src/utils/flags.ts +153 -0
  160. package/src/utils/formatting.ts +540 -55
  161. package/src/utils/prompts.ts +35 -1
  162. package/src/utils/sous-directory.ts +245 -0
  163. package/src/utils/table.ts +603 -0
  164. package/src/utils/value-prompt.ts +119 -0
  165. package/shared-prompts/_partials/resume-task.md +0 -51
  166. package/shared-prompts/_partials/sub-agent-delegation.md +0 -32
  167. package/shared-prompts/_partials/update-task-file.md +0 -52
  168. package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +0 -52
  169. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +0 -102
  170. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +0 -81
  171. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +0 -126
  172. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +0 -92
  173. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +0 -61
  174. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +0 -65
  175. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +0 -96
  176. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +0 -104
  177. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +0 -243
  178. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +0 -148
  179. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +0 -383
  180. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +0 -267
  181. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +0 -56
  182. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +0 -169
  183. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +0 -59
  184. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +0 -25
  185. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +0 -140
  186. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +0 -140
  187. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +0 -1
  188. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +0 -185
  189. package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +0 -52
  190. package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +0 -59
  191. package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +0 -47
  192. package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +0 -26
  193. package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +0 -58
  194. package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +0 -27
  195. package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +0 -34
  196. package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +0 -51
  197. package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +0 -122
  198. package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +0 -80
  199. package/shared-prompts/skills/task-files/go/SKILL.tpl.md +0 -14
  200. package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +0 -13
  201. package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +0 -93
  202. package/shared-prompts/skills/task-files/update/SKILL.tpl.md +0 -14
  203. package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +0 -13
  204. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/substitutions.md +0 -0
  205. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/references/liquid-filters.md +0 -0
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Where a locked recipe's files actually are.
3
+ *
4
+ * The lockfile pins a recipe to a repository, a version and a content hash; it
5
+ * does not say where the bytes live. Three consumers need that answer and must
6
+ * all get the same one: the namespace resolver (which recipe directory a
7
+ * `~namespace` include lands in), the variable definition source (which
8
+ * manifests `sous vars` reads), and the recipe compile targets (which files a
9
+ * build copies or renders).
10
+ *
11
+ * There are two possible homes, and the order matters. A LINKED repository is
12
+ * read from its working copy, because a link is a deliberate instruction to
13
+ * bypass versions and the lockfile; everything else is read from the immutable
14
+ * store entry at the locked version. Nothing here fetches anything: a recipe the
15
+ * store does not hold yet is reported as absent so the caller can restore it.
16
+ *
17
+ * Reading is done once per call and cached inside the returned list, because a
18
+ * build asks for the same answer several times in a row.
19
+ */
20
+
21
+ import fs from "node:fs";
22
+ import path from "node:path";
23
+ import type { Settings } from "../settings.js";
24
+ import { resolveStoreRoot } from "../sous-home.js";
25
+ import { parseLockfile, type LockKind, type Lockfile } from "./formats/lockfile.js";
26
+ import { parseRecipeManifest, type RecipeManifest } from "./formats/recipe-manifest.js";
27
+ import { parseRepoManifest, type RepoManifest } from "./formats/repo-manifest.js";
28
+ import { LOCKFILE_FILENAME } from "./formats/common.js";
29
+ import {
30
+ findRecipeManifest,
31
+ findRepoManifest,
32
+ loadJsonFile,
33
+ loadManifestFile,
34
+ } from "./load-manifest.js";
35
+ import { readEffectiveLinks } from "./links.js";
36
+ import { identitySegments } from "./identity.js";
37
+ import { enabledSubscriptions } from "./defaults.js";
38
+ import { PROJECT_HOLDER } from "./formats/lockfile.js";
39
+
40
+ /** One locked recipe, together with the directory its files are read from. */
41
+ export type LockedRecipeLocation = {
42
+ /** The recipe key, `namespace/recipe`. */
43
+ key: string;
44
+ /** The short name of the repository it came from. */
45
+ repo: string;
46
+ /** The recipe's namespace. */
47
+ namespace: string;
48
+ /** The recipe's name. */
49
+ name: string;
50
+ /** The exact version the lockfile pins. */
51
+ version: string;
52
+ /** The content hash the lockfile pins. */
53
+ hash: string;
54
+ /** Whether anything holds it as a co-subscription, or only as a build dependency. */
55
+ kind: LockKind;
56
+ /** Everyone holding it: "project", and the key of every recipe that asked. */
57
+ requestedBy: string[];
58
+ /** Absolute directory holding the recipe's files. */
59
+ dir: string;
60
+ /** True when `dir` is a linked working copy rather than a store entry. */
61
+ linked: boolean;
62
+ /** True when `dir` exists on disk right now. */
63
+ present: boolean;
64
+ };
65
+
66
+ /** How locked recipes are located. */
67
+ export type LockedRecipeOptions = {
68
+ /** The project's `.sous/` directory, which holds the lockfile and the links map. */
69
+ sousDir: string;
70
+ /** The environment to read; decides where the store and the machine-wide links map are. */
71
+ env?: NodeJS.ProcessEnv;
72
+ /** The store root to use instead of the one the environment implies. */
73
+ storeRoot?: string;
74
+ };
75
+
76
+ /**
77
+ * Reads and validates a project's lockfile, returning an empty one when the
78
+ * project has locked nothing yet.
79
+ *
80
+ * @param sousDir - The project's `.sous/` directory.
81
+ */
82
+ export function readProjectLockfile(sousDir: string): Lockfile {
83
+ const filePath = path.join(sousDir, LOCKFILE_FILENAME);
84
+ if (!fs.existsSync(filePath)) return { formatVersion: 1, repos: {}, recipes: {} };
85
+ return parseLockfile(loadJsonFile(filePath, "lockfile"), filePath);
86
+ }
87
+
88
+ /**
89
+ * Reads and validates the recipe manifest in a directory, returning undefined
90
+ * when the directory has none or does not exist. A recipe that is not on disk
91
+ * yet is an ordinary state (a fresh clone before restore), never an error here.
92
+ *
93
+ * @param recipeDir - The directory holding the recipe's files.
94
+ */
95
+ export function readRecipeManifestIn(recipeDir: string): RecipeManifest | undefined {
96
+ let manifestPath: string | undefined;
97
+ try {
98
+ manifestPath = findRecipeManifest(recipeDir);
99
+ } catch {
100
+ return undefined;
101
+ }
102
+ if (manifestPath === undefined) return undefined;
103
+ return parseRecipeManifest(loadManifestFile(manifestPath), manifestPath);
104
+ }
105
+
106
+ /**
107
+ * Reads and validates the repo manifest at the root of a checkout, returning
108
+ * undefined when there is none.
109
+ *
110
+ * @param repoRoot - The checkout's root directory.
111
+ */
112
+ export function readRepoManifestIn(repoRoot: string): RepoManifest | undefined {
113
+ let manifestPath: string | undefined;
114
+ try {
115
+ manifestPath = findRepoManifest(repoRoot);
116
+ } catch {
117
+ return undefined;
118
+ }
119
+ if (manifestPath === undefined) return undefined;
120
+ return parseRepoManifest(loadManifestFile(manifestPath), manifestPath);
121
+ }
122
+
123
+ /**
124
+ * Maps every recipe a linked checkout publishes to its directory, by reading the
125
+ * checkout's repo manifest and then each recipe folder's own manifest. A folder
126
+ * the repo manifest lists but that holds no readable recipe manifest is skipped
127
+ * rather than failing the build: a working copy is edited by hand and is allowed
128
+ * to be mid-change.
129
+ *
130
+ * @param checkoutDir - The linked working copy's root directory.
131
+ */
132
+ export function mapLinkedRecipes(checkoutDir: string): Record<string, string> {
133
+ const manifest = readRepoManifestIn(checkoutDir);
134
+ if (manifest === undefined) return {};
135
+
136
+ const found: Record<string, string> = {};
137
+ for (const relative of manifest.recipes) {
138
+ const recipeDir = path.join(checkoutDir, relative);
139
+ const recipe = readRecipeManifestIn(recipeDir);
140
+ if (recipe === undefined) continue;
141
+ found[`${recipe.namespace}/${recipe.name}`] = recipeDir;
142
+ }
143
+ return found;
144
+ }
145
+
146
+ /**
147
+ * Every recipe the lockfile pins, with the directory each one's files are read
148
+ * from. Linked repositories win over the store, and a recipe whose directory is
149
+ * not there yet comes back with `present: false`.
150
+ *
151
+ * @param options - The project's `.sous/` directory and the environment.
152
+ */
153
+ export function listLockedRecipes(
154
+ options: LockedRecipeOptions
155
+ ): LockedRecipeLocation[] {
156
+ const env = options.env ?? process.env;
157
+ const lock = readProjectLockfile(options.sousDir);
158
+ const keys = Object.keys(lock.recipes);
159
+ if (keys.length === 0) return [];
160
+
161
+ const storeRoot = options.storeRoot ?? resolveStoreRoot(env);
162
+ const links = readEffectiveLinks(options.sousDir, env);
163
+ const linkedRecipes = new Map<string, Record<string, string>>();
164
+
165
+ const located: LockedRecipeLocation[] = [];
166
+
167
+ for (const key of keys.sort()) {
168
+ const entry = lock.recipes[key]!;
169
+ const namespace = key.slice(0, key.indexOf("/"));
170
+ const name = key.slice(namespace.length + 1);
171
+
172
+ let dir: string | undefined;
173
+ let linked = false;
174
+
175
+ const checkout = links[entry.repo]?.path;
176
+ if (checkout !== undefined) {
177
+ if (!linkedRecipes.has(entry.repo)) {
178
+ linkedRecipes.set(entry.repo, mapLinkedRecipes(checkout));
179
+ }
180
+ dir = linkedRecipes.get(entry.repo)![key];
181
+ linked = dir !== undefined;
182
+ }
183
+
184
+ if (dir === undefined) {
185
+ // The store is machine-wide, so it files an entry under the repository's
186
+ // canonical identity rather than under this project's short name for it.
187
+ const identity = lock.repos[entry.repo]?.identity;
188
+ dir =
189
+ identity === undefined
190
+ ? undefined
191
+ : path.join(storeRoot, ...identitySegments(identity), namespace, name, entry.version);
192
+ }
193
+
194
+ located.push({
195
+ key,
196
+ repo: entry.repo,
197
+ namespace,
198
+ name,
199
+ version: entry.version,
200
+ hash: entry.hash,
201
+ kind: entry.kind,
202
+ requestedBy: [...entry.requestedBy],
203
+ dir: dir ?? "",
204
+ linked,
205
+ present: dir !== undefined && fs.existsSync(dir),
206
+ });
207
+ }
208
+
209
+ return located;
210
+ }
211
+
212
+ /**
213
+ * The lockfile keys one subscription holds DIRECTLY. A recipe ref holds its own
214
+ * key; a namespace ref holds every recipe in that namespace.
215
+ *
216
+ * Only entries the project itself holds count. A recipe that arrived purely as
217
+ * another recipe's `depends` sits under the same namespace but was never the
218
+ * project's to hold, and counting it made `sous unsubscribe <namespace>` report
219
+ * it as having "stayed" when the project had never held it in the first place.
220
+ *
221
+ * @param lock - The lockfile as it stands.
222
+ * @param key - The subscription's ref key: a namespace, or `namespace/recipe`.
223
+ */
224
+ export function keysHeldBySubscription(lock: Lockfile, key: string): string[] {
225
+ const heldByProject = (entry: string): boolean =>
226
+ lock.recipes[entry]?.requestedBy.includes(PROJECT_HOLDER) === true;
227
+
228
+ if (key.includes("/")) {
229
+ return heldByProject(key) ? [key] : [];
230
+ }
231
+ return Object.keys(lock.recipes)
232
+ .filter((entry) => entry.startsWith(`${key}/`) && heldByProject(entry))
233
+ .sort();
234
+ }
235
+
236
+ /**
237
+ * The refs a project's own templates may address: everything it subscribed to
238
+ * directly. Both sources are read, because a subscription may be written by
239
+ * `sous subscribe` into the managed layer, hand-written in the primary config,
240
+ * or (for an older project) recorded only in the lockfile.
241
+ *
242
+ * @param settings - The merged project config.
243
+ * @param locked - The locked recipes, as returned by listLockedRecipes.
244
+ */
245
+ export function projectSubscriptionRefs(
246
+ settings: Settings,
247
+ locked: LockedRecipeLocation[]
248
+ ): string[] {
249
+ const refs = new Set<string>(Object.keys(enabledSubscriptions(settings)));
250
+ for (const recipe of locked) {
251
+ if (recipe.requestedBy.includes(PROJECT_HOLDER)) refs.add(recipe.key);
252
+ }
253
+ return [...refs].sort();
254
+ }
@@ -0,0 +1,422 @@
1
+ /**
2
+ * The managed config layers.
3
+ *
4
+ * Three files in a project's `conf.d/` directory are written by sous rather
5
+ * than by a person: `500-repos.jsonc`, which holds the repositories the project
6
+ * trusts, `510-subscriptions.jsonc`, which holds what it subscribes to, and
7
+ * `520-var-mappings.jsonc` (written from `vars/mappings.ts`), which holds
8
+ * variable mapping records. All three sit in the 5xx band reserved for
9
+ * machine-written layers, so a user's own primary config and non-5xx layers are
10
+ * never touched.
11
+ *
12
+ * They are `.jsonc`, not `.json`, so they can carry real comments: each one
13
+ * opens with a header saying what it holds and who writes it. Sous edits them
14
+ * BY KEY, through `jsonc-parser`, which rewrites only the bytes of the entry it
15
+ * is changing. A comment somebody adds beside an entry, the order they put the
16
+ * keys in, and the way they formatted the file all survive a sous edit.
17
+ *
18
+ * A layer that still exists under its old `.json` name is read as a fallback
19
+ * and migrates on the next write: the `.jsonc` file is written and the `.json`
20
+ * one is removed, so a project never ends up with both (two layers with the
21
+ * same baseName are a hard config error).
22
+ */
23
+
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+ import {
27
+ applyEdits,
28
+ findNodeAtLocation,
29
+ modify,
30
+ parse as parseJsonc,
31
+ parseTree,
32
+ type ParseError,
33
+ } from "jsonc-parser";
34
+ import { CONFD_DIR_NAME } from "../config-discovery.js";
35
+ import { ConfigError } from "../errors.js";
36
+ import { stableJsonStringify } from "./formats/common.js";
37
+ import { ensureConfdDirectory } from "../../utils/sous-directory.js";
38
+
39
+ /** The machine-written layer holding the repositories a project trusts. */
40
+ export const REPOS_LAYER_FILENAME = "500-repos.jsonc";
41
+
42
+ /** The machine-written layer holding a project's subscriptions. */
43
+ export const SUBSCRIPTIONS_LAYER_FILENAME = "510-subscriptions.jsonc";
44
+
45
+ /**
46
+ * The policy every managed layer states in its own header: sous edits it by
47
+ * key, and a person may edit it too.
48
+ */
49
+ export const MANAGED_LAYER_COMMENT =
50
+ "This file is managed by sous. Sous edits these files by key; you may edit them " +
51
+ "too, and your comments, key order and formatting are kept.";
52
+
53
+ /** The closing lines of every managed layer header, describing the format. */
54
+ const MANAGED_LAYER_FORMAT_NOTE = [
55
+ "It is JSON with comments (.jsonc): line comments, block comments and trailing",
56
+ "commas are all allowed here.",
57
+ ];
58
+
59
+ /** What each managed layer holds, and which commands write it. */
60
+ const MANAGED_LAYER_DESCRIPTIONS: Record<string, string[]> = {
61
+ [REPOS_LAYER_FILENAME]: [
62
+ "It records the repositories this project trusts. The 'sous repo add' and",
63
+ "'sous repo remove' commands write the entries under 'repos'.",
64
+ ],
65
+ [SUBSCRIPTIONS_LAYER_FILENAME]: [
66
+ "It records what this project subscribes to. The 'sous subscription add' and",
67
+ "'sous subscription remove' commands write the entries under 'subscriptions'.",
68
+ ],
69
+ };
70
+
71
+ /** Wraps a sentence into `//` comment lines of at most `width` characters. */
72
+ function commentLines(text: string, width = 78): string[] {
73
+ const lines: string[] = [];
74
+ let current = "";
75
+ for (const word of text.split(/\s+/)) {
76
+ if (current.length === 0) {
77
+ current = word;
78
+ } else if (`${current} ${word}`.length + 3 <= width) {
79
+ current = `${current} ${word}`;
80
+ } else {
81
+ lines.push(current);
82
+ current = word;
83
+ }
84
+ }
85
+ if (current.length > 0) lines.push(current);
86
+ return lines;
87
+ }
88
+
89
+ /**
90
+ * The header comment block a managed layer opens with. It states the policy,
91
+ * says what the file holds, and names the format.
92
+ *
93
+ * @param fileName - The layer's file name, which selects the description.
94
+ * @param description - Lines describing the file, for a layer this module does
95
+ * not know about (the variable mapping layer passes its own).
96
+ */
97
+ export function managedLayerHeader(fileName: string, description?: string[]): string {
98
+ const what = description ?? MANAGED_LAYER_DESCRIPTIONS[fileName] ?? [];
99
+ const blocks = [commentLines(MANAGED_LAYER_COMMENT), what, MANAGED_LAYER_FORMAT_NOTE].filter(
100
+ (block) => block.length > 0
101
+ );
102
+
103
+ return (
104
+ blocks
105
+ .map((block) => block.map((line) => `// ${line}`.trimEnd()).join("\n"))
106
+ .join("\n//\n") + "\n"
107
+ );
108
+ }
109
+
110
+ /** Where a managed layer lives, and how it is written. */
111
+ export type ManagedLayerOptions = {
112
+ /**
113
+ * The `conf.d/` directory to use instead of `<sousDir>/conf.d`. Set it from
114
+ * the discovered config context, so `--sous-confd` and `SOUS_CONFD` are
115
+ * respected.
116
+ */
117
+ confDir?: string;
118
+ /**
119
+ * The header comment block a newly created layer opens with, for a layer this
120
+ * module has no description for. Defaults to the header for `fileName`.
121
+ */
122
+ header?: string;
123
+ };
124
+
125
+ /**
126
+ * The directory a managed layer is written into.
127
+ *
128
+ * @param sousDir - The project's `.sous/` directory.
129
+ * @param options - An explicit `conf.d/` directory, when there is one.
130
+ */
131
+ export function managedLayerDir(sousDir: string, options: ManagedLayerOptions = {}): string {
132
+ return options.confDir ?? path.join(sousDir, CONFD_DIR_NAME);
133
+ }
134
+
135
+ /**
136
+ * The full path of a managed layer.
137
+ *
138
+ * @param sousDir - The project's `.sous/` directory.
139
+ * @param fileName - The layer's file name, such as `500-repos.jsonc`.
140
+ * @param options - An explicit `conf.d/` directory, when there is one.
141
+ */
142
+ export function managedLayerPath(
143
+ sousDir: string,
144
+ fileName: string,
145
+ options: ManagedLayerOptions = {}
146
+ ): string {
147
+ return path.join(managedLayerDir(sousDir, options), fileName);
148
+ }
149
+
150
+ /**
151
+ * The path the same layer had before managed layers became `.jsonc`, or
152
+ * undefined when the name is not a `.jsonc` one. Read as a fallback, and
153
+ * removed by the first write that migrates the layer.
154
+ *
155
+ * @param sousDir - The project's `.sous/` directory.
156
+ * @param fileName - The layer's file name.
157
+ * @param options - An explicit `conf.d/` directory, when there is one.
158
+ */
159
+ export function legacyManagedLayerPath(
160
+ sousDir: string,
161
+ fileName: string,
162
+ options: ManagedLayerOptions = {}
163
+ ): string | undefined {
164
+ if (!fileName.endsWith(".jsonc")) return undefined;
165
+ return path.join(managedLayerDir(sousDir, options), `${fileName.slice(0, -1)}`);
166
+ }
167
+
168
+ /** True when the path exists and is a regular file. */
169
+ function isFile(candidate: string): boolean {
170
+ try {
171
+ return fs.statSync(candidate).isFile();
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * The managed layer file that actually exists: the `.jsonc` one, or the old
179
+ * `.json` one when only that is there. Undefined when the layer has never been
180
+ * written.
181
+ */
182
+ function existingManagedLayerPath(
183
+ sousDir: string,
184
+ fileName: string,
185
+ options: ManagedLayerOptions
186
+ ): string | undefined {
187
+ const current = managedLayerPath(sousDir, fileName, options);
188
+ if (isFile(current)) return current;
189
+
190
+ const legacy = legacyManagedLayerPath(sousDir, fileName, options);
191
+ if (legacy !== undefined && isFile(legacy)) return legacy;
192
+
193
+ return undefined;
194
+ }
195
+
196
+ /** Parses a managed layer's text, allowing comments and trailing commas. */
197
+ function parseManagedLayerText(text: string, filePath: string): unknown {
198
+ const errors: ParseError[] = [];
199
+ const value = parseJsonc(text, errors, {
200
+ allowTrailingComma: true,
201
+ disallowComments: false,
202
+ });
203
+
204
+ if (errors.length > 0) {
205
+ throw new ConfigError(
206
+ `Sous could not read its own config layer at ${filePath}.\n` +
207
+ ` The file is not valid JSON with comments.\n` +
208
+ ` Sous writes this file itself. Restoring it from version control, or deleting ` +
209
+ `it and adding the entries again, both fix this.`
210
+ );
211
+ }
212
+
213
+ return value;
214
+ }
215
+
216
+ /**
217
+ * Reads a managed layer, returning an empty object when the file does not exist
218
+ * yet. A file that does not parse is an error naming it, because sous wrote it
219
+ * and is about to edit it: silently discarding somebody's edits would be worse
220
+ * than stopping.
221
+ *
222
+ * @param sousDir - The project's `.sous/` directory.
223
+ * @param fileName - The layer's file name.
224
+ * @param options - An explicit `conf.d/` directory, when there is one.
225
+ */
226
+ export function readManagedLayer(
227
+ sousDir: string,
228
+ fileName: string,
229
+ options: ManagedLayerOptions = {}
230
+ ): Record<string, unknown> {
231
+ const filePath = existingManagedLayerPath(sousDir, fileName, options);
232
+ if (filePath === undefined) return {};
233
+
234
+ let text: string;
235
+ try {
236
+ text = fs.readFileSync(filePath, "utf8");
237
+ } catch (error) {
238
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
239
+ throw new ConfigError(
240
+ `Sous could not read its own config layer at ${filePath}.\n` +
241
+ ` ${(error as Error).message}`
242
+ );
243
+ }
244
+
245
+ const parsed = parseManagedLayerText(text, filePath);
246
+
247
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
248
+ throw new ConfigError(
249
+ `The config layer at ${filePath} is not a JSON object.\n` +
250
+ ` Sous writes this file itself; it always holds one object.`
251
+ );
252
+ }
253
+
254
+ return parsed as Record<string, unknown>;
255
+ }
256
+
257
+ /**
258
+ * Writes a layer's text to its `.jsonc` path and removes the old `.json` name
259
+ * when there was one, so a migrated layer never exists twice. The file is
260
+ * staged under a temporary name in the same directory and renamed into place,
261
+ * so a reader never sees a half-written layer.
262
+ */
263
+ function writeLayerText(
264
+ sousDir: string,
265
+ fileName: string,
266
+ body: string,
267
+ options: ManagedLayerOptions
268
+ ): string {
269
+ const directory = managedLayerDir(sousDir, options);
270
+ const filePath = path.join(directory, fileName);
271
+
272
+ // Created with its README, so somebody who finds a machine-written layer can
273
+ // read what the directory is for without leaving it.
274
+ ensureConfdDirectory(directory);
275
+
276
+ const temporary = path.join(directory, `.${fileName}.tmp-${process.pid}`);
277
+ try {
278
+ fs.writeFileSync(temporary, body, "utf8");
279
+ fs.renameSync(temporary, filePath);
280
+ } catch (error) {
281
+ fs.rmSync(temporary, { force: true });
282
+ throw new ConfigError(
283
+ `Sous could not write its config layer at ${filePath}.\n ${(error as Error).message}`
284
+ );
285
+ }
286
+
287
+ const legacy = legacyManagedLayerPath(sousDir, fileName, options);
288
+ if (legacy !== undefined && legacy !== filePath) fs.rmSync(legacy, { force: true });
289
+
290
+ return filePath;
291
+ }
292
+
293
+ /**
294
+ * Writes a managed layer, replacing whatever was there: the header comment,
295
+ * then the content as sorted JSON. Use `updateManagedLayer` for an ordinary
296
+ * change; this is for creating a layer from nothing, or for replacing one whose
297
+ * whole content sous is generating.
298
+ *
299
+ * The header comment is added for you; there is no need to pass one.
300
+ *
301
+ * @param sousDir - The project's `.sous/` directory.
302
+ * @param fileName - The layer's file name.
303
+ * @param content - The layer's whole content.
304
+ * @param options - An explicit `conf.d/` directory, when there is one.
305
+ */
306
+ export function writeManagedLayer(
307
+ sousDir: string,
308
+ fileName: string,
309
+ content: Record<string, unknown>,
310
+ options: ManagedLayerOptions = {}
311
+ ): string {
312
+ const header = options.header ?? managedLayerHeader(fileName);
313
+ return writeLayerText(sousDir, fileName, header + stableJsonStringify(content), options);
314
+ }
315
+
316
+ /**
317
+ * True when the layer's text actually holds something at a key path. Used to
318
+ * tell a removal that has work to do from one that does not.
319
+ *
320
+ * @param text - The layer's text.
321
+ * @param keyPath - The key path to look for.
322
+ */
323
+ function hasNodeAt(text: string, keyPath: (string | number)[]): boolean {
324
+ const root = parseTree(text);
325
+ if (root === undefined) return false;
326
+ return findNodeAtLocation(root, keyPath) !== undefined;
327
+ }
328
+
329
+ /** One key-path edit to a managed layer. `undefined` removes the key. */
330
+ export type ManagedLayerEdit = {
331
+ /** The key path to change, such as `["repos", "team-recipes"]`. */
332
+ path: (string | number)[];
333
+ /** The value to write there, or undefined to remove the key. */
334
+ value: unknown | undefined;
335
+ };
336
+
337
+ /**
338
+ * Applies key-path edits to a managed layer, rewriting only the bytes of the
339
+ * entries that change. Comments, key order and formatting elsewhere in the file
340
+ * are left exactly as they were, which is what lets a person keep notes beside
341
+ * the entries sous manages.
342
+ *
343
+ * A layer that does not exist yet is created with its header comment and an
344
+ * empty object, and the edits are applied to that. A layer still under its old
345
+ * `.json` name is edited and written back as `.jsonc`, and the `.json` file is
346
+ * removed.
347
+ *
348
+ * New keys are inserted in sorted position, so a layer sous has written from
349
+ * the start stays in a stable order and its diffs stay small.
350
+ *
351
+ * @param sousDir - The project's `.sous/` directory.
352
+ * @param fileName - The layer's file name.
353
+ * @param edits - The key paths to set, or to remove by passing undefined.
354
+ * @param options - An explicit `conf.d/` directory, and a header for a layer
355
+ * this module has no description for.
356
+ * @returns The path of the layer file that was written.
357
+ */
358
+ export function updateManagedLayer(
359
+ sousDir: string,
360
+ fileName: string,
361
+ edits: ManagedLayerEdit[],
362
+ options: ManagedLayerOptions = {}
363
+ ): string {
364
+ const existing = existingManagedLayerPath(sousDir, fileName, options);
365
+
366
+ let text: string;
367
+ if (existing === undefined) {
368
+ text = (options.header ?? managedLayerHeader(fileName)) + "{}\n";
369
+ } else {
370
+ try {
371
+ text = fs.readFileSync(existing, "utf8");
372
+ } catch (error) {
373
+ throw new ConfigError(
374
+ `Sous could not read its own config layer at ${existing}.\n` +
375
+ ` ${(error as Error).message}`
376
+ );
377
+ }
378
+ // Refuse to edit a file that does not parse, rather than writing over it.
379
+ parseManagedLayerText(text, existing);
380
+ }
381
+
382
+ for (const edit of edits) {
383
+ // Removing a key that is not there is already done. Asking `modify` to do it
384
+ // anyway throws, because there is no parent object to remove it from, and a
385
+ // layer somebody has hand-edited is exactly where that happens.
386
+ if (edit.value === undefined && !hasNodeAt(text, edit.path)) continue;
387
+
388
+ const last = edit.path[edit.path.length - 1];
389
+ const changes = modify(text, edit.path, edit.value, {
390
+ formattingOptions: { tabSize: 2, insertSpaces: true, eol: "\n" },
391
+ getInsertionIndex:
392
+ typeof last === "string"
393
+ ? (properties) => properties.filter((name) => name < last).length
394
+ : undefined,
395
+ });
396
+ text = applyEdits(text, changes);
397
+ }
398
+
399
+ if (!text.endsWith("\n")) text += "\n";
400
+
401
+ return writeLayerText(sousDir, fileName, text, options);
402
+ }
403
+
404
+ /**
405
+ * Removes a managed layer entirely, which is what emptying one comes down to: a
406
+ * layer holding nothing but its own header comment is noise in a project. The
407
+ * old `.json` name is removed too, so a migration in progress leaves nothing
408
+ * behind.
409
+ *
410
+ * @param sousDir - The project's `.sous/` directory.
411
+ * @param fileName - The layer's file name.
412
+ * @param options - An explicit `conf.d/` directory, when there is one.
413
+ */
414
+ export function removeManagedLayer(
415
+ sousDir: string,
416
+ fileName: string,
417
+ options: ManagedLayerOptions = {}
418
+ ): void {
419
+ fs.rmSync(managedLayerPath(sousDir, fileName, options), { force: true });
420
+ const legacy = legacyManagedLayerPath(sousDir, fileName, options);
421
+ if (legacy !== undefined) fs.rmSync(legacy, { force: true });
422
+ }