@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,391 @@
1
+ /**
2
+ * The local provider: a repository that lives on this machine.
3
+ *
4
+ * It exists for local development and for tests. A recipe repository under
5
+ * development, or a fixture repository built by a test, is an ordinary
6
+ * directory; this provider reads one exactly the way the hosted providers read
7
+ * a remote, so everything above it (trust, the resolver, the store, the
8
+ * lockfile, the build) runs unchanged and without a network.
9
+ *
10
+ * TRUST SEMANTICS ARE IDENTICAL. A local path is added, and therefore trusted,
11
+ * through the same ceremony as any other repository; sous reads nothing from a
12
+ * directory a project has not added. "It is on my disk already" is not a reason
13
+ * to skip the question, because the recipes in it still run on this machine.
14
+ *
15
+ * Two URL forms are accepted, and they mean the same thing:
16
+ *
17
+ * file:///home/me/Projects/my-recipes
18
+ * /home/me/Projects/my-recipes
19
+ *
20
+ * A relative path (`../my-recipes`, `~/my-recipes`) is what people actually
21
+ * type, so `sous repo add` and `sous repo link` run it through
22
+ * `resolveRepoArgument` before any provider sees it and store the absolute
23
+ * result. The provider itself still matches absolute paths only, because a
24
+ * stored entry is read from a config file that several working directories may
25
+ * run against.
26
+ *
27
+ * The index is read from the working tree when the file is there, so an
28
+ * uncommitted index is picked up while a repository is being authored, and from
29
+ * `git show HEAD:sous.index.json` otherwise. A recipe's files come from
30
+ * `git clone --branch <tag>` of the local path, which is the same sparse,
31
+ * blobless fetch the hosted providers use; a directory that is not a git
32
+ * repository (or a tag that does not exist in it) falls back to copying the
33
+ * recipe folder out of the working tree.
34
+ */
35
+
36
+ import fs from "node:fs";
37
+ import fsp from "node:fs/promises";
38
+ import os from "node:os";
39
+ import path from "node:path";
40
+ import { fileURLToPath, pathToFileURL } from "node:url";
41
+ import { ConfigError } from "../../errors.js";
42
+ import {
43
+ INDEX_FILENAME,
44
+ MANIFEST_EXTENSIONS,
45
+ REPO_MANIFEST_BASENAME,
46
+ } from "../formats/common.js";
47
+ import { findRepoManifest } from "../load-manifest.js";
48
+ import { ProviderBase } from "./base.js";
49
+ import { fetchSubtree, runGit, tryCommand, type CommandRunner } from "./git.js";
50
+ import type {
51
+ CanonicalRepo,
52
+ FetchedIndex,
53
+ ProviderFeature,
54
+ ProviderOptions,
55
+ } from "./provider.js";
56
+
57
+ /** The identifier a repository entry uses to name this provider explicitly. */
58
+ export const LOCAL_PROVIDER_ID = "local" as const;
59
+
60
+ /**
61
+ * The absolute directory a repository URL names, or undefined when the URL is
62
+ * not a local path at all. Both the `file://` form and a bare absolute path are
63
+ * accepted; a relative path is not, because a repository entry is read from a
64
+ * config file that several working directories may run against.
65
+ *
66
+ * localRepoPath("file:///home/me/recipes"); // -> "/home/me/recipes"
67
+ * localRepoPath("https://github.com/o/r"); // -> undefined
68
+ *
69
+ * @param url - The repository URL, as configured.
70
+ */
71
+ export function localRepoPath(url: string): string | undefined {
72
+ const trimmed = url.trim();
73
+ if (trimmed.length === 0) return undefined;
74
+
75
+ if (trimmed.toLowerCase().startsWith("file://")) {
76
+ try {
77
+ return path.normalize(fileURLToPath(trimmed));
78
+ } catch {
79
+ return undefined;
80
+ }
81
+ }
82
+
83
+ return path.isAbsolute(trimmed) ? path.normalize(trimmed) : undefined;
84
+ }
85
+
86
+ /** A URL that names its scheme, such as `https://` or `ssh://`. */
87
+ const SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
88
+
89
+ /** The `scp`-style SSH form people paste, as in `git@github.com:owner/name.git`. */
90
+ const SCP_PATTERN = /^[^@\s/\\]+@[^@\s/\\:]+:.+$/;
91
+
92
+ /** A Windows absolute path, as in `C:\Projects\recipes`. */
93
+ const WINDOWS_ABSOLUTE_PATTERN = /^[A-Za-z]:[\\/]/;
94
+
95
+ /**
96
+ * Expands a leading `~` to the current user's home directory. Only a bare `~`
97
+ * or a `~/...` prefix is expanded; `~other/x` is left alone, because sous does
98
+ * not look other people's home directories up.
99
+ *
100
+ * @param value - The path as typed.
101
+ */
102
+ export function expandHomePath(value: string): string {
103
+ if (value === "~") return os.homedir();
104
+ if (value.startsWith("~/") || value.startsWith("~\\")) {
105
+ return path.join(os.homedir(), value.slice(2));
106
+ }
107
+ return value;
108
+ }
109
+
110
+ /**
111
+ * True when a repository argument is a filesystem path rather than a URL to a
112
+ * host. A `file://` URL, an absolute path, a `~` path and an explicitly
113
+ * relative path (`./x`, `../x`, `.`, `..`) all count outright. A bare segment
114
+ * such as `my-recipes` counts only when a directory of that name really is
115
+ * there, so a host name is never mistaken for a folder.
116
+ *
117
+ * looksLikeLocalPath("../my-recipes"); // -> true
118
+ * looksLikeLocalPath("https://github.com/o/r"); // -> false
119
+ *
120
+ * @param value - The repository argument, as typed.
121
+ * @param cwd - The directory a relative path is measured from. Defaults to the working directory.
122
+ */
123
+ export function looksLikeLocalPath(value: string, cwd: string = process.cwd()): boolean {
124
+ const trimmed = value.trim();
125
+ if (trimmed.length === 0) return false;
126
+
127
+ if (trimmed.toLowerCase().startsWith("file://")) return true;
128
+ if (SCHEME_PATTERN.test(trimmed)) return false;
129
+
130
+ const expanded = expandHomePath(trimmed);
131
+ if (path.isAbsolute(expanded) || WINDOWS_ABSOLUTE_PATTERN.test(expanded)) return true;
132
+ if (expanded !== trimmed) return true;
133
+
134
+ if (
135
+ trimmed === "." ||
136
+ trimmed === ".." ||
137
+ trimmed.startsWith("./") ||
138
+ trimmed.startsWith("../") ||
139
+ trimmed.startsWith(".\\") ||
140
+ trimmed.startsWith("..\\")
141
+ ) {
142
+ return true;
143
+ }
144
+
145
+ if (SCP_PATTERN.test(trimmed)) return false;
146
+
147
+ try {
148
+ return fs.statSync(path.resolve(cwd, trimmed)).isDirectory();
149
+ } catch {
150
+ return false;
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Normalizes a repository argument before any provider looks at it: a path is
156
+ * expanded and resolved to an absolute one, and anything else is handed back
157
+ * unchanged. The absolute form is what gets stored, because a repository on
158
+ * this machine is machine-specific whichever way it was typed.
159
+ *
160
+ * resolveRepoArgument("../my-recipes", "/home/me/work"); // -> "/home/me/my-recipes"
161
+ * resolveRepoArgument("https://github.com/o/r"); // -> unchanged
162
+ *
163
+ * @param value - The repository argument, as typed.
164
+ * @param cwd - The directory a relative path is measured from. Defaults to the working directory.
165
+ */
166
+ export function resolveRepoArgument(value: string, cwd: string = process.cwd()): string {
167
+ const trimmed = value.trim();
168
+ if (!looksLikeLocalPath(trimmed, cwd)) return trimmed;
169
+
170
+ if (trimmed.toLowerCase().startsWith("file://")) {
171
+ const direct = localRepoPath(trimmed);
172
+ return direct ?? trimmed;
173
+ }
174
+
175
+ return path.resolve(cwd, expandHomePath(trimmed));
176
+ }
177
+
178
+ /**
179
+ * Checks that a resolved local path really is a sous repository, and explains
180
+ * the path itself when it is not. A person who typed a path made a path
181
+ * mistake, so the message names what they typed, where sous looked, and what
182
+ * it expected to find there; providers do not come into it.
183
+ *
184
+ * @param typed - The path exactly as the user typed it.
185
+ * @param resolved - The absolute path sous resolved it to.
186
+ */
187
+ export function assertLocalRepoDirectory(typed: string, resolved: string): void {
188
+ const manifestName = `${REPO_MANIFEST_BASENAME}${MANIFEST_EXTENSIONS[0]}`;
189
+ const spellings = MANIFEST_EXTENSIONS.map(
190
+ (extension) => `${REPO_MANIFEST_BASENAME}${extension}`
191
+ ).join(", ");
192
+
193
+ let stats: fs.Stats;
194
+ try {
195
+ stats = fs.statSync(resolved);
196
+ } catch {
197
+ throw new ConfigError(
198
+ `There is no directory at '${typed}'.\n` +
199
+ ` Sous read that as the path ${resolved}, and nothing is there.\n` +
200
+ ` A repository on this machine is a directory holding a '${manifestName}' ` +
201
+ `file at its root. Check the path, or create one with 'sous repo init'.`
202
+ );
203
+ }
204
+
205
+ if (!stats.isDirectory()) {
206
+ throw new ConfigError(
207
+ `'${typed}' is a file, not a directory.\n` +
208
+ ` Sous read that as the path ${resolved}.\n` +
209
+ ` Name the root directory of a repository: the directory holding its ` +
210
+ `'${manifestName}' file.`
211
+ );
212
+ }
213
+
214
+ if (findRepoManifest(resolved) === undefined) {
215
+ throw new ConfigError(
216
+ `The directory '${typed}' is not a sous repository.\n` +
217
+ ` Sous read that as the path ${resolved}, and it holds no repository ` +
218
+ `manifest.\n` +
219
+ ` A repository declares itself with one of ${spellings} at its root. ` +
220
+ `Check the path, or create a repository there with 'sous repo init'.`
221
+ );
222
+ }
223
+ }
224
+
225
+ /** True when a directory is the root of a git repository or a working tree of one. */
226
+ async function isGitRepository(
227
+ directory: string,
228
+ run?: CommandRunner
229
+ ): Promise<boolean> {
230
+ const found = await tryCommand("git", ["-C", directory, "rev-parse", "--git-dir"], {
231
+ ...(run === undefined ? {} : { run }),
232
+ });
233
+ return found !== undefined;
234
+ }
235
+
236
+ /** True when a git repository holds the named tag. */
237
+ async function hasTag(
238
+ directory: string,
239
+ tag: string,
240
+ run?: CommandRunner
241
+ ): Promise<boolean> {
242
+ const found = await tryCommand(
243
+ "git",
244
+ ["-C", directory, "rev-parse", "--verify", "--quiet", `refs/tags/${tag}`],
245
+ { ...(run === undefined ? {} : { run }) }
246
+ );
247
+ return found !== undefined;
248
+ }
249
+
250
+ /** The repository that a local provider call is reading. */
251
+ function repoDirectory(repo: CanonicalRepo): string {
252
+ return repo.httpsUrl;
253
+ }
254
+
255
+ /** A repository on this machine, read as though it were a hosted one. */
256
+ export class LocalProvider extends ProviderBase {
257
+ readonly id = LOCAL_PROVIDER_ID;
258
+
259
+ /**
260
+ * Proposing a change to a directory on your own disk is just editing it, so
261
+ * this provider declares no `submit` feature and has no command line tool.
262
+ * The write-path calls it inherits from ProviderBase all refuse, naming the
263
+ * provider and the feature.
264
+ */
265
+ readonly features: ProviderFeature[] = ["fetch"];
266
+
267
+ matches(url: string): boolean {
268
+ return localRepoPath(url) !== undefined;
269
+ }
270
+
271
+ /**
272
+ * Takes a local repository path apart. `httpsUrl` carries the absolute
273
+ * directory rather than a URL, because that is what git is handed when a
274
+ * recipe is fetched; `sshUrl` carries the canonical `file://` spelling, so a
275
+ * caller that wants to show the URL back to a person has one.
276
+ *
277
+ * @param url - The repository URL or path, as configured.
278
+ */
279
+ canonicalize(url: string): CanonicalRepo {
280
+ const directory = localRepoPath(url);
281
+ if (directory === undefined) {
282
+ throw new ConfigError(
283
+ `'${url}' is not a local repository path that sous can read.\n` +
284
+ ` A local repository is named by an absolute path, or by the same path in ` +
285
+ `'file:///...' form. A relative path is not accepted, because a repository ` +
286
+ `entry is read from a config file that several working directories may run ` +
287
+ `against.`
288
+ );
289
+ }
290
+
291
+ return {
292
+ host: "localhost",
293
+ owner: path.dirname(directory),
294
+ name: path.basename(directory),
295
+ httpsUrl: directory,
296
+ sshUrl: pathToFileURL(directory).href,
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Reads the repository's index: the working tree's copy when there is one, so
302
+ * an index being authored right now is picked up, and the committed copy
303
+ * otherwise.
304
+ *
305
+ * @param repo - The canonicalized repository.
306
+ * @param options - Subprocess runner override.
307
+ */
308
+ async fetchIndex(
309
+ repo: CanonicalRepo,
310
+ options: ProviderOptions = {}
311
+ ): Promise<FetchedIndex> {
312
+ const directory = repoDirectory(repo);
313
+
314
+ if (!fs.existsSync(directory)) {
315
+ throw new ConfigError(
316
+ `There is no directory at ${directory}.\n` +
317
+ ` This project reads a repository from that path, and it is not there. ` +
318
+ `Either the checkout moved, or the repository entry names the wrong place.`
319
+ );
320
+ }
321
+
322
+ const working = path.join(directory, INDEX_FILENAME);
323
+ if (fs.existsSync(working)) {
324
+ return { text: await fsp.readFile(working, "utf8"), ref: "working tree" };
325
+ }
326
+
327
+ if (!(await isGitRepository(directory, options.run))) {
328
+ throw new ConfigError(
329
+ `The directory ${directory} publishes no sous index.\n` +
330
+ ` A repository publishes '${INDEX_FILENAME}' at its root, written by ` +
331
+ `'sous repo release'. This directory has none, and it is not a git ` +
332
+ `repository, so there is no committed copy to read either.`
333
+ );
334
+ }
335
+
336
+ const text = await runGit(
337
+ ["-C", directory, "show", `HEAD:${INDEX_FILENAME}`],
338
+ { ...(options.run === undefined ? {} : { run: options.run }) }
339
+ );
340
+ return { text, ref: "HEAD" };
341
+ }
342
+
343
+ /**
344
+ * Fetches one recipe folder at one tag. A git repository is cloned at the tag,
345
+ * exactly as a hosted repository would be, so a version really is the version
346
+ * the tag points at. A plain directory has no versions to honour, so its
347
+ * working tree is copied instead.
348
+ *
349
+ * @param repo - The canonicalized repository.
350
+ * @param recipePath - The recipe folder, relative to the repository root.
351
+ * @param tag - The git tag carrying the version.
352
+ * @param destDir - Where the recipe's files should end up.
353
+ * @param options - Subprocess runner override.
354
+ */
355
+ async fetchRecipeTree(
356
+ repo: CanonicalRepo,
357
+ recipePath: string,
358
+ tag: string,
359
+ destDir: string,
360
+ options: ProviderOptions = {}
361
+ ): Promise<void> {
362
+ const directory = repoDirectory(repo);
363
+
364
+ if (
365
+ (await isGitRepository(directory, options.run)) &&
366
+ (await hasTag(directory, tag, options.run))
367
+ ) {
368
+ await fetchSubtree({
369
+ cloneUrl: directory,
370
+ tag,
371
+ subPath: recipePath,
372
+ destDir,
373
+ ...(options.run === undefined ? {} : { run: options.run }),
374
+ });
375
+ return;
376
+ }
377
+
378
+ const source = path.join(directory, recipePath);
379
+ if (!fs.existsSync(source)) {
380
+ throw new ConfigError(
381
+ `The repository at ${directory} has no folder '${recipePath}'.\n` +
382
+ ` Its index says the recipe lives there, so either the index is out of date ` +
383
+ `or the folder has been moved.`
384
+ );
385
+ }
386
+
387
+ await fsp.rm(destDir, { recursive: true, force: true });
388
+ await fsp.mkdir(path.dirname(destDir), { recursive: true });
389
+ await fsp.cp(source, destDir, { recursive: true });
390
+ }
391
+ }