@sous-io/sous 0.1.0 → 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 (206) hide show
  1. package/README.md +121 -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 +73 -9
  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/bin/xcv +0 -5
  166. package/shared-prompts/_partials/resume-task.md +0 -51
  167. package/shared-prompts/_partials/sub-agent-delegation.md +0 -32
  168. package/shared-prompts/_partials/update-task-file.md +0 -52
  169. package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +0 -52
  170. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +0 -102
  171. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +0 -81
  172. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +0 -126
  173. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +0 -92
  174. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +0 -61
  175. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +0 -65
  176. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +0 -96
  177. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +0 -104
  178. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +0 -243
  179. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +0 -148
  180. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +0 -383
  181. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +0 -267
  182. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +0 -56
  183. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +0 -169
  184. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +0 -59
  185. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +0 -25
  186. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +0 -140
  187. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +0 -140
  188. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +0 -1
  189. package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +0 -185
  190. package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +0 -52
  191. package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +0 -59
  192. package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +0 -47
  193. package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +0 -26
  194. package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +0 -58
  195. package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +0 -27
  196. package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +0 -34
  197. package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +0 -51
  198. package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +0 -122
  199. package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +0 -80
  200. package/shared-prompts/skills/task-files/go/SKILL.tpl.md +0 -14
  201. package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +0 -13
  202. package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +0 -93
  203. package/shared-prompts/skills/task-files/update/SKILL.tpl.md +0 -14
  204. package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +0 -13
  205. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/substitutions.md +0 -0
  206. /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/references/liquid-filters.md +0 -0
@@ -0,0 +1,370 @@
1
+ import path from "node:path";
2
+
3
+ /**
4
+ * Namespace addressability for templates: the reserved `~` include sigil.
5
+ *
6
+ * An include line of the form `@~<namespace>/<rest>` (and the equivalent
7
+ * `{% render "~<namespace>/<rest>" %}`) addresses a recipe namespace rather
8
+ * than the filesystem. `<rest>` begins with the recipe name and continues with
9
+ * the path inside that recipe, so
10
+ *
11
+ * @~workflow/task-files/_partials/resume.md
12
+ *
13
+ * means "the file `_partials/resume.md` inside recipe `workflow/task-files`".
14
+ * A namespace holds many recipes and each recipe is a directory at its pinned
15
+ * version, so resolution is a lookup from (namespace, recipe) to a directory.
16
+ *
17
+ * A bare `@path` (no `~`) never consults a namespace; it stays a relative path
18
+ * or a declared alias.
19
+ *
20
+ * This module defines only the CONTRACT plus a static, in-memory implementation
21
+ * used by tests. The real implementation (backed by the repository store, the
22
+ * lockfile and linked checkouts) is supplied by the repositories layer and is
23
+ * injected into the compiler, so nothing here reads configuration or disk.
24
+ *
25
+ * Scoping is the resolver's responsibility, which is why every request carries
26
+ * the including file:
27
+ * - a file that lives inside a recipe may address only that recipe's declared
28
+ * dependencies (`depends` plus `subscribes`) at their pinned versions;
29
+ * - a file in the project's own templates may address the project's
30
+ * subscriptions.
31
+ */
32
+
33
+ /** A single `~namespace/rest` resolution request. */
34
+ export type NamespaceRequest = {
35
+ /** The namespace name, with the leading `~` already stripped (e.g. `workflow`). */
36
+ namespace: string;
37
+ /**
38
+ * Everything after the namespace segment: the recipe name, then the path
39
+ * inside that recipe (e.g. `task-files/_partials/resume.md`). Never has a
40
+ * leading separator.
41
+ */
42
+ rest: string;
43
+ /**
44
+ * Absolute path of the file performing the include, used to decide which
45
+ * recipe (if any) is asking. A directory path is accepted for callers that
46
+ * only know the including directory, such as the `{% render %}` filesystem.
47
+ */
48
+ fromFile: string;
49
+ };
50
+
51
+ /**
52
+ * The outcome of a namespace lookup. `candidates` is the success case; the
53
+ * other members carry enough detail to build a precise error message.
54
+ *
55
+ * Implementations may only return these members. Callers should treat any
56
+ * unrecognized `kind` as "no candidates, no specific advice" so the union can
57
+ * grow without breaking older callers.
58
+ */
59
+ export type NamespaceResolution =
60
+ /** Ordered absolute paths to try, most preferred first. An empty list means the lookup produced nothing. */
61
+ | { kind: "candidates"; candidates: string[] }
62
+ /** No such namespace is known at all. `known` lists the namespaces that are. */
63
+ | { kind: "unknown-namespace"; known: string[] }
64
+ /**
65
+ * The namespace exists but holds no such recipe. `recipe` is the fully
66
+ * qualified ref that was asked for; `known` lists the recipe refs the
67
+ * namespace does hold.
68
+ */
69
+ | { kind: "unknown-recipe"; recipe: string; known: string[] }
70
+ /**
71
+ * The recipe exists but the including file is not allowed to address it.
72
+ * `recipe` is the fully qualified ref that was asked for. `includingRecipe`
73
+ * is the ref of the recipe the including file belongs to, or `null` when the
74
+ * including file is one of the project's own templates (in which case the
75
+ * project simply does not subscribe to the recipe).
76
+ */
77
+ | { kind: "not-a-dependency"; recipe: string; includingRecipe: string | null }
78
+ /**
79
+ * The reference tried to leave the recipe directory: it carried a `.` or `..`
80
+ * segment, or an absolute inner path. A `~namespace` reference addresses a
81
+ * recipe's own files and nothing else, so this is refused rather than
82
+ * resolved. `reference` is the reference as it was written.
83
+ */
84
+ | { kind: "escapes-recipe"; recipe: string; reference: string };
85
+
86
+ /** Resolves `~namespace/rest` references to candidate absolute paths. */
87
+ export interface NamespaceResolver {
88
+ /**
89
+ * Resolve one `~namespace/rest` reference.
90
+ *
91
+ * @param request - The namespace, the remainder of the reference, and the including file.
92
+ * @returns Candidate absolute paths (most preferred first), or a reason the lookup failed.
93
+ */
94
+ resolve(request: NamespaceRequest): NamespaceResolution;
95
+ }
96
+
97
+ /**
98
+ * Render a namespace lookup failure as human-readable lines for an error
99
+ * message. Each line is indented by two spaces so it can be appended directly
100
+ * to the compiler's "Include not found" block.
101
+ *
102
+ * @param opts.namespace - The namespace that was asked for.
103
+ * @param opts.rest - The remainder of the reference (recipe name plus inner path).
104
+ * @param opts.fromFile - The file (or directory) that performed the include.
105
+ * @param opts.resolution - What the resolver returned.
106
+ * @returns Indented, newline-joined explanation lines; an empty string when there is nothing to add.
107
+ */
108
+ export function formatNamespaceProblem(opts: {
109
+ namespace: string;
110
+ rest: string;
111
+ fromFile: string;
112
+ resolution: NamespaceResolution;
113
+ }): string {
114
+ const lines: string[] = [`in file: ${opts.fromFile}`, `namespace: ${opts.namespace}`];
115
+
116
+ const resolution = opts.resolution;
117
+
118
+ if (resolution.kind === "candidates") {
119
+ return "";
120
+ }
121
+
122
+ if (resolution.kind === "unknown-namespace") {
123
+ lines.push(`There is no recipe namespace named "${opts.namespace}" available here.`);
124
+ lines.push(
125
+ resolution.known.length > 0
126
+ ? `Available namespaces: ${resolution.known.join(", ")}.`
127
+ : "This project has no recipe namespaces available yet."
128
+ );
129
+ } else if (resolution.kind === "unknown-recipe") {
130
+ lines.push(`recipe: ${resolution.recipe}`);
131
+ lines.push(`The namespace "${opts.namespace}" holds no recipe named "${resolution.recipe}".`);
132
+ lines.push(
133
+ resolution.known.length > 0
134
+ ? `Recipes in this namespace: ${resolution.known.join(", ")}.`
135
+ : `The namespace "${opts.namespace}" currently holds no recipes.`
136
+ );
137
+ } else if (resolution.kind === "not-a-dependency") {
138
+ lines.push(`recipe: ${resolution.recipe}`);
139
+ if (resolution.includingRecipe) {
140
+ lines.push(
141
+ `The recipe "${resolution.includingRecipe}" does not declare "${resolution.recipe}" as a dependency.`
142
+ );
143
+ lines.push(
144
+ `Add "${resolution.recipe}" to the "depends" list in that recipe's manifest before addressing it as "~${opts.namespace}".`
145
+ );
146
+ } else {
147
+ lines.push(`This project does not subscribe to "${resolution.recipe}".`);
148
+ lines.push(
149
+ `Subscribe to it before addressing it as "~${opts.namespace}" from a project template.`
150
+ );
151
+ }
152
+ } else if (resolution.kind === "escapes-recipe") {
153
+ lines.push(`recipe: ${resolution.recipe}`);
154
+ lines.push(
155
+ `The reference "~${resolution.reference}" points outside the recipe "${resolution.recipe}".`
156
+ );
157
+ lines.push(
158
+ `A "~namespace" reference addresses a recipe's own files, so it may not contain ` +
159
+ `"." or ".." segments and may not be an absolute path.`
160
+ );
161
+ lines.push(
162
+ `Write the path of a file inside the recipe, or include the other file by a ` +
163
+ `relative path or a declared alias.`
164
+ );
165
+ }
166
+
167
+ return lines.map((line) => ` ${line}`).join("\n");
168
+ }
169
+
170
+ /** Options for {@link StaticNamespaceResolver}. */
171
+ export type StaticNamespaceResolverOptions = {
172
+ /**
173
+ * Every known recipe, mapping the fully qualified ref `<namespace>/<recipe>`
174
+ * to the absolute directory holding that recipe's files at its pinned
175
+ * version. These directories double as the recipe roots used to decide which
176
+ * recipe an including file belongs to.
177
+ */
178
+ recipes: Record<string, string>;
179
+ /**
180
+ * What each recipe declares, mapping `<namespace>/<recipe>` to the refs it
181
+ * may address. An entry may be a full ref (`workflow/task-files`) or a bare
182
+ * namespace (`workflow`, meaning every recipe in it). A recipe with no entry
183
+ * declares nothing and may address only itself.
184
+ */
185
+ dependencies?: Record<string, string[]>;
186
+ /**
187
+ * What the project's own templates may address, in the same ref forms as
188
+ * `dependencies`. Omit it to make every known recipe addressable from
189
+ * project templates (the convenient default for tests).
190
+ */
191
+ projectScope?: string[];
192
+ };
193
+
194
+ /**
195
+ * A dependency-free, in-memory {@link NamespaceResolver} built from a map of
196
+ * recipe refs to directories.
197
+ *
198
+ * It implements the full scoping rule (recipe files see their declared
199
+ * dependencies; project files see the project's subscriptions) without knowing
200
+ * anything about repositories, versions or the store, which makes it the
201
+ * resolver used by tests and a usable core for the real implementation to wrap.
202
+ */
203
+ export class StaticNamespaceResolver implements NamespaceResolver {
204
+ private readonly recipes: Record<string, string>;
205
+ private readonly dependencies: Record<string, string[]>;
206
+ private readonly projectScope?: string[];
207
+
208
+ constructor(options: StaticNamespaceResolverOptions) {
209
+ this.recipes = {};
210
+ for (const [ref, dir] of Object.entries(options.recipes)) {
211
+ this.recipes[ref] = path.resolve(dir);
212
+ }
213
+ this.dependencies = options.dependencies ?? {};
214
+ this.projectScope = options.projectScope;
215
+ }
216
+
217
+ /** Every namespace this resolver knows about, sorted. */
218
+ private knownNamespaces(): string[] {
219
+ const names = new Set<string>();
220
+ for (const ref of Object.keys(this.recipes)) {
221
+ names.add(ref.split("/")[0]);
222
+ }
223
+ return [...names].sort();
224
+ }
225
+
226
+ /** Every known recipe ref inside one namespace, sorted. */
227
+ private recipesIn(namespace: string): string[] {
228
+ return Object.keys(this.recipes)
229
+ .filter((ref) => ref.split("/")[0] === namespace)
230
+ .sort();
231
+ }
232
+
233
+ /**
234
+ * The ref of the recipe whose directory contains `fromFile`, or null when the
235
+ * file lives outside every recipe (a project template). The deepest matching
236
+ * recipe directory wins, so nested layouts resolve to the innermost recipe.
237
+ */
238
+ private includingRecipe(fromFile: string): string | null {
239
+ const target = path.resolve(fromFile);
240
+ let best: string | null = null;
241
+ let bestLength = -1;
242
+
243
+ for (const [ref, dir] of Object.entries(this.recipes)) {
244
+ if (!isInside(dir, target)) continue;
245
+ if (dir.length > bestLength) {
246
+ best = ref;
247
+ bestLength = dir.length;
248
+ }
249
+ }
250
+
251
+ return best;
252
+ }
253
+
254
+ resolve(request: NamespaceRequest): NamespaceResolution {
255
+ const { namespace, rest, fromFile } = request;
256
+
257
+ if (!this.knownNamespaces().includes(namespace)) {
258
+ return { kind: "unknown-namespace", known: this.knownNamespaces() };
259
+ }
260
+
261
+ const segments = rest.split("/").filter((segment) => segment.length > 0);
262
+ const recipeName = segments[0] ?? "";
263
+ const ref = `${namespace}/${recipeName}`;
264
+ const recipeDir = this.recipes[ref];
265
+
266
+ if (!recipeDir) {
267
+ return { kind: "unknown-recipe", recipe: ref, known: this.recipesIn(namespace) };
268
+ }
269
+
270
+ const includingRecipe = this.includingRecipe(fromFile);
271
+ const declared = includingRecipe
272
+ ? [includingRecipe, ...(this.dependencies[includingRecipe] ?? [])]
273
+ : this.projectScope;
274
+
275
+ if (declared !== undefined && !declaresRef(declared, namespace, ref)) {
276
+ return { kind: "not-a-dependency", recipe: ref, includingRecipe };
277
+ }
278
+
279
+ const inner = segments.slice(1).join("/");
280
+ const resolved = path.resolve(recipeDir, inner);
281
+
282
+ // A `~namespace` reference addresses a recipe's own files. Without this the
283
+ // reference could walk out of the recipe with `..` segments and have the
284
+ // compiler render anything on the machine into the project's output. The
285
+ // segment check catches the written form, and the relative check catches
286
+ // everything else, including an absolute inner path and any symlink-free
287
+ // route out that normalisation would otherwise hide.
288
+ if (escapesRecipe(recipeDir, segments.slice(1), inner, resolved)) {
289
+ return { kind: "escapes-recipe", recipe: ref, reference: `${namespace}/${rest}` };
290
+ }
291
+
292
+ return { kind: "candidates", candidates: [resolved] };
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Whether an inner path would address something outside the recipe directory.
298
+ *
299
+ * @param recipeDir - The recipe's absolute directory.
300
+ * @param innerSegments - The inner path's segments, as they were written.
301
+ * @param inner - Those segments rejoined.
302
+ * @param resolved - What the inner path resolved to.
303
+ */
304
+ function escapesRecipe(
305
+ recipeDir: string,
306
+ innerSegments: string[],
307
+ inner: string,
308
+ resolved: string
309
+ ): boolean {
310
+ if (innerSegments.some((segment) => segment === "." || segment === "..")) return true;
311
+ if (inner !== "" && path.isAbsolute(inner)) return true;
312
+ if (resolved === recipeDir) return false;
313
+
314
+ const relative = path.relative(recipeDir, resolved);
315
+ return relative === "" || relative.startsWith("..") || path.isAbsolute(relative);
316
+ }
317
+
318
+ /**
319
+ * Strip the decorations a written reference may carry so it can be compared to
320
+ * a plain `<namespace>/<recipe>` ref: a repository qualifier (a subscription's
321
+ * `sous-public:misc/stuff`, or a manifest's
322
+ * `github://owner/repo/misc/stuff` locator) and a trailing version range
323
+ * (`misc/stuff@^1.2`).
324
+ *
325
+ * @param ref - A reference as written in a manifest or subscription entry.
326
+ * @returns The bare `<namespace>` or `<namespace>/<recipe>` form.
327
+ */
328
+ export function normalizeRef(ref: string): string {
329
+ const trimmed = ref.trim();
330
+
331
+ // A locator URL names the repository first and the recipe last, so the two
332
+ // trailing segments are the ref; everything before them is where it lives.
333
+ const scheme = trimmed.indexOf("://");
334
+ const body =
335
+ scheme === -1
336
+ ? trimmed.includes(":")
337
+ ? trimmed.slice(trimmed.indexOf(":") + 1)
338
+ : trimmed
339
+ : trimmed.slice(scheme + 3).split("/").slice(-2).join("/");
340
+
341
+ const at = body.lastIndexOf("@");
342
+ return (at > 0 ? body.slice(0, at) : body).trim();
343
+ }
344
+
345
+ /**
346
+ * Whether a list of declared refs covers a recipe, either by naming the recipe
347
+ * itself or by naming its whole namespace.
348
+ *
349
+ * @param declared - Declared refs (dependencies, or the project's subscriptions).
350
+ * @param namespace - The namespace being addressed.
351
+ * @param ref - The fully qualified recipe ref being addressed.
352
+ * @returns True when the reference is in scope.
353
+ */
354
+ function declaresRef(declared: string[], namespace: string, ref: string): boolean {
355
+ return declared.some((entry) => {
356
+ const normalized = normalizeRef(entry);
357
+ return normalized === ref || normalized === namespace;
358
+ });
359
+ }
360
+
361
+ /**
362
+ * Whether `target` is the directory `dir` itself or sits underneath it.
363
+ *
364
+ * @param dir - An absolute directory path.
365
+ * @param target - An absolute file or directory path.
366
+ * @returns True when target is inside dir.
367
+ */
368
+ function isInside(dir: string, target: string): boolean {
369
+ return target === dir || target.startsWith(dir + path.sep);
370
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * The base class every built-in provider extends.
3
+ *
4
+ * It carries the plumbing no provider should repeat: running a subprocess
5
+ * through the injectable runner, capturing what one printed, finding a token in
6
+ * the environment or from the host's own command line tool, and pulling the
7
+ * address out of a tool's output.
8
+ *
9
+ * It also answers the whole write path with a refusal. A provider that does not
10
+ * declare the `submit` feature (the local one, for instance) inherits four
11
+ * methods that raise a ConfigError naming the provider and what was asked of
12
+ * it, so a caller that skips the feature check gets a sentence rather than a
13
+ * `TypeError`.
14
+ */
15
+
16
+ import { ConfigError } from "../../errors.js";
17
+ import {
18
+ spawnCommand,
19
+ tryCommand,
20
+ type CommandResult,
21
+ type CommandRunner,
22
+ } from "./git.js";
23
+ import type {
24
+ AuthStatus,
25
+ CanonicalRepo,
26
+ ChangeProposal,
27
+ FetchedIndex,
28
+ ForkedRepo,
29
+ ProposedChange,
30
+ ProviderCli,
31
+ ProviderFeature,
32
+ ProviderId,
33
+ ProviderOptions,
34
+ RepoProvider,
35
+ } from "./provider.js";
36
+
37
+ /**
38
+ * The first URL in a command's output, which is where a host's own tool prints
39
+ * the thing it just created.
40
+ *
41
+ * firstUrlIn("https://github.com/o/r/pull/7\n"); // -> "https://github.com/o/r/pull/7"
42
+ *
43
+ * @param output - Whatever the command printed.
44
+ */
45
+ export function firstUrlIn(output: string): string | undefined {
46
+ const match = /https?:\/\/\S+/.exec(output);
47
+ return match === null ? undefined : match[0];
48
+ }
49
+
50
+ /** Everything a provider inherits rather than writes for itself. */
51
+ export abstract class ProviderBase implements RepoProvider {
52
+ abstract readonly id: ProviderId;
53
+ abstract readonly features: ProviderFeature[];
54
+
55
+ abstract matches(url: string): boolean;
56
+ abstract canonicalize(url: string): CanonicalRepo;
57
+ abstract fetchIndex(repo: CanonicalRepo, options?: ProviderOptions): Promise<FetchedIndex>;
58
+ abstract fetchRecipeTree(
59
+ repo: CanonicalRepo,
60
+ recipePath: string,
61
+ tag: string,
62
+ destDir: string,
63
+ options?: ProviderOptions
64
+ ): Promise<void>;
65
+
66
+ // --- Subprocess plumbing ---------------------------------------------------
67
+
68
+ /**
69
+ * The runner a call should use: the injected one when a caller supplied it,
70
+ * and a real process otherwise. Tests substitute their own, which is why no
71
+ * provider reaches for `spawn` directly.
72
+ *
73
+ * @param options - The call's options.
74
+ */
75
+ protected runnerFor(options: ProviderOptions): CommandRunner {
76
+ return options.run ?? spawnCommand;
77
+ }
78
+
79
+ /**
80
+ * Runs a command and hands back everything it reported, including a non-zero
81
+ * exit. A command that cannot be started at all comes back as exit code 127.
82
+ *
83
+ * @param command - The executable to run.
84
+ * @param args - Its arguments, already split.
85
+ * @param options - The call's options; `cwd` and `run` are used.
86
+ */
87
+ protected async runCommand(
88
+ command: string,
89
+ args: string[],
90
+ options: ProviderOptions = {}
91
+ ): Promise<CommandResult> {
92
+ return this.runnerFor(options)(command, args, { cwd: options.cwd });
93
+ }
94
+
95
+ /**
96
+ * True when a command ran and exited successfully, whatever it printed. Used
97
+ * for the checks whose answer is the exit code itself, such as `auth status`.
98
+ *
99
+ * @param command - The executable to run.
100
+ * @param args - Its arguments.
101
+ * @param options - The call's options.
102
+ */
103
+ protected async commandSucceeds(
104
+ command: string,
105
+ args: string[],
106
+ options: ProviderOptions = {}
107
+ ): Promise<boolean> {
108
+ try {
109
+ const result = await this.runCommand(command, args, options);
110
+ return result.code === 0;
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * A command's trimmed standard output, or undefined when it did not succeed,
118
+ * is not installed, or printed nothing at all.
119
+ *
120
+ * @param command - The executable to run.
121
+ * @param args - Its arguments.
122
+ * @param options - The call's options.
123
+ */
124
+ protected async capturedOutput(
125
+ command: string,
126
+ args: string[],
127
+ options: ProviderOptions = {}
128
+ ): Promise<string | undefined> {
129
+ return tryCommand(command, args, {
130
+ ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
131
+ ...(options.run === undefined ? {} : { run: options.run }),
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Finds a host token: the environment first, then the host's own command line
137
+ * tool when it is installed and signed in. Undefined is a normal answer,
138
+ * because a public repository needs no token at all.
139
+ *
140
+ * @param envName - The environment variable to read, such as `GITHUB_TOKEN`.
141
+ * @param args - The arguments that make the tool print a token.
142
+ * @param options - Environment and subprocess runner overrides.
143
+ */
144
+ protected async findToken(
145
+ envName: string,
146
+ args: string[],
147
+ options: ProviderOptions = {}
148
+ ): Promise<string | undefined> {
149
+ const env = options.env ?? process.env;
150
+ const fromEnv = env[envName];
151
+ if (fromEnv !== undefined && fromEnv.trim().length > 0) return fromEnv.trim();
152
+ if (this.cli === undefined) return undefined;
153
+ return this.capturedOutput(this.cli.command, args, options);
154
+ }
155
+
156
+ // --- The write path, refused unless a provider overrides it ----------------
157
+
158
+ /**
159
+ * The command line tool this provider drives, and what its proposals are
160
+ * called. Both are declared by the provider that has them; `declare` here
161
+ * only tells the type system they may exist, so a subclass's own field is the
162
+ * one that ends up on the instance.
163
+ */
164
+ declare readonly cli?: ProviderCli;
165
+ declare readonly proposalNoun?: string;
166
+
167
+ async authStatus(_options: ProviderOptions = {}): Promise<AuthStatus> {
168
+ throw this.unsupported("submit", "check whether you are signed in to it");
169
+ }
170
+
171
+ async canPush(
172
+ _repo: CanonicalRepo,
173
+ _options: ProviderOptions = {}
174
+ ): Promise<boolean | undefined> {
175
+ throw this.unsupported("submit", "check whether you can push to it");
176
+ }
177
+
178
+ async fork(_repo: CanonicalRepo, _options: ProviderOptions = {}): Promise<ForkedRepo> {
179
+ throw this.unsupported("submit", "fork it on your behalf");
180
+ }
181
+
182
+ async proposeChange(
183
+ _repo: CanonicalRepo,
184
+ _proposal: ChangeProposal,
185
+ _options: ProviderOptions = {}
186
+ ): Promise<ProposedChange> {
187
+ throw this.unsupported("submit", "propose a change to it");
188
+ }
189
+
190
+ /**
191
+ * The refusal a provider gives when it is asked for something it never
192
+ * claimed. It names the provider and the feature, so the caller learns why
193
+ * rather than only that.
194
+ *
195
+ * @param feature - The feature the call belongs to.
196
+ * @param what - What was being attempted, in plain language.
197
+ */
198
+ protected unsupported(feature: ProviderFeature, what: string): ConfigError {
199
+ return new ConfigError(
200
+ `The '${this.id}' provider does not support the '${feature}' feature, so sous cannot ` +
201
+ `${what}.\n` +
202
+ ` A provider answers only what its features promise; this one promises ` +
203
+ `${this.features.map((entry) => `'${entry}'`).join(", ")}.`
204
+ );
205
+ }
206
+ }