@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,496 @@
1
+ /**
2
+ * Proposing a change to a recipe repository: validate first, then delegate.
3
+ *
4
+ * `submit` universally means "propose a change for maintainers to review". It
5
+ * never publishes and never writes to a repository directly; the fork and
6
+ * proposal mechanics belong to the provider, which knows its own host and
7
+ * already has the contributor's credentials through that host's command line
8
+ * tool.
9
+ *
10
+ * This module is a SEQUENCER and nothing more. It knows the order the steps go
11
+ * in, what each one is called, and what to say when one fails; it does not know
12
+ * that GitHub exists, which tool proposes a change, or how a fork is spelled.
13
+ * Every host-specific fact is asked of the provider interface and comes back as
14
+ * plain data, which is what keeps a third provider a single new file.
15
+ *
16
+ * Two rules shape everything here:
17
+ *
18
+ * - Nothing is sent until the repository validates and its index is current. A
19
+ * proposal that fails the maintainer's own checks wastes their review.
20
+ * - Every step announces itself BEFORE it runs, and a failure says exactly which
21
+ * steps completed. A half-finished submission (a branch pushed, no proposal
22
+ * opened) is a normal outcome of a network failure, and the contributor has to
23
+ * be told the truth about it.
24
+ *
25
+ * Every subprocess goes through the injectable runner, so no test here reaches
26
+ * a network.
27
+ */
28
+
29
+ import { ConfigError } from "../../errors.js";
30
+ import { runGit, type CommandRunner } from "../providers/git.js";
31
+ import { detectProvider } from "../providers/index.js";
32
+ import {
33
+ supportsSubmit,
34
+ type CanonicalRepo,
35
+ type ProviderOptions,
36
+ type RepoProvider,
37
+ type SubmitCapableProvider,
38
+ } from "../providers/provider.js";
39
+ import {
40
+ buildIndex,
41
+ describeIndexDrift,
42
+ readIndexFile,
43
+ type IndexBuildResult,
44
+ } from "./index-builder.js";
45
+ import {
46
+ currentBranch,
47
+ createBranch,
48
+ defaultBranch,
49
+ lastCommitSubject,
50
+ pushBranch,
51
+ remoteUrl,
52
+ submitBranchName,
53
+ uncommittedChanges,
54
+ } from "./git-state.js";
55
+ import {
56
+ errorsIn,
57
+ hasErrors,
58
+ validateRepo,
59
+ type RepoValidation,
60
+ type ValidationProblem,
61
+ } from "./validate.js";
62
+
63
+ /** The remote a repository is contributed back to. */
64
+ const UPSTREAM_REMOTE = "origin";
65
+
66
+ /** The remote name sous gives a fork it created. */
67
+ const FORK_REMOTE = "fork";
68
+
69
+ /** What a proposal is called when the provider does not name it. */
70
+ const DEFAULT_PROPOSAL_NOUN = "proposal";
71
+
72
+ /** What `submitRepo` needs to know. */
73
+ export type SubmitOptions = {
74
+ /** The repository's root directory. */
75
+ rootDir: string;
76
+ /** The title for the proposal. Defaults to the last commit's subject. */
77
+ title?: string;
78
+ /** The body for the proposal. Defaults to a summary sous writes. */
79
+ body?: string;
80
+ /** Whether to open the proposal as a draft. */
81
+ draft?: boolean;
82
+ /** When true, everything is checked and reported and nothing is sent. */
83
+ dryRun?: boolean;
84
+ /** The version of sous, recorded when the index is regenerated for the check. */
85
+ sousVersion: string;
86
+ /** When the submission is happening; decides the branch name. Defaults to now. */
87
+ now?: Date;
88
+ /** How subprocesses are run. Defaults to spawning a real process. */
89
+ run?: CommandRunner;
90
+ /** The providers to consider. Defaults to the built-in list. */
91
+ providers?: RepoProvider[];
92
+ /** Called with each step, BEFORE it runs. */
93
+ onStep?: (message: string) => void;
94
+ /** Called with anything worth saying that is not a step. */
95
+ onNotice?: (message: string) => void;
96
+ };
97
+
98
+ /** What a submission did. */
99
+ export type SubmitResult = {
100
+ /** The provider the proposal went to. */
101
+ provider: string;
102
+ /** The repository, as the provider understands it. */
103
+ repo: CanonicalRepo;
104
+ /** The branch the change is on. */
105
+ branch: string;
106
+ /** The branch the proposal targets. */
107
+ baseBranch: string;
108
+ /** True when the change was pushed to a fork rather than to the repository itself. */
109
+ usedFork: boolean;
110
+ /** The remote the branch was pushed to. */
111
+ pushedTo: string;
112
+ /** The proposal's title. */
113
+ title: string;
114
+ /** The proposal's URL, when the provider reported one. */
115
+ url?: string;
116
+ /** Every step that completed, in order. */
117
+ completed: string[];
118
+ /** True when nothing was actually sent. */
119
+ dryRun: boolean;
120
+ };
121
+
122
+ /**
123
+ * Validates a repository and proposes its committed changes upstream.
124
+ *
125
+ * @param options - The repository, the proposal's text, and the testing seams.
126
+ */
127
+ export async function submitRepo(options: SubmitOptions): Promise<SubmitResult> {
128
+ const {
129
+ rootDir,
130
+ sousVersion,
131
+ draft = false,
132
+ dryRun = false,
133
+ now = new Date(),
134
+ run,
135
+ } = options;
136
+ const step = options.onStep ?? (() => {});
137
+ const notice = options.onNotice ?? (() => {});
138
+ const completed: string[] = [];
139
+
140
+ /** Runs one step, announcing it first and recording it once it succeeds. */
141
+ const doStep = async <T>(message: string, action: () => Promise<T>): Promise<T> => {
142
+ step(message);
143
+ const result = await action().catch((error: unknown) => {
144
+ throw partialStateError(message, completed, error);
145
+ });
146
+ completed.push(message);
147
+ return result;
148
+ };
149
+
150
+ // --- Preflight: is this a repository sous can propose a change to? --------
151
+ //
152
+ // The cheap, actionable checks come first. A repository with an uncommitted
153
+ // file is the commonest reason a submission stops, and saying so is far more
154
+ // useful than a content hash disagreeing because of that same uncommitted
155
+ // file. The manifests are read this early only for the contribution
156
+ // pointer; what the recipes say is only judged once the ground is firm.
157
+
158
+ step("Reading the repository manifest and every recipe in it");
159
+ const validation = validateRepo(rootDir);
160
+ completed.push("Read the repository manifest and every recipe in it");
161
+
162
+ step("Looking up where this repository was cloned from");
163
+ const upstreamUrl = await remoteUrl(rootDir, UPSTREAM_REMOTE, { run });
164
+ if (upstreamUrl === undefined) {
165
+ throw new ConfigError(
166
+ `This repository has no '${UPSTREAM_REMOTE}' remote, so sous cannot tell where to ` +
167
+ `propose the change.\n` +
168
+ ` Add one with 'git remote add ${UPSTREAM_REMOTE} <url>', then run the command again.`
169
+ );
170
+ }
171
+ completed.push("Looked up where this repository was cloned from");
172
+
173
+ const provider = requireSubmitProvider(upstreamUrl, validation, options.providers);
174
+ const repo = provider.canonicalize(upstreamUrl);
175
+
176
+ // Everything the provider runs, it runs inside the contributor's checkout.
177
+ const providerOptions: ProviderOptions = { cwd: rootDir, run };
178
+
179
+ const signInLabel = provider.cli?.label ?? "the repository host's command line tool";
180
+ step(`Checking that ${signInLabel} is installed and signed in`);
181
+ const auth = await provider.authStatus(providerOptions);
182
+ if (!auth.ok) {
183
+ throw new ConfigError(`${auth.detail}\n` + contributePointer(validation));
184
+ }
185
+ completed.push(`Checked that ${signInLabel} is installed and signed in`);
186
+
187
+ step("Checking that everything is committed");
188
+ const changed = await uncommittedChanges(rootDir, { run });
189
+ if (changed.length > 0) {
190
+ const listed = changed.map((entry) => ` ${entry.path}`).join("\n");
191
+ throw new ConfigError(
192
+ "Cannot propose a change while the working tree has uncommitted changes.\n\n" +
193
+ `${listed}\n\n` +
194
+ " A proposal is made of commits, so everything it should carry has to be " +
195
+ "committed first.\n" +
196
+ " Sous does not commit for you: commit these, then run the command again."
197
+ );
198
+ }
199
+ completed.push("Checked that everything is committed");
200
+
201
+ // --- Validate what is about to be proposed --------------------------------
202
+
203
+ step("Checking that every recipe describes itself correctly");
204
+ assertRepoValidates(validation);
205
+ completed.push("Checked that every recipe describes itself correctly");
206
+
207
+ step("Confirming the committed index is current");
208
+ const built = await buildIndex({
209
+ validation,
210
+ existing: readIndexFile(rootDir),
211
+ sousVersion,
212
+ run,
213
+ });
214
+ assertIndexReady(built, readIndexFile(rootDir));
215
+ completed.push("Confirmed the committed index is current");
216
+
217
+ // --- The branch the change lives on ---------------------------------------
218
+
219
+ const baseBranch = (await defaultBranch(rootDir, { run })) ?? "main";
220
+ const checkedOut = await currentBranch(rootDir, { run });
221
+ let branch = checkedOut;
222
+
223
+ if (checkedOut === undefined || checkedOut === baseBranch) {
224
+ branch = submitBranchName(now);
225
+ if (dryRun) {
226
+ notice(`A branch named '${branch}' would be created from the current commit.`);
227
+ } else {
228
+ await doStep(`Creating the branch '${branch}' from the current commit`, () =>
229
+ createBranch(rootDir, branch!, { run })
230
+ );
231
+ }
232
+ }
233
+
234
+ const title =
235
+ options.title ?? (await lastCommitSubject(rootDir, { run })) ?? defaultTitle(validation);
236
+ const body = options.body ?? defaultBody(built, validation);
237
+
238
+ // --- Fork, push, propose --------------------------------------------------
239
+
240
+ let usedFork = false;
241
+ let pushRemote = UPSTREAM_REMOTE;
242
+ let forkOwner: string | undefined;
243
+
244
+ step("Checking whether you can push to the repository itself");
245
+ const canPush = await provider.canPush(repo, providerOptions);
246
+ completed.push("Checked whether you can push to the repository itself");
247
+
248
+ if (canPush === undefined) {
249
+ notice(
250
+ `Sous could not tell whether you can push to ${repo.owner}/${repo.name}, so the change ` +
251
+ `goes to '${UPSTREAM_REMOTE}' as it stands.`
252
+ );
253
+ } else if (!canPush) {
254
+ usedFork = true;
255
+ pushRemote = FORK_REMOTE;
256
+ if (dryRun) {
257
+ notice(
258
+ `You cannot push to ${repo.owner}/${repo.name}, so the change would go through ` +
259
+ `a fork on your own account.`
260
+ );
261
+ } else {
262
+ forkOwner = await prepareFork(
263
+ rootDir,
264
+ repo,
265
+ provider,
266
+ providerOptions,
267
+ run,
268
+ doStep
269
+ );
270
+ }
271
+ }
272
+
273
+ if (dryRun) {
274
+ notice("Nothing was sent; this was a dry run.");
275
+ return {
276
+ provider: provider.id,
277
+ repo,
278
+ branch: branch!,
279
+ baseBranch,
280
+ usedFork,
281
+ pushedTo: pushRemote,
282
+ title,
283
+ completed,
284
+ dryRun: true,
285
+ };
286
+ }
287
+
288
+ await doStep(`Pushing '${branch}' to '${pushRemote}'`, () =>
289
+ pushBranch(rootDir, pushRemote, branch!, { run })
290
+ );
291
+
292
+ const proposalNoun = provider.proposalNoun ?? DEFAULT_PROPOSAL_NOUN;
293
+ const proposed = await doStep(`Opening a ${proposalNoun} for review`, () =>
294
+ provider.proposeChange(
295
+ repo,
296
+ {
297
+ branch: branch!,
298
+ base: baseBranch,
299
+ title,
300
+ body,
301
+ draft,
302
+ ...(forkOwner === undefined ? {} : { head: { owner: forkOwner } }),
303
+ },
304
+ providerOptions
305
+ )
306
+ );
307
+ if (proposed.url === undefined) notice(proposed.detail);
308
+
309
+ return {
310
+ provider: provider.id,
311
+ repo,
312
+ branch: branch!,
313
+ baseBranch,
314
+ usedFork,
315
+ pushedTo: pushRemote,
316
+ title,
317
+ ...(proposed.url === undefined ? {} : { url: proposed.url }),
318
+ completed,
319
+ dryRun: false,
320
+ };
321
+ }
322
+
323
+ // --- Preflight helpers --------------------------------------------------------------------------
324
+
325
+ /** Refuses to submit a repository that does not describe itself correctly. */
326
+ function assertRepoValidates(validation: RepoValidation): void {
327
+ if (!hasErrors(validation.problems)) return;
328
+ throw new ConfigError(
329
+ "This repository does not validate, so there is nothing worth proposing yet:\n\n" +
330
+ renderProblems(errorsIn(validation.problems)) +
331
+ "\n\n Fix these, then run the command again."
332
+ );
333
+ }
334
+
335
+ /** Refuses to submit while the index disagrees with what the repository publishes. */
336
+ function assertIndexReady(
337
+ built: IndexBuildResult,
338
+ existing: ReturnType<typeof readIndexFile>
339
+ ): void {
340
+ if (hasErrors(built.problems)) {
341
+ throw new ConfigError(
342
+ "This repository's index and its tags do not agree, so there is nothing worth " +
343
+ "proposing yet:\n\n" +
344
+ renderProblems(errorsIn(built.problems)) +
345
+ "\n\n Fix these, then run the command again."
346
+ );
347
+ }
348
+
349
+ if (built.stale) {
350
+ const drift = describeIndexDrift(existing, built.index)
351
+ .map((line) => ` ${line}`)
352
+ .join("\n");
353
+ throw new ConfigError(
354
+ "The committed index is out of date, and a maintainer's own checks would reject " +
355
+ "the proposal:\n\n" +
356
+ `${drift}\n\n` +
357
+ " Run 'sous repo release', commit the regenerated index, then run this command " +
358
+ "again."
359
+ );
360
+ }
361
+ }
362
+
363
+ /** Renders a list of problems as an indented block. */
364
+ function renderProblems(problems: ReadonlyArray<ValidationProblem>): string {
365
+ return problems.map((problem) => ` ${problem.where}: ${problem.message}`).join("\n");
366
+ }
367
+
368
+ /**
369
+ * The provider that will carry the proposal, or a ConfigError pointing the
370
+ * contributor at whatever route the repository documents instead. The feature
371
+ * list is the only thing consulted: a provider that does not promise `submit`
372
+ * is not asked to, whatever host it serves.
373
+ */
374
+ function requireSubmitProvider(
375
+ upstreamUrl: string,
376
+ validation: RepoValidation,
377
+ providers?: RepoProvider[]
378
+ ): SubmitCapableProvider {
379
+ const provider =
380
+ providers === undefined ? detectProvider(upstreamUrl) : detectProvider(upstreamUrl, providers);
381
+
382
+ if (provider === undefined) {
383
+ throw new ConfigError(
384
+ `Sous does not know how to propose a change to ${upstreamUrl}.\n` +
385
+ contributePointer(validation)
386
+ );
387
+ }
388
+ if (!supportsSubmit(provider)) {
389
+ throw new ConfigError(
390
+ `The '${provider.id}' provider cannot propose a change on your behalf.\n` +
391
+ contributePointer(validation)
392
+ );
393
+ }
394
+ return provider;
395
+ }
396
+
397
+ /** The repository's own contribution instructions, when its manifest carries any. */
398
+ function contributePointer(validation: RepoValidation): string {
399
+ const contribute = validation.manifest.contribute;
400
+ if (contribute === undefined) {
401
+ return (
402
+ ` This repository's manifest does not say where to send a change, so send it the ` +
403
+ `way its maintainers prefer.`
404
+ );
405
+ }
406
+ return ` This repository asks that changes be sent this way:\n ${contribute}`;
407
+ }
408
+
409
+ // --- Delegation helpers -------------------------------------------------------------------------
410
+
411
+ /**
412
+ * Asks the provider to fork the repository onto the contributor's own account,
413
+ * then makes sure a git remote points at whatever came back. The fork itself is
414
+ * the provider's business; the remote is git's, and therefore sous's.
415
+ *
416
+ * Returns the account the fork lives under, which is what a cross-repository
417
+ * proposal needs.
418
+ */
419
+ async function prepareFork(
420
+ rootDir: string,
421
+ repo: CanonicalRepo,
422
+ provider: SubmitCapableProvider,
423
+ providerOptions: ProviderOptions,
424
+ run: CommandRunner | undefined,
425
+ doStep: <T>(message: string, action: () => Promise<T>) => Promise<T>
426
+ ): Promise<string> {
427
+ const fork = await doStep(`Forking ${repo.owner}/${repo.name} onto your own account`, () =>
428
+ provider.fork(repo, providerOptions)
429
+ );
430
+
431
+ const existing = await remoteUrl(rootDir, FORK_REMOTE, { run });
432
+ if (existing === undefined) {
433
+ await doStep(`Adding the remote '${FORK_REMOTE}' for ${fork.owner}/${fork.name}`, async () => {
434
+ try {
435
+ await runGit(["remote", "add", FORK_REMOTE, fork.httpsUrl], { cwd: rootDir, run });
436
+ } catch (error) {
437
+ throw new ConfigError(
438
+ `Could not add the remote '${FORK_REMOTE}'.\n ` +
439
+ `${error instanceof Error ? error.message : String(error)}`
440
+ );
441
+ }
442
+ });
443
+ }
444
+
445
+ return fork.owner;
446
+ }
447
+
448
+ /** The title used when there is no commit subject to borrow. */
449
+ function defaultTitle(validation: RepoValidation): string {
450
+ return `Update the ${validation.manifest.name} recipes`;
451
+ }
452
+
453
+ /** The body sous writes when the contributor did not supply one. */
454
+ function defaultBody(built: IndexBuildResult, validation: RepoValidation): string {
455
+ const lines = [
456
+ `Proposed with 'sous repo submit' from the ${validation.manifest.name} repository.`,
457
+ "",
458
+ "Recipes in this repository:",
459
+ ];
460
+ for (const recipe of validation.recipes) {
461
+ lines.push(`- ${recipe.key} at version ${recipe.manifest.version}`);
462
+ }
463
+ if (built.pending.length > 0) {
464
+ lines.push("");
465
+ lines.push("Versions this proposal would publish once it is merged and tagged:");
466
+ for (const entry of built.pending) {
467
+ lines.push(`- ${entry.key} ${entry.version}`);
468
+ }
469
+ }
470
+ return lines.join("\n");
471
+ }
472
+
473
+ /**
474
+ * Turns a mid-flight failure into an error that says what already happened.
475
+ * A pushed branch with no proposal behind it is a state the contributor has to
476
+ * know about; silence would leave them guessing.
477
+ */
478
+ function partialStateError(
479
+ failedStep: string,
480
+ completed: ReadonlyArray<string>,
481
+ error: unknown
482
+ ): ConfigError {
483
+ const done =
484
+ completed.length === 0
485
+ ? " nothing had been sent yet"
486
+ : completed.map((entry) => ` ${entry}`).join("\n");
487
+
488
+ return new ConfigError(
489
+ `${failedStep}: this step failed.\n\n` +
490
+ ` ${error instanceof Error ? error.message : String(error)}\n\n` +
491
+ ` What had already been done:\n${done}\n\n` +
492
+ ` Nothing after that step ran. Fix the problem above and run the command again; ` +
493
+ `sous starts from where the repository actually is, so a step that already ` +
494
+ `succeeded is not repeated.`
495
+ );
496
+ }