@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,513 @@
1
+ /**
2
+ * The ref parser.
3
+ *
4
+ * A "ref" is how everything in the Repositories system names a namespace or a
5
+ * recipe: on the command line, in a project's subscriptions, and in a recipe
6
+ * manifest's `depends` and `subscribes` lists. The grammar is deliberately
7
+ * small and prefix-free:
8
+ *
9
+ * ref := [ repo ":" ] namespace [ "/" recipe [ "@" range ] ]
10
+ *
11
+ * workflow a whole namespace
12
+ * workflow/task-files one recipe, any version
13
+ * workflow/task-files@^1.2.0 one recipe, constrained
14
+ * sous-recipes:workflow/task-files the same recipe in a named repo
15
+ *
16
+ * The repo qualifier is only needed when the same ref resolves in more than one
17
+ * added repo; refs otherwise resolve across every added repo's cached index.
18
+ * A version range applies to a recipe, never to a namespace, because namespaces
19
+ * are not versioned.
20
+ */
21
+
22
+ import semver from "semver";
23
+ import { ConfigError } from "../errors.js";
24
+ import {
25
+ NAMESPACE_NAME_PATTERN,
26
+ RECIPE_NAME_PATTERN,
27
+ REPO_NAME_PATTERN,
28
+ } from "./formats/patterns.js";
29
+
30
+ /** A parsed ref. `recipe` is absent for a namespace ref; `range` needs a recipe. */
31
+ export type ParsedRef = {
32
+ /** The repo short name from a `repo:` qualifier, when one was given. */
33
+ repo?: string;
34
+ /** The namespace. Always present. */
35
+ namespace: string;
36
+ /** The recipe name, when the ref names a recipe rather than a whole namespace. */
37
+ recipe?: string;
38
+ /** The semantic version range, when one was given. Only legal with a recipe. */
39
+ range?: string;
40
+ };
41
+
42
+ /** The one-line reminder appended to every ref error. */
43
+ const SYNTAX_HELP =
44
+ "A ref is written as 'namespace', 'namespace/recipe', 'namespace/recipe@<range>' or " +
45
+ "'repo:namespace/recipe@<range>'.";
46
+
47
+ /** Builds a ConfigError that quotes the offending input and shows the grammar. */
48
+ function refError(input: string, problem: string): ConfigError {
49
+ return new ConfigError(`Invalid ref '${input}': ${problem}\n ${SYNTAX_HELP}`);
50
+ }
51
+
52
+ /**
53
+ * Parses a ref string into its parts, throwing a ConfigError that quotes the
54
+ * input and shows the grammar when it does not fit.
55
+ *
56
+ * @param input - The ref as written by a user or a manifest.
57
+ */
58
+ export function parseRef(input: string): ParsedRef {
59
+ if (typeof input !== "string") {
60
+ throw refError(String(input), "a ref must be a string.");
61
+ }
62
+
63
+ const trimmed = input.trim();
64
+ if (trimmed.length === 0) {
65
+ throw refError(input, "a ref must not be empty.");
66
+ }
67
+
68
+ if (trimmed.startsWith("@")) {
69
+ throw refError(
70
+ input,
71
+ "refs take no '@' prefix. The '@' character introduces a version range only, " +
72
+ "as in 'workflow/task-files@^1.2.0'."
73
+ );
74
+ }
75
+ if (trimmed.startsWith("~")) {
76
+ throw refError(
77
+ input,
78
+ "refs take no '~' prefix. The '~' sigil belongs to template include lines " +
79
+ "('@~workflow/file.md'); a ref itself is written without it."
80
+ );
81
+ }
82
+
83
+ // Split the version range off first, so the rest is pure path and qualifier.
84
+ let body = trimmed;
85
+ let range: string | undefined;
86
+ const atIndex = body.indexOf("@");
87
+ if (atIndex !== -1) {
88
+ if (body.indexOf("@", atIndex + 1) !== -1) {
89
+ throw refError(input, "a ref may carry at most one '@' version range.");
90
+ }
91
+ range = body.slice(atIndex + 1).trim();
92
+ body = body.slice(0, atIndex);
93
+ if (range.length === 0) {
94
+ throw refError(input, "the '@' is not followed by a version range.");
95
+ }
96
+ }
97
+
98
+ // Then the repo qualifier.
99
+ let repo: string | undefined;
100
+ const colonIndex = body.indexOf(":");
101
+ if (colonIndex !== -1) {
102
+ if (body.indexOf(":", colonIndex + 1) !== -1) {
103
+ throw refError(input, "a ref may carry at most one 'repo:' qualifier.");
104
+ }
105
+ repo = body.slice(0, colonIndex);
106
+ body = body.slice(colonIndex + 1);
107
+ if (repo.length === 0) {
108
+ throw refError(input, "the repo qualifier before ':' is empty.");
109
+ }
110
+ if (!REPO_NAME_PATTERN.test(repo)) {
111
+ throw refError(
112
+ input,
113
+ `the repo qualifier '${repo}' must be lowercase kebab-case: a letter, then ` +
114
+ "letters, digits or hyphens."
115
+ );
116
+ }
117
+ }
118
+
119
+ // What is left is the namespace, optionally followed by a recipe.
120
+ const segments = body.split("/");
121
+ if (segments.length > 2) {
122
+ throw refError(
123
+ input,
124
+ "a ref has at most two path segments, a namespace and a recipe."
125
+ );
126
+ }
127
+
128
+ const [namespace, recipe] = segments;
129
+ if (namespace === undefined || namespace.length === 0) {
130
+ throw refError(input, "the namespace is empty.");
131
+ }
132
+ if (!NAMESPACE_NAME_PATTERN.test(namespace)) {
133
+ throw refError(
134
+ input,
135
+ `the namespace '${namespace}' must be lowercase kebab-case: a letter, then ` +
136
+ "letters, digits or hyphens."
137
+ );
138
+ }
139
+
140
+ if (recipe !== undefined) {
141
+ if (recipe.length === 0) {
142
+ throw refError(input, "the recipe name after '/' is empty.");
143
+ }
144
+ if (!RECIPE_NAME_PATTERN.test(recipe)) {
145
+ throw refError(
146
+ input,
147
+ `the recipe name '${recipe}' must be lowercase kebab-case: a letter, then ` +
148
+ "letters, digits or hyphens."
149
+ );
150
+ }
151
+ }
152
+
153
+ if (range !== undefined) {
154
+ if (recipe === undefined) {
155
+ throw refError(
156
+ input,
157
+ "a version range applies to a recipe, and namespaces are not versioned. " +
158
+ "Name a recipe, as in 'workflow/task-files@^1.2.0'."
159
+ );
160
+ }
161
+ if (semver.validRange(range) === null) {
162
+ throw refError(
163
+ input,
164
+ `'${range}' is not a version range. Ranges follow npm's rules, such as ` +
165
+ "'^1.2.0', '~2.1', '>=1.0.0 <2.0.0' or '*'."
166
+ );
167
+ }
168
+ }
169
+
170
+ const parsed: ParsedRef = { namespace };
171
+ if (repo !== undefined) parsed.repo = repo;
172
+ if (recipe !== undefined) parsed.recipe = recipe;
173
+ if (range !== undefined) parsed.range = range;
174
+ return parsed;
175
+ }
176
+
177
+ /**
178
+ * Parses a ref, returning undefined instead of throwing. Use this where a bad
179
+ * ref is reported through another mechanism, such as a zod issue.
180
+ *
181
+ * @param input - The ref as written.
182
+ */
183
+ export function tryParseRef(input: string): ParsedRef | undefined {
184
+ try {
185
+ return parseRef(input);
186
+ } catch {
187
+ return undefined;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * True when the input parses as a ref.
193
+ *
194
+ * @param input - The ref as written.
195
+ */
196
+ export function isValidRef(input: string): boolean {
197
+ return tryParseRef(input) !== undefined;
198
+ }
199
+
200
+ /**
201
+ * Renders a parsed ref back into its canonical written form. Round-trips with
202
+ * parseRef, apart from surrounding whitespace.
203
+ *
204
+ * @param parsed - The ref parts.
205
+ */
206
+ export function formatRef(parsed: ParsedRef): string {
207
+ const qualifier = parsed.repo === undefined ? "" : `${parsed.repo}:`;
208
+ const recipe = parsed.recipe === undefined ? "" : `/${parsed.recipe}`;
209
+ const range = parsed.range === undefined ? "" : `@${parsed.range}`;
210
+ return `${qualifier}${parsed.namespace}${recipe}${range}`;
211
+ }
212
+
213
+ /**
214
+ * The ref's identity, with the repo qualifier and the version range dropped:
215
+ * `namespace` for a namespace ref, `namespace/recipe` for a recipe ref. This is
216
+ * the key everything else is stored under (subscriptions, the index, the
217
+ * lockfile), so the same recipe is never recorded twice under two spellings.
218
+ *
219
+ * @param parsed - The ref parts.
220
+ */
221
+ export function refKey(parsed: ParsedRef): string {
222
+ return parsed.recipe === undefined
223
+ ? parsed.namespace
224
+ : `${parsed.namespace}/${parsed.recipe}`;
225
+ }
226
+
227
+ /** True when the ref names a whole namespace rather than a single recipe. */
228
+ export function isNamespaceRef(parsed: ParsedRef): boolean {
229
+ return parsed.recipe === undefined;
230
+ }
231
+
232
+ // --- Dependency refs ----------------------------------------------------------------------------
233
+
234
+ /**
235
+ * The default host of each provider that can appear as a dependency locator's
236
+ * scheme. A locator whose repository path does not begin with a host segment
237
+ * (a segment carrying a dot) means the provider's own public host.
238
+ *
239
+ * The values match the `GITHUB_HOST` and `GITLAB_HOST` constants the providers
240
+ * themselves use; they are repeated here because the ref parser deliberately
241
+ * imports nothing from the provider layer, which is built on top of it.
242
+ */
243
+ export const DEPENDENCY_PROVIDER_HOSTS: Readonly<Record<string, string>> = {
244
+ github: "github.com",
245
+ gitlab: "gitlab.com",
246
+ };
247
+
248
+ /**
249
+ * A dependency as a recipe manifest writes it, in either of the two spellings
250
+ * `depends` and `subscribes` accept.
251
+ *
252
+ * A SIBLING is a recipe in the same repository, written as a bare ref; a REMOTE
253
+ * is a recipe in another repository, written as a locator URL whose scheme is
254
+ * the provider's identifier.
255
+ */
256
+ export type DependencyRef = {
257
+ /** Whether the target lives in this repository or in another one. */
258
+ kind: "sibling" | "remote";
259
+ /** The provider identifier the locator's scheme named. Remote refs only. */
260
+ provider?: string;
261
+ /** The host the repository lives on, explicit or the provider's default. */
262
+ host?: string;
263
+ /** The repository's path on that host, such as `sous-io/sous-recipes`. */
264
+ repoPath?: string;
265
+ /** The repository's canonical identity, `<host>/<repoPath>`. Remote refs only. */
266
+ canonicalRepo?: string;
267
+ /** The namespace the target recipe belongs to. */
268
+ namespace: string;
269
+ /**
270
+ * The recipe's name. A sibling ref may name a whole namespace and leave this
271
+ * out; a locator URL always names a recipe, because its last two path
272
+ * segments are by rule the namespace and the recipe.
273
+ */
274
+ recipe?: string;
275
+ /** The version range, when one was written. */
276
+ range?: string;
277
+ };
278
+
279
+ /** The one-line reminder appended to every dependency ref error. */
280
+ const DEPENDENCY_SYNTAX_HELP =
281
+ "A dependency is written either as a bare ref naming a recipe in this same repository " +
282
+ "('workflow/task-files', optionally with a range such as 'workflow/task-files@^1.1'), " +
283
+ "or as a locator URL naming a recipe in another repository " +
284
+ "('github://sous-io/sous-recipes/workflow/task-files@^1.1').";
285
+
286
+ /** Builds a ConfigError that quotes the offending dependency and shows the grammar. */
287
+ function dependencyError(input: string, problem: string): ConfigError {
288
+ return new ConfigError(
289
+ `Invalid dependency '${input}': ${problem}\n ${DEPENDENCY_SYNTAX_HELP}`
290
+ );
291
+ }
292
+
293
+ /** The scheme a locator URL leads with, when it leads with one. */
294
+ const LOCATOR_SCHEME_PATTERN = /^([a-z][a-z0-9+.-]*):\/\/(.*)$/i;
295
+
296
+ /**
297
+ * Parses one entry of a recipe manifest's `depends` or `subscribes` list.
298
+ *
299
+ * parseDependencyRef("workflow/task-files");
300
+ * // -> { kind: "sibling", namespace: "workflow", recipe: "task-files" }
301
+ *
302
+ * parseDependencyRef("github://sous-io/sous-recipes/workflow/sat@^1.1");
303
+ * // -> { kind: "remote", provider: "github", host: "github.com",
304
+ * // repoPath: "sous-io/sous-recipes",
305
+ * // canonicalRepo: "github.com/sous-io/sous-recipes",
306
+ * // namespace: "workflow", recipe: "sat", range: "^1.1" }
307
+ *
308
+ * @param input - The dependency exactly as the manifest wrote it.
309
+ */
310
+ export function parseDependencyRef(input: string): DependencyRef {
311
+ if (typeof input !== "string") {
312
+ throw dependencyError(String(input), "a dependency must be a string.");
313
+ }
314
+
315
+ const trimmed = input.trim();
316
+ if (trimmed.length === 0) {
317
+ throw dependencyError(input, "a dependency must not be empty.");
318
+ }
319
+
320
+ const locator = LOCATOR_SCHEME_PATTERN.exec(trimmed);
321
+ if (locator !== null) return parseRemoteDependency(input, locator[1]!, locator[2]!);
322
+
323
+ // Anything without a scheme is a sibling, so it follows the ordinary ref
324
+ // grammar; the one thing a manifest may no longer write is the consumer-side
325
+ // 'repo:' qualifier, which names a short name only that project knows.
326
+ if (trimmed.includes(":")) {
327
+ throw dependencyError(
328
+ input,
329
+ "a 'repo:' qualifier names a short name that only the consuming project knows, so " +
330
+ "it cannot appear in a published manifest. Name the other repository by its " +
331
+ "location instead, as in 'github://owner/repository/namespace/recipe'."
332
+ );
333
+ }
334
+
335
+ const parsed = parseRef(trimmed);
336
+ const sibling: DependencyRef = { kind: "sibling", namespace: parsed.namespace };
337
+ if (parsed.recipe !== undefined) sibling.recipe = parsed.recipe;
338
+ if (parsed.range !== undefined) sibling.range = parsed.range;
339
+ return sibling;
340
+ }
341
+
342
+ /**
343
+ * Parses the locator form, whose scheme is the provider's identifier.
344
+ *
345
+ * The path is read from the RIGHT: the last two segments are always the
346
+ * namespace and the recipe, because they are the recipe's published identity
347
+ * and never a filesystem path. Everything before them is the repository, whose
348
+ * first segment is the host when it carries a dot and otherwise the provider's
349
+ * own public host.
350
+ *
351
+ * @param input - The dependency as written, for error messages.
352
+ * @param scheme - The scheme, which names the provider.
353
+ * @param rest - Everything after the `://`.
354
+ */
355
+ function parseRemoteDependency(
356
+ input: string,
357
+ scheme: string,
358
+ rest: string
359
+ ): DependencyRef {
360
+ const provider = scheme.toLowerCase();
361
+
362
+ if (provider === "local") {
363
+ throw dependencyError(
364
+ input,
365
+ "a local repository is a consumer's convenience, not a published location, so a " +
366
+ "manifest cannot depend on one. Publish the recipe and depend on it by its " +
367
+ "published location."
368
+ );
369
+ }
370
+
371
+ const defaultHost = DEPENDENCY_PROVIDER_HOSTS[provider];
372
+ if (defaultHost === undefined) {
373
+ const known = Object.keys(DEPENDENCY_PROVIDER_HOSTS).sort().join(", ");
374
+ throw dependencyError(
375
+ input,
376
+ `'${provider}' is not a provider sous can fetch from. The scheme of a locator URL ` +
377
+ `is the provider's own identifier; sous ships these: ${known}.`
378
+ );
379
+ }
380
+
381
+ let body = rest.trim();
382
+ let range: string | undefined;
383
+ const atIndex = body.indexOf("@");
384
+ if (atIndex !== -1) {
385
+ if (body.indexOf("@", atIndex + 1) !== -1) {
386
+ throw dependencyError(input, "a dependency may carry at most one '@' version range.");
387
+ }
388
+ range = body.slice(atIndex + 1).trim();
389
+ body = body.slice(0, atIndex);
390
+ if (range.length === 0) {
391
+ throw dependencyError(input, "the '@' is not followed by a version range.");
392
+ }
393
+ if (semver.validRange(range) === null) {
394
+ throw dependencyError(
395
+ input,
396
+ `'${range}' is not a version range. Ranges follow npm's rules, such as '^1.2.0', ` +
397
+ "'~2.1', '>=1.0.0 <2.0.0' or '*'."
398
+ );
399
+ }
400
+ }
401
+
402
+ const segments = body.split("/").filter((segment) => segment.length > 0);
403
+
404
+ // The recipe is two segments and a repository is at least an owner and a
405
+ // name, so four is the shortest locator that can mean anything.
406
+ if (segments.length < 4) {
407
+ throw dependencyError(
408
+ input,
409
+ "a locator URL names a repository and then the recipe inside it, as in " +
410
+ "'github://owner/repository/namespace/recipe'. The last two segments are always " +
411
+ "the namespace and the recipe."
412
+ );
413
+ }
414
+
415
+ const recipe = segments.pop()!;
416
+ const namespace = segments.pop()!;
417
+
418
+ if (!NAMESPACE_NAME_PATTERN.test(namespace)) {
419
+ throw dependencyError(
420
+ input,
421
+ `the namespace '${namespace}' must be lowercase kebab-case: a letter, then letters, ` +
422
+ "digits or hyphens."
423
+ );
424
+ }
425
+ if (!RECIPE_NAME_PATTERN.test(recipe)) {
426
+ throw dependencyError(
427
+ input,
428
+ `the recipe name '${recipe}' must be lowercase kebab-case: a letter, then letters, ` +
429
+ "digits or hyphens."
430
+ );
431
+ }
432
+
433
+ const first = segments[0]!;
434
+ const hasHost = first.includes(".");
435
+ const host = (hasHost ? first : defaultHost).toLowerCase();
436
+ const pathSegments = hasHost ? segments.slice(1) : segments;
437
+
438
+ if (pathSegments.length < 2) {
439
+ const what =
440
+ pathSegments.length === 0 ? "nothing" : `only '${pathSegments.join("/")}'`;
441
+ throw dependencyError(
442
+ input,
443
+ `the host '${host}' is followed by ${what}, and a repository is named by an owner ` +
444
+ "and a repository name, as in " +
445
+ "'gitlab://gitlab.example.com/group/subgroup/project/namespace/recipe'."
446
+ );
447
+ }
448
+
449
+ const repoPath = pathSegments.join("/").replace(/\.git$/i, "");
450
+
451
+ return {
452
+ kind: "remote",
453
+ provider,
454
+ host,
455
+ repoPath,
456
+ canonicalRepo: `${host}/${repoPath}`.toLowerCase(),
457
+ namespace,
458
+ recipe,
459
+ ...(range === undefined ? {} : { range }),
460
+ };
461
+ }
462
+
463
+ /**
464
+ * The recipe key a dependency names: `namespace/recipe`, or the bare namespace
465
+ * when a sibling ref named a whole namespace. This is what the index, the
466
+ * lockfile and the store all file a recipe under.
467
+ *
468
+ * @param parsed - The parsed dependency.
469
+ */
470
+ export function dependencyRefKey(parsed: DependencyRef): string {
471
+ return parsed.recipe === undefined
472
+ ? parsed.namespace
473
+ : `${parsed.namespace}/${parsed.recipe}`;
474
+ }
475
+
476
+ /**
477
+ * Renders a parsed dependency back into the form a manifest writes. Round-trips
478
+ * with parseDependencyRef, apart from surrounding whitespace and a host that was
479
+ * left implicit.
480
+ *
481
+ * @param parsed - The parsed dependency.
482
+ */
483
+ export function formatDependencyRef(parsed: DependencyRef): string {
484
+ const key = dependencyRefKey(parsed);
485
+ const range = parsed.range === undefined ? "" : `@${parsed.range}`;
486
+ if (parsed.kind === "sibling") return `${key}${range}`;
487
+ return `${parsed.provider}://${parsed.host}/${parsed.repoPath}/${key}${range}`;
488
+ }
489
+
490
+ /**
491
+ * Parses a dependency, returning undefined instead of throwing. Use this where
492
+ * a bad dependency is reported through another mechanism, such as a zod issue.
493
+ *
494
+ * @param input - The dependency as written.
495
+ */
496
+ export function tryParseDependencyRef(input: string): DependencyRef | undefined {
497
+ try {
498
+ return parseDependencyRef(input);
499
+ } catch {
500
+ return undefined;
501
+ }
502
+ }
503
+
504
+ /**
505
+ * The HTTPS location a remote dependency points at, which is what `sous repo
506
+ * add` would be given for it.
507
+ *
508
+ * @param parsed - A parsed remote dependency.
509
+ */
510
+ export function dependencyRepoUrl(parsed: DependencyRef): string | undefined {
511
+ if (parsed.kind !== "remote") return undefined;
512
+ return `https://${parsed.host}/${parsed.repoPath}`;
513
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Saying what a reference resolved to.
3
+ *
4
+ * A word on the command line can name a repository, a namespace, a recipe or a
5
+ * variable, and when sous settles which one it means it has to say so. What it
6
+ * resolved to is a set of facts, so it is written as the same key and value
7
+ * list every other set of facts in the CLI is written as, with one short
8
+ * sentence after it saying why that candidate won. One function builds it, so
9
+ * every command that resolves a reference reports it the same way; `pickReference`
10
+ * in `src/lib/refs/pick.ts` is the single caller, which is how every command gets it.
11
+ */
12
+
13
+ import {
14
+ formatVariable,
15
+ indent,
16
+ wrapColumns,
17
+ wrapText,
18
+ type VariableEntry,
19
+ } from "../../utils/formatting.js";
20
+ import { SCOPE_LABELS, SousScope } from "../refs/scopes.js";
21
+ import type { ReferenceMatch } from "../refs/find.js";
22
+
23
+ /** What a resolved reference is, in the words the report shows. */
24
+ export interface ResolvedReferenceFacts {
25
+ /** The reference exactly as the person wrote it. */
26
+ search: string;
27
+ /** The fully qualified spelling the run proceeds with. */
28
+ resolvedTo: string;
29
+ /** What kind of thing it turned out to be: "recipe", "namespace", "repository". */
30
+ kind: string;
31
+ /** The repository publishing it. */
32
+ repository?: string;
33
+ /** The namespace it lives in, when it has one. */
34
+ namespace?: string;
35
+ /** The recipe itself, when the reference named one. */
36
+ recipe?: string;
37
+ /** The variable itself, when the reference named one. */
38
+ variable?: string;
39
+ /** Where a repository lives, when the reference named a repository. */
40
+ location?: string;
41
+ /** The publisher's one-line summary, when there is one. */
42
+ description?: string;
43
+ /**
44
+ * Why this candidate won, as the closing sentence. The default says the
45
+ * reference named one thing and nothing else; a caller that settled it some
46
+ * other way (taking the first of several, say) supplies its own.
47
+ */
48
+ reason?: string;
49
+ }
50
+
51
+ /**
52
+ * The report as lines to write: the facts, a blank line, and the sentence.
53
+ *
54
+ * @param facts - What the reference resolved to.
55
+ * @returns The lines to write, ready for the console.
56
+ */
57
+ export function formatResolvedReference(facts: ResolvedReferenceFacts): string[] {
58
+ const entries: VariableEntry[] = [
59
+ { label: "Resolved to", value: facts.resolvedTo },
60
+ ...(facts.variable === undefined ? [] : [{ label: "Variable", value: facts.variable }]),
61
+ ...(facts.recipe === undefined ? [] : [{ label: "Recipe", value: facts.recipe }]),
62
+ ...(facts.namespace === undefined ? [] : [{ label: "Namespace", value: facts.namespace }]),
63
+ ...(facts.repository === undefined
64
+ ? []
65
+ : [{ label: "Repository", value: facts.repository }]),
66
+ ...(facts.location === undefined ? [] : [{ label: "Location", value: facts.location }]),
67
+ ...(facts.description === undefined
68
+ ? []
69
+ : [{ label: "Description", value: facts.description }]),
70
+ ];
71
+
72
+ const labelWidth = Math.max(...entries.map((entry) => entry.label.length));
73
+ const lines = entries.flatMap((entry) => formatVariable(entry, { labelWidth }));
74
+
75
+ const reason =
76
+ facts.reason ??
77
+ `'${facts.search}' named one ${facts.kind}, and nothing else, so that is what ` +
78
+ `is being used.`;
79
+
80
+ lines.push("");
81
+ for (const line of wrapText(reason, wrapColumns() - 4)) {
82
+ lines.push(indent(line));
83
+ }
84
+
85
+ return lines;
86
+ }
87
+
88
+ /**
89
+ * The facts one match carries, ready for `formatResolvedReference`.
90
+ *
91
+ * The match knows what it is; this decides which of its fields are facts worth
92
+ * showing. A repository's detail is where it lives rather than a summary of it,
93
+ * and a repository's own name is already the resolved spelling, so neither is
94
+ * repeated as a line of its own. What a match resolves to is always its fully
95
+ * qualified key, including for an environment variable name, because the key is
96
+ * what the rest of the run proceeds with.
97
+ *
98
+ * @param match - The match the run proceeds with.
99
+ * @param search - The reference exactly as it was written.
100
+ * @param reason - The closing sentence, when the caller has one of its own.
101
+ */
102
+ export function resolvedReferenceFacts(
103
+ match: ReferenceMatch,
104
+ search: string,
105
+ reason?: string
106
+ ): ResolvedReferenceFacts {
107
+ const isRepository = match.scope === SousScope.Repository;
108
+ const resolvedTo = match.key;
109
+
110
+ return {
111
+ search,
112
+ resolvedTo,
113
+ kind: SCOPE_LABELS[match.scope],
114
+ ...(match.variable === undefined ? {} : { variable: match.variable }),
115
+ ...(match.recipe === undefined ? {} : { recipe: match.recipe }),
116
+ ...(match.namespace === undefined ? {} : { namespace: match.namespace }),
117
+ ...(match.repo === undefined || match.repo === resolvedTo ? {} : { repository: match.repo }),
118
+ ...(isRepository && match.detail !== undefined ? { location: match.detail } : {}),
119
+ ...(!isRepository && match.detail !== undefined ? { description: match.detail } : {}),
120
+ ...(reason === undefined ? {} : { reason }),
121
+ };
122
+ }