@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,233 @@
1
+ /**
2
+ * The one place the Repositories layer shells out.
3
+ *
4
+ * Every subprocess a provider runs (git itself, and the `gh` / `glab` CLIs when
5
+ * they are present and can hand over a token) goes through the helpers here, so
6
+ * failures are reported the same way everywhere: the command that was run, the
7
+ * exit code it returned, and whatever it wrote to stderr.
8
+ *
9
+ * The runner is injectable. Tests substitute their own, so no test in this
10
+ * layer ever spawns a process or touches the network.
11
+ */
12
+
13
+ import { spawn } from "node:child_process";
14
+ import fs from "node:fs/promises";
15
+ import path from "node:path";
16
+ import { ConfigError } from "../../errors.js";
17
+
18
+ /** What a finished subprocess reports back. */
19
+ export type CommandResult = {
20
+ /** The process exit code; 0 on success. */
21
+ code: number;
22
+ stdout: string;
23
+ stderr: string;
24
+ };
25
+
26
+ /** How a command is run. Tests replace this with a function of their own. */
27
+ export type CommandRunner = (
28
+ command: string,
29
+ args: string[],
30
+ options: { cwd?: string }
31
+ ) => Promise<CommandResult>;
32
+
33
+ /**
34
+ * The default runner: spawns the command, captures both streams, and resolves
35
+ * once it exits. A command that cannot be started at all (it is not installed)
36
+ * resolves with exit code 127, so callers treat it as an ordinary failure.
37
+ *
38
+ * @param command - The executable to run.
39
+ * @param args - Its arguments, already split.
40
+ * @param options - Optional working directory.
41
+ */
42
+ export const spawnCommand: CommandRunner = (command, args, options = {}) =>
43
+ new Promise<CommandResult>((resolve) => {
44
+ const child = spawn(command, args, {
45
+ cwd: options.cwd,
46
+ stdio: ["ignore", "pipe", "pipe"],
47
+ });
48
+
49
+ let stdout = "";
50
+ let stderr = "";
51
+ child.stdout?.on("data", (chunk: Buffer) => {
52
+ stdout += chunk.toString();
53
+ });
54
+ child.stderr?.on("data", (chunk: Buffer) => {
55
+ stderr += chunk.toString();
56
+ });
57
+
58
+ child.on("error", (error: Error) => {
59
+ resolve({ code: 127, stdout, stderr: `${stderr}${error.message}` });
60
+ });
61
+ child.on("close", (code) => {
62
+ resolve({ code: code ?? 1, stdout, stderr });
63
+ });
64
+ });
65
+
66
+ /** Options shared by every subprocess helper here. */
67
+ export type RunOptions = {
68
+ /** Directory to run in. */
69
+ cwd?: string;
70
+ /** The runner to use. Defaults to spawning a real process. */
71
+ run?: CommandRunner;
72
+ };
73
+
74
+ /** Renders a command and its arguments the way a user would have typed them. */
75
+ function describeCommand(command: string, args: string[]): string {
76
+ return [command, ...args].join(" ");
77
+ }
78
+
79
+ /**
80
+ * Runs `git` with the given arguments and returns its standard output, trimmed.
81
+ * A non-zero exit is a ConfigError naming the command, the exit code and the
82
+ * error output, because that is what a user needs in order to fix it.
83
+ *
84
+ * @param args - Arguments to pass to git.
85
+ * @param options - Working directory and an optional runner override.
86
+ */
87
+ export async function runGit(args: string[], options: RunOptions = {}): Promise<string> {
88
+ const run = options.run ?? spawnCommand;
89
+ const result = await run("git", args, { cwd: options.cwd });
90
+
91
+ if (result.code !== 0) {
92
+ const lines = [
93
+ `The command '${describeCommand("git", args)}' failed with exit code ${result.code}.`,
94
+ ];
95
+ if (options.cwd !== undefined) lines.push(` It was run in ${options.cwd}.`);
96
+ const detail = result.stderr.trim() || result.stdout.trim();
97
+ if (detail.length > 0) {
98
+ for (const line of detail.split("\n")) lines.push(` ${line}`);
99
+ }
100
+ if (result.code === 127) {
101
+ lines.push(
102
+ " Sous fetches recipes with git, so git must be installed and on your PATH."
103
+ );
104
+ }
105
+ throw new ConfigError(lines.join("\n"));
106
+ }
107
+
108
+ return result.stdout.trim();
109
+ }
110
+
111
+ /**
112
+ * Runs a command that sous can do without, returning its trimmed output or
113
+ * undefined when it is missing, unauthenticated, or otherwise unhappy. Used for
114
+ * the optional `gh auth token` and `glab auth token` lookups: a missing CLI is
115
+ * never a reason to fail.
116
+ *
117
+ * @param command - The executable to try.
118
+ * @param args - Its arguments.
119
+ * @param options - Working directory and an optional runner override.
120
+ */
121
+ export async function tryCommand(
122
+ command: string,
123
+ args: string[],
124
+ options: RunOptions = {}
125
+ ): Promise<string | undefined> {
126
+ const run = options.run ?? spawnCommand;
127
+ try {
128
+ const result = await run(command, args, { cwd: options.cwd });
129
+ if (result.code !== 0) return undefined;
130
+ const output = result.stdout.trim();
131
+ return output.length > 0 ? output : undefined;
132
+ } catch {
133
+ return undefined;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Fetches ONE subtree of a repository at one tag, and nothing else.
139
+ *
140
+ * A shallow, blobless, sparse checkout is what makes this cheap: git downloads
141
+ * the commit at the tag, then only the blobs inside the recipe folder. Sous
142
+ * never copies a whole repository in order to install one recipe.
143
+ *
144
+ * The temporary checkout is made beside the destination rather than in the
145
+ * system temporary directory, so moving the subtree into place is a rename on
146
+ * one filesystem; a cross-device move still works, it just copies.
147
+ *
148
+ * @param options - The clone URL, the tag, the subtree and where it should land.
149
+ */
150
+ export async function fetchSubtree(options: {
151
+ /** The repository's HTTPS clone URL. */
152
+ cloneUrl: string;
153
+ /** The git tag to fetch. */
154
+ tag: string;
155
+ /** The subtree's path, relative to the repository root. */
156
+ subPath: string;
157
+ /** Where the subtree's contents should end up. */
158
+ destDir: string;
159
+ /** How subprocesses are run. Defaults to spawning a real process. */
160
+ run?: CommandRunner;
161
+ }): Promise<void> {
162
+ const { cloneUrl, tag, subPath, destDir } = options;
163
+ const parent = path.dirname(destDir);
164
+ await fs.mkdir(parent, { recursive: true });
165
+
166
+ const workDir = await fs.mkdtemp(path.join(parent, ".sous-fetch-"));
167
+ const checkoutDir = path.join(workDir, "checkout");
168
+
169
+ try {
170
+ await runGit(
171
+ [
172
+ "clone",
173
+ "--depth",
174
+ "1",
175
+ "--filter=blob:none",
176
+ "--sparse",
177
+ "--branch",
178
+ tag,
179
+ // Everything after this is a path or a URL, never an option, whatever it
180
+ // starts with. `repoUrlSchema` already refuses a leading hyphen; this is
181
+ // the second lock on the same door, and it is what `git-clone.ts` does.
182
+ "--",
183
+ cloneUrl,
184
+ checkoutDir,
185
+ ],
186
+ { run: options.run }
187
+ );
188
+ await runGit(["sparse-checkout", "set", subPath], {
189
+ cwd: checkoutDir,
190
+ run: options.run,
191
+ });
192
+
193
+ const source = path.join(checkoutDir, subPath);
194
+ if (!(await isDirectory(source))) {
195
+ throw new ConfigError(
196
+ `The repository ${cloneUrl} has no folder '${subPath}' at the tag '${tag}'.\n` +
197
+ ` The repository's index says the recipe lives there, so either the index is ` +
198
+ `out of date or the tag points at the wrong commit.`
199
+ );
200
+ }
201
+
202
+ await fs.rm(destDir, { recursive: true, force: true });
203
+ await movePath(source, destDir);
204
+ } finally {
205
+ await fs.rm(workDir, { recursive: true, force: true });
206
+ }
207
+ }
208
+
209
+ /** True when the path exists and is a directory. */
210
+ async function isDirectory(candidate: string): Promise<boolean> {
211
+ try {
212
+ return (await fs.stat(candidate)).isDirectory();
213
+ } catch {
214
+ return false;
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Moves a directory, falling back to a recursive copy when the source and the
220
+ * destination live on different filesystems.
221
+ *
222
+ * @param source - The directory to move.
223
+ * @param destination - Where it should end up.
224
+ */
225
+ async function movePath(source: string, destination: string): Promise<void> {
226
+ try {
227
+ await fs.rename(source, destination);
228
+ } catch (error) {
229
+ if ((error as NodeJS.ErrnoException).code !== "EXDEV") throw error;
230
+ await fs.cp(source, destination, { recursive: true });
231
+ await fs.rm(source, { recursive: true, force: true });
232
+ }
233
+ }
@@ -0,0 +1,294 @@
1
+ /**
2
+ * The GitHub provider: the read path, and the write path behind it.
3
+ *
4
+ * Reading a repo needs no GitHub API and no `gh`: the index file is one raw
5
+ * HTTPS GET, and a recipe's subtree comes from git itself. A token is used when
6
+ * one is available, so private repositories work; it is read from GITHUB_TOKEN,
7
+ * or asked of the `gh` command line tool when that is installed and signed in.
8
+ * A missing `gh` is never an error on the read path.
9
+ *
10
+ * The write path is where `gh` becomes load-bearing, and this file is the ONLY
11
+ * place in sous that knows the command exists. Proposing a change is a pull
12
+ * request opened by `gh pr create`, a contributor without push permission works
13
+ * through a fork made by `gh repo fork`, and both are reported back as plain
14
+ * data, so the service that sequences them never learns a GitHub-shaped fact.
15
+ */
16
+
17
+ import { ConfigError } from "../../errors.js";
18
+ import { INDEX_FILENAME } from "../formats/common.js";
19
+ import { ProviderBase, firstUrlIn } from "./base.js";
20
+ import { fetchSubtree, type CommandRunner } from "./git.js";
21
+ import { fetchText, type FetchLike } from "./http.js";
22
+ import {
23
+ buildCanonicalRepo,
24
+ invalidRepoUrl,
25
+ splitRepoUrl,
26
+ type AuthStatus,
27
+ type CanonicalRepo,
28
+ type ChangeProposal,
29
+ type FetchedIndex,
30
+ type ForkedRepo,
31
+ type ProposedChange,
32
+ type ProviderCli,
33
+ type ProviderFeature,
34
+ type ProviderOptions,
35
+ } from "./provider.js";
36
+
37
+ /** The host this provider serves when a URL does not say otherwise. */
38
+ export const GITHUB_HOST = "github.com";
39
+
40
+ /** The environment variable a GitHub token is read from. */
41
+ export const GITHUB_TOKEN_ENV = "GITHUB_TOKEN";
42
+
43
+ /**
44
+ * Finds a GitHub token: the environment first, then `gh auth token` when the
45
+ * `gh` command line tool is installed and signed in. Returns undefined when
46
+ * there is none, because public repositories need no token at all.
47
+ *
48
+ * @param options - Environment and subprocess runner overrides.
49
+ */
50
+ export async function findGithubToken(options: {
51
+ env?: NodeJS.ProcessEnv;
52
+ run?: CommandRunner;
53
+ } = {}): Promise<string | undefined> {
54
+ return new GithubProvider().token(options);
55
+ }
56
+
57
+ /** The GitHub provider. */
58
+ export class GithubProvider extends ProviderBase {
59
+ readonly id = "github" as const;
60
+
61
+ /**
62
+ * Reads the index and recipe subtrees, and proposes a change through
63
+ * the GitHub CLI ('gh').
64
+ */
65
+ readonly features: ProviderFeature[] = ["fetch", "submit"];
66
+
67
+ /** The command line tool the write path is built on. */
68
+ readonly cli: ProviderCli = {
69
+ command: "gh",
70
+ label: "the GitHub CLI",
71
+ install: "https://cli.github.com",
72
+ };
73
+
74
+ /** What GitHub calls a proposal. */
75
+ readonly proposalNoun = "pull request";
76
+
77
+ matches(url: string): boolean {
78
+ const parts = splitRepoUrl(url);
79
+ return parts !== undefined && parts.host === GITHUB_HOST;
80
+ }
81
+
82
+ canonicalize(url: string): CanonicalRepo {
83
+ const parts = splitRepoUrl(url);
84
+ if (parts === undefined) throw invalidRepoUrl(this.id, url);
85
+ return buildCanonicalRepo(parts.host, parts.owner, parts.name);
86
+ }
87
+
88
+ /**
89
+ * A GitHub token, from the environment or from `gh`. Public, because the
90
+ * index cache and the exported `findGithubToken` both ask for one.
91
+ *
92
+ * @param options - Environment and subprocess runner overrides.
93
+ */
94
+ async token(options: ProviderOptions = {}): Promise<string | undefined> {
95
+ return this.findToken(GITHUB_TOKEN_ENV, ["auth", "token"], options);
96
+ }
97
+
98
+ /**
99
+ * Fetches the repo's index file from the raw content host at the repository's
100
+ * default branch, which is what `HEAD` names there.
101
+ *
102
+ * @param repo - The canonicalized repository.
103
+ * @param options - Environment, fetch and subprocess overrides.
104
+ */
105
+ async fetchIndex(
106
+ repo: CanonicalRepo,
107
+ options: ProviderOptions = {}
108
+ ): Promise<FetchedIndex> {
109
+ const token = await this.token(options);
110
+ const url = this.indexUrl(repo);
111
+ const fetched = await fetchText(url, {
112
+ ...(token === undefined ? {} : { token }),
113
+ ...(options.fetchImpl === undefined
114
+ ? {}
115
+ : { fetchImpl: options.fetchImpl as FetchLike }),
116
+ label: "repo index",
117
+ });
118
+
119
+ return fetched.etag === undefined
120
+ ? { text: fetched.text, ref: "HEAD" }
121
+ : { text: fetched.text, ref: "HEAD", etag: fetched.etag };
122
+ }
123
+
124
+ /**
125
+ * Fetches one recipe folder at one tag. Private repositories work through
126
+ * git's own credential helpers, the same way a manual clone would.
127
+ *
128
+ * @param repo - The canonicalized repository.
129
+ * @param recipePath - The recipe folder, relative to the repository root.
130
+ * @param tag - The git tag carrying the version.
131
+ * @param destDir - Where the recipe's files should end up.
132
+ * @param options - Subprocess runner override.
133
+ */
134
+ async fetchRecipeTree(
135
+ repo: CanonicalRepo,
136
+ recipePath: string,
137
+ tag: string,
138
+ destDir: string,
139
+ options: ProviderOptions = {}
140
+ ): Promise<void> {
141
+ await fetchSubtree({
142
+ cloneUrl: repo.httpsUrl,
143
+ tag,
144
+ subPath: recipePath,
145
+ destDir,
146
+ ...(options.run === undefined ? {} : { run: options.run }),
147
+ });
148
+ }
149
+
150
+ /**
151
+ * The raw URL of a repository's index file at its default branch.
152
+ *
153
+ * @param repo - The canonicalized repository.
154
+ */
155
+ indexUrl(repo: CanonicalRepo): string {
156
+ return `https://raw.githubusercontent.com/${repo.owner}/${repo.name}/HEAD/${INDEX_FILENAME}`;
157
+ }
158
+
159
+ // --- The write path --------------------------------------------------------
160
+
161
+ /**
162
+ * Whether `gh` is installed and signed in. The detail is the whole
163
+ * explanation, ready to print, because only this provider knows what to
164
+ * install and which command signs in.
165
+ *
166
+ * @param options - Subprocess runner and working directory overrides.
167
+ */
168
+ async authStatus(options: ProviderOptions = {}): Promise<AuthStatus> {
169
+ const ok = await this.commandSucceeds(this.cli.command, ["auth", "status"], options);
170
+ if (ok) {
171
+ return {
172
+ ok: true,
173
+ detail: `${this.cli.label} ('${this.cli.command}') is installed and signed in.`,
174
+ };
175
+ }
176
+ return {
177
+ ok: false,
178
+ detail:
179
+ `Sous proposes a change through ${this.cli.label} ('${this.cli.command}'), and it is ` +
180
+ `either not installed or not signed in.\n` +
181
+ ` Install it from ${this.cli.install}, then run '${this.cli.command} auth login'.`,
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Whether the signed-in contributor may push to the repository itself, as
187
+ * GitHub reports it. Undefined when `gh` could not answer at all, which is
188
+ * not the same as a refusal.
189
+ *
190
+ * @param repo - The canonicalized repository.
191
+ * @param options - Subprocess runner and working directory overrides.
192
+ */
193
+ async canPush(
194
+ repo: CanonicalRepo,
195
+ options: ProviderOptions = {}
196
+ ): Promise<boolean | undefined> {
197
+ const answer = await this.capturedOutput(
198
+ this.cli.command,
199
+ ["api", `repos/${repo.owner}/${repo.name}`, "--jq", ".permissions.push"],
200
+ options
201
+ );
202
+ if (answer === undefined) return undefined;
203
+ const value = answer.trim();
204
+ if (value === "true") return true;
205
+ if (value === "false") return false;
206
+ return undefined;
207
+ }
208
+
209
+ /**
210
+ * Forks the repository onto the contributor's own account and says where the
211
+ * fork landed. No remote is added here; that is git's business, and the
212
+ * service above does it with the URLs returned.
213
+ *
214
+ * @param repo - The canonicalized repository.
215
+ * @param options - Subprocess runner and working directory overrides.
216
+ */
217
+ async fork(repo: CanonicalRepo, options: ProviderOptions = {}): Promise<ForkedRepo> {
218
+ const forked = await this.runCommand(
219
+ this.cli.command,
220
+ ["repo", "fork", `${repo.owner}/${repo.name}`, "--remote=false"],
221
+ options
222
+ );
223
+ if (forked.code !== 0) {
224
+ throw new ConfigError(
225
+ `'${this.cli.command} repo fork' did not succeed.\n ` +
226
+ `${forked.stderr.trim() || forked.stdout.trim()}`
227
+ );
228
+ }
229
+
230
+ const who = await this.capturedOutput(
231
+ this.cli.command,
232
+ ["api", "user", "--jq", ".login"],
233
+ options
234
+ );
235
+ if (who === undefined) {
236
+ throw new ConfigError(
237
+ "The fork was requested, but sous could not read your GitHub login from " +
238
+ `'${this.cli.command} api user', so it does not know where the fork lives.`
239
+ );
240
+ }
241
+
242
+ const owner = who.trim();
243
+ return {
244
+ owner,
245
+ name: repo.name,
246
+ httpsUrl: `https://${repo.host}/${owner}/${repo.name}.git`,
247
+ sshUrl: `git@${repo.host}:${owner}/${repo.name}.git`,
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Opens a pull request for a branch that has already been pushed. A proposal
253
+ * carrying a head owner came from a fork, which is what a cross-repository
254
+ * pull request spells as `owner:branch`.
255
+ *
256
+ * @param repo - The canonicalized repository the proposal targets.
257
+ * @param proposal - The branch, the text and whether it is a draft.
258
+ * @param options - Subprocess runner and working directory overrides.
259
+ */
260
+ async proposeChange(
261
+ repo: CanonicalRepo,
262
+ proposal: ChangeProposal,
263
+ options: ProviderOptions = {}
264
+ ): Promise<ProposedChange> {
265
+ const head =
266
+ proposal.head === undefined
267
+ ? proposal.branch
268
+ : `${proposal.head.owner}:${proposal.branch}`;
269
+
270
+ const args = ["pr", "create", "--repo", `${repo.owner}/${repo.name}`];
271
+ if (proposal.base !== undefined) args.push("--base", proposal.base);
272
+ args.push("--head", head, "--title", proposal.title, "--body", proposal.body);
273
+ if (proposal.draft) args.push("--draft");
274
+
275
+ const result = await this.runCommand(this.cli.command, args, options);
276
+ if (result.code !== 0) {
277
+ const reported = result.stderr.trim() || result.stdout.trim();
278
+ throw new ConfigError(
279
+ `'${this.cli.command} pr create' did not succeed, so no proposal was opened.` +
280
+ (reported.length === 0 ? "" : `\n ${reported}`)
281
+ );
282
+ }
283
+
284
+ const url = firstUrlIn(result.stdout);
285
+ if (url === undefined) {
286
+ return {
287
+ detail:
288
+ `The ${this.proposalNoun} was opened, but '${this.cli.command}' printed no address ` +
289
+ `for it.`,
290
+ };
291
+ }
292
+ return { url, detail: `The ${this.proposalNoun} is at ${url}.` };
293
+ }
294
+ }