@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,619 @@
1
+ import fs from "node:fs";
2
+ import { Command, Flags } from "@oclif/core";
3
+ import { ConfigError, isConfigError, SOUS_VERSION } from "../../lib/settings.js";
4
+ import { INDEX_FILENAME } from "../../lib/repos/formats/common.js";
5
+ import { isInteractive, nonInteractiveError } from "../../lib/interactive.js";
6
+ import { askYesNo } from "../../utils/prompts.js";
7
+ import { reportCommandError } from "../../utils/command-errors.js";
8
+ import { confirmationFlag, nonInteractiveFlag } from "../../utils/flags.js";
9
+ import {
10
+ BUMP_LEVELS,
11
+ anythingToCommit,
12
+ buildIndex,
13
+ buildReleasePlan,
14
+ bumpRecipeVersion,
15
+ commitPaths,
16
+ createAnnotatedTag,
17
+ currentBranch,
18
+ defaultBranch,
19
+ describeIndexDrift,
20
+ describeScope,
21
+ errorsIn,
22
+ findRepoRoot,
23
+ hasCommitIdentity,
24
+ hasErrors,
25
+ indexFilePath,
26
+ pushBranch,
27
+ pushTags,
28
+ readIndexFile,
29
+ releaseScope,
30
+ remoteUrl,
31
+ scopeProblems,
32
+ uncommittedChanges,
33
+ validateRepo,
34
+ warningsIn,
35
+ type BumpLevel,
36
+ type IndexBuildResult,
37
+ type ReleasePlan,
38
+ type ReleaseScope,
39
+ type RepoValidation,
40
+ type ValidationProblem,
41
+ } from "../../lib/repos/release/index.js";
42
+ import {
43
+ blankLine,
44
+ displayErrorBlock,
45
+ dryRunNotice,
46
+ footer,
47
+ header,
48
+ log,
49
+ section,
50
+ showCommandVars,
51
+ showVariables,
52
+ warning,
53
+ } from "../../utils/formatting.js";
54
+
55
+ /** The remote a release pushes to. Recipe repositories have exactly one. */
56
+ const RELEASE_REMOTE = "origin";
57
+
58
+ /**
59
+ * `sous repo release` publishes new versions of the recipes in a repository.
60
+ *
61
+ * Like `sous repo init`, this command does NOT extend BaseCommand: it runs
62
+ * inside a RECIPE repository, which is not a sous project and has no `.sous/`
63
+ * directory of its own, so there is no project config to discover.
64
+ *
65
+ * One run does the whole job, in one order, after showing what it will do and
66
+ * asking once:
67
+ *
68
+ * 1. Validate the repository.
69
+ * 2. For every recipe in scope whose files have changed since the tag that
70
+ * last published it, raise the version (a patch step unless `--bump` says
71
+ * otherwise).
72
+ * 3. Regenerate the index, with each new version's dependencies resolved to
73
+ * the exact versions it is being released against.
74
+ * 4. Commit the manifests and the index together.
75
+ * 5. Tag that commit, dependency-first, with an annotated tag per version.
76
+ * 6. Push, but only when asked to.
77
+ *
78
+ * Two presets sit on top of it. `--check` is read-only and is what a pull
79
+ * request runs; `--ci` is the non-interactive form a merge runs, which bumps
80
+ * nothing because the version was raised in the change being merged.
81
+ */
82
+ export default class RepoRelease extends Command {
83
+ static description =
84
+ "Publish new versions of this repository's recipes: bump, regenerate the index, commit and tag";
85
+
86
+ /**
87
+ * The other spelling of the topic. It lives under a hidden topic, so it is
88
+ * typable everywhere without ever reaching the top-level listing.
89
+ */
90
+ static aliases = ["repos:release"];
91
+
92
+ static examples = [
93
+ "<%= config.bin %> repo release",
94
+ "<%= config.bin %> repo release --dry-run",
95
+ "<%= config.bin %> repo release --namespace workflow --bump minor",
96
+ "<%= config.bin %> repo release --recipe workflow/task-files --yes --push",
97
+ "<%= config.bin %> repo release --check",
98
+ "<%= config.bin %> repo release --ci --push",
99
+ ];
100
+
101
+ static flags = {
102
+ check: Flags.boolean({
103
+ description:
104
+ "Only check: validate, and fail when the committed index is out of date",
105
+ default: false,
106
+ }),
107
+ ci: Flags.boolean({
108
+ description:
109
+ "Run the way a merge does: never bump, never ask, and fail on anything unbumped",
110
+ default: false,
111
+ }),
112
+ namespace: Flags.string({
113
+ description: "Release only this namespace. Repeat to name several",
114
+ multiple: true,
115
+ }),
116
+ recipe: Flags.string({
117
+ description: "Release only this recipe, as 'namespace/name'. Repeat to name several",
118
+ multiple: true,
119
+ }),
120
+ bump: Flags.string({
121
+ description: "How far to raise a changed recipe's version. Defaults to a patch step",
122
+ options: [...BUMP_LEVELS],
123
+ }),
124
+ "no-bump": Flags.boolean({
125
+ description: "Raise no versions; a changed recipe that was never raised is an error",
126
+ default: false,
127
+ }),
128
+ "include-unchanged": Flags.boolean({
129
+ description: "Release every recipe in scope, whether its files changed or not",
130
+ default: false,
131
+ }),
132
+ tag: Flags.boolean({
133
+ description: "Cut the tags even on a branch other than the default one",
134
+ default: false,
135
+ }),
136
+ push: Flags.boolean({
137
+ description: "Push the commit, and the tags this run created, to the remote",
138
+ default: false,
139
+ }),
140
+ yes: confirmationFlag(),
141
+ "dry-run": Flags.boolean({
142
+ description: "Print the plan and stop, changing nothing",
143
+ default: false,
144
+ }),
145
+ // This command does not extend BaseCommand, so it declares the global
146
+ // non-interactive flag itself; the rule is the same everywhere.
147
+ "non-interactive": nonInteractiveFlag(),
148
+ };
149
+
150
+ async init(): Promise<void> {
151
+ await super.init();
152
+ header();
153
+ }
154
+
155
+ async run(): Promise<void> {
156
+ const { flags } = await this.parse(RepoRelease);
157
+ const dryRun = flags["dry-run"];
158
+ const ci = flags.ci;
159
+ // The CI preset is exactly two settings: never raise a version, and never
160
+ // ask. It deliberately does NOT imply --push; the workflow passes that
161
+ // itself, so what gets pushed is visible in the workflow file.
162
+ const noBump = flags["no-bump"] || ci;
163
+ const interactive = ci ? false : isInteractive();
164
+
165
+ assertFlagsAgree({ ...flags, noBump });
166
+
167
+ const rootDir = findRepoRoot(process.cwd());
168
+ const scope = releaseScope(flags.namespace ?? [], flags.recipe ?? []);
169
+
170
+ showCommandVars({
171
+ Repository: rootDir,
172
+ Mode: describeMode(flags),
173
+ Scope: describeScope(scope),
174
+ ...(dryRun ? { "Dry Run": true } : {}),
175
+ });
176
+
177
+ // --- Validate ---------------------------------------------------------
178
+
179
+ section("Checking the repository");
180
+ let validation = validateRepo(rootDir);
181
+ const scoping = scopeProblems(validation, scope);
182
+ reportProblems([...validation.problems, ...scoping]);
183
+ if (hasErrors([...validation.problems, ...scoping])) {
184
+ return this.stopForErrors([...validation.problems, ...scoping]);
185
+ }
186
+ log(` Read ${describeCount(validation.recipes.length, "recipe")}.`);
187
+
188
+ if (flags.check) return await this.runCheck(rootDir, validation);
189
+
190
+ // --- Plan -------------------------------------------------------------
191
+
192
+ const branch = await currentBranch(rootDir);
193
+ const mainBranch = await defaultBranch(rootDir);
194
+ const onDefaultBranch =
195
+ mainBranch === undefined || branch === undefined || branch === mainBranch;
196
+ const willTag = onDefaultBranch || flags.tag;
197
+
198
+ const plan = await buildReleasePlan({
199
+ validation,
200
+ scope,
201
+ ...(flags.bump === undefined ? {} : { bump: flags.bump as BumpLevel }),
202
+ noBump,
203
+ includeUnchanged: flags["include-unchanged"],
204
+ });
205
+
206
+ reportProblems(plan.problems);
207
+ if (hasErrors(plan.problems)) return this.stopForErrors(plan.problems);
208
+
209
+ this.reportPlan(plan, { willTag, onDefaultBranch, branch, push: flags.push });
210
+
211
+ if (plan.releases.length === 0) {
212
+ footer();
213
+ return;
214
+ }
215
+
216
+ if (dryRun) {
217
+ blankLine();
218
+ dryRunNotice("Nothing was written; this was a dry run.");
219
+ footer();
220
+ return;
221
+ }
222
+
223
+ // --- Confirm ----------------------------------------------------------
224
+
225
+ if (!flags.yes) {
226
+ if (!interactive) {
227
+ throw nonInteractiveError({
228
+ prompt: "whether to publish the versions listed above",
229
+ remedy:
230
+ "pass '--yes' (spelled '-y' or '--force' if you prefer) to accept the plan " +
231
+ "above without being asked.",
232
+ });
233
+ }
234
+ blankLine();
235
+ const accepted = await askYesNo("Publish these versions?");
236
+ if (!accepted) {
237
+ blankLine();
238
+ log(" Nothing was written.");
239
+ footer();
240
+ return;
241
+ }
242
+ }
243
+
244
+ // --- Carry it out -----------------------------------------------------
245
+
246
+ await this.assertNothingUncommitted(rootDir);
247
+ await this.assertCommitIdentity(rootDir);
248
+
249
+ section("Publishing");
250
+
251
+ const changedPaths: string[] = [];
252
+ for (const release of plan.releases) {
253
+ if (release.bump === undefined) {
254
+ log(` ${release.key}: publishing version ${release.to}.`);
255
+ continue;
256
+ }
257
+ const bumped = bumpRecipeVersion(release.manifestPath, release.bump);
258
+ log(` ${release.key}: ${bumped.from} becomes ${bumped.to}.`);
259
+ changedPaths.push(release.manifestPath);
260
+ }
261
+
262
+ // The manifests changed, so the repository is read again; everything after
263
+ // this point works from what the files now say.
264
+ validation = validateRepo(rootDir);
265
+ reportProblems(validation.problems);
266
+ if (hasErrors(validation.problems)) return this.stopForErrors(validation.problems);
267
+
268
+ const publishing: Record<string, string> = {};
269
+ for (const release of plan.releases) publishing[release.key] = release.to;
270
+
271
+ const existing = readExistingIndex(rootDir);
272
+ const rebuilt = await buildIndex({
273
+ validation,
274
+ existing,
275
+ sousVersion: SOUS_VERSION,
276
+ publishing,
277
+ });
278
+ reportProblems(rebuilt.problems);
279
+ if (hasErrors(rebuilt.problems)) return this.stopForErrors(rebuilt.problems);
280
+
281
+ if (rebuilt.stale) {
282
+ fs.writeFileSync(indexFilePath(rootDir), rebuilt.text, "utf8");
283
+ log(` Wrote ${INDEX_FILENAME}.`);
284
+ changedPaths.push(indexFilePath(rootDir));
285
+ } else {
286
+ log(` The committed ${INDEX_FILENAME} was already current.`);
287
+ }
288
+
289
+ const message = `Release ${plan.releases
290
+ .map((release) => `${release.key}@${release.to}`)
291
+ .join(", ")}`;
292
+
293
+ if (await anythingToCommit(rootDir, changedPaths)) {
294
+ await commitPaths(rootDir, changedPaths, message);
295
+ log(` Committed: ${message}`);
296
+ } else {
297
+ log(" Nothing to commit; the manifests and the index were already current.");
298
+ }
299
+
300
+ const created: string[] = [];
301
+ if (willTag) {
302
+ for (const release of plan.releases) {
303
+ await createAnnotatedTag(
304
+ rootDir,
305
+ release.tag,
306
+ `Release ${release.key} version ${release.to}`
307
+ );
308
+ created.push(release.tag);
309
+ log(` Created the tag ${release.tag}.`);
310
+ }
311
+ } else {
312
+ blankLine();
313
+ log(` No tags were cut: this is the branch '${branch}', and tags are cut on the`);
314
+ log(` default branch '${mainBranch}'. Continuous integration does that after the`);
315
+ log(" merge, or pass '--tag' to cut them here.");
316
+ }
317
+
318
+ // --- Push -------------------------------------------------------------
319
+
320
+ if (flags.push) {
321
+ if ((await remoteUrl(rootDir, RELEASE_REMOTE)) === undefined) {
322
+ throw new ConfigError(
323
+ `The release was made, but this repository has no '${RELEASE_REMOTE}' remote to ` +
324
+ `push it to.\n` +
325
+ ` Add one with 'git remote add ${RELEASE_REMOTE} <url>', then push the commit ` +
326
+ `and its tags yourself.`
327
+ );
328
+ }
329
+ if (branch !== undefined) {
330
+ await pushBranch(rootDir, RELEASE_REMOTE, branch);
331
+ log(` Pushed the branch '${branch}' to ${RELEASE_REMOTE}.`);
332
+ }
333
+ if (created.length > 0) {
334
+ await pushTags(rootDir, RELEASE_REMOTE, created);
335
+ log(` Pushed ${describeCount(created.length, "tag")} to ${RELEASE_REMOTE}.`);
336
+ }
337
+ } else {
338
+ section("What to do next");
339
+ log(` Push the commit with 'git push ${RELEASE_REMOTE} ${branch ?? "<branch>"}'.`);
340
+ if (created.length > 0) {
341
+ log(` Push the tags with 'git push ${RELEASE_REMOTE} --tags'.`);
342
+ }
343
+ log(" Or run this command again with '--push', which does both.");
344
+ }
345
+
346
+ footer();
347
+ }
348
+
349
+ // --- The read-only form -------------------------------------------------------------------------
350
+
351
+ /**
352
+ * `--check`: validate, regenerate the index in memory, and fail when the
353
+ * committed one is out of date. This is what a pull request runs, so it
354
+ * writes nothing, asks nothing and tags nothing.
355
+ *
356
+ * @param rootDir - The repository's root directory.
357
+ * @param validation - The validated repository.
358
+ */
359
+ private async runCheck(
360
+ rootDir: string,
361
+ validation: RepoValidation
362
+ ): Promise<void> {
363
+ section("Checking the index");
364
+
365
+ const existing = readExistingIndex(rootDir);
366
+ const result = await buildIndex({ validation, existing, sousVersion: SOUS_VERSION });
367
+ reportProblems(result.problems);
368
+ if (hasErrors(result.problems)) return this.stopForErrors(result.problems);
369
+
370
+ if (!result.stale) {
371
+ log(` The committed ${INDEX_FILENAME} is current, and so are the dependencies it`);
372
+ log(" records for every version it publishes.");
373
+ reportPending(result, "These versions have no tag yet; they publish when this merges:");
374
+ footer();
375
+ return;
376
+ }
377
+
378
+ const lines = [
379
+ `The committed ${INDEX_FILENAME} is out of date:`,
380
+ "",
381
+ ...describeIndexDrift(existing, result.index).map((line) => ` ${line}`),
382
+ "",
383
+ "Run 'sous repo release' to regenerate it, and commit what it writes.",
384
+ ];
385
+ displayErrorBlock(lines.join("\n"));
386
+ this.exit(1);
387
+ }
388
+
389
+ // --- Output ---------------------------------------------------------------------------------
390
+
391
+ /**
392
+ * Prints the whole plan before anything happens: what is released, what is
393
+ * left alone, whether tags are cut, and what is pushed.
394
+ *
395
+ * @param plan - The plan to describe.
396
+ * @param context - The branch rule's outcome and the push flag.
397
+ */
398
+ private reportPlan(
399
+ plan: ReleasePlan,
400
+ context: {
401
+ willTag: boolean;
402
+ onDefaultBranch: boolean;
403
+ branch: string | undefined;
404
+ push: boolean;
405
+ }
406
+ ): void {
407
+ section("The release this would make");
408
+
409
+ if (plan.releases.length === 0) {
410
+ log(" Nothing in scope has changed since the tag that last published it.");
411
+ if (plan.skipped.length > 0) {
412
+ blankLine();
413
+ for (const entry of plan.skipped) log(` ${entry.key}: ${entry.reason}`);
414
+ }
415
+ blankLine();
416
+ log(" Pass '--include-unchanged' to release everything in scope anyway.");
417
+ return;
418
+ }
419
+
420
+ const rows: Record<string, string> = {};
421
+ for (const release of plan.releases) {
422
+ rows[release.key] =
423
+ release.bump === undefined
424
+ ? `${release.to} (already raised; the tag would be ${release.tag})`
425
+ : `${release.from} becomes ${release.to} (a ${release.bump} step; the tag would be ${release.tag})`;
426
+ }
427
+ showVariables(rows);
428
+
429
+ if (plan.skipped.length > 0) {
430
+ blankLine();
431
+ log(" Left alone:");
432
+ for (const entry of plan.skipped) log(` ${entry.key}: ${entry.reason}`);
433
+ }
434
+
435
+ blankLine();
436
+ log(" This run would:");
437
+ log(" Raise the versions listed above, in the manifests that declare them.");
438
+ log(` Regenerate ${INDEX_FILENAME}, with each version's dependencies resolved.`);
439
+ log(" Commit the manifests and the index together.");
440
+ if (context.willTag) {
441
+ log(" Cut one annotated tag per version, dependency-first.");
442
+ } else {
443
+ log(
444
+ ` Cut no tags: this is the branch '${context.branch}', not the default one.`
445
+ );
446
+ }
447
+ log(
448
+ context.push
449
+ ? ` Push the commit${context.willTag ? " and the tags" : ""} to ${RELEASE_REMOTE}.`
450
+ : " Push nothing; pass '--push' to push what it makes."
451
+ );
452
+ }
453
+
454
+ /** Ends the run after printing why the repository cannot be released. */
455
+ private stopForErrors(problems: ReadonlyArray<ValidationProblem>): void {
456
+ const count = errorsIn(problems).length;
457
+ displayErrorBlock(
458
+ `This repository cannot be released yet: ${describeCount(count, "problem")} ` +
459
+ `${count === 1 ? "is" : "are"} listed above.\n` +
460
+ ` Fix them and run the command again.`
461
+ );
462
+ this.exit(1);
463
+ }
464
+
465
+ /**
466
+ * Refuses to release while anything is uncommitted.
467
+ *
468
+ * A tag names one commit, and the index this run writes records the content
469
+ * hash of every recipe folder as it stands, so an uncommitted edit would be
470
+ * published by hash and absent from the tag. Sous commits its own version
471
+ * bumps and its own index, and nothing else.
472
+ *
473
+ * @param rootDir - The repository's root directory.
474
+ */
475
+ private async assertNothingUncommitted(rootDir: string): Promise<void> {
476
+ const changed = await uncommittedChanges(rootDir);
477
+ if (changed.length === 0) return;
478
+
479
+ const listed = changed.map((entry) => ` ${entry.path}`).join("\n");
480
+ throw new ConfigError(
481
+ "Cannot release while the working tree has uncommitted changes.\n\n" +
482
+ `${listed}\n\n` +
483
+ " A tag names one commit, and the index this writes records what each recipe " +
484
+ "folder holds right now, so everything being published has to be committed " +
485
+ "first.\n" +
486
+ " Commit these, then run the command again; sous commits the version bumps and " +
487
+ "the index itself."
488
+ );
489
+ }
490
+
491
+ /**
492
+ * Refuses to release from a repository where git does not know who is
493
+ * committing, before anything has been written.
494
+ *
495
+ * A release makes a commit and annotated tags, and both need an identity. Git
496
+ * would fail partway through with its own "empty ident name" message, which
497
+ * says nothing about sous or about which repository is the problem; that is
498
+ * exactly how the first automated release of sous itself failed. This says
499
+ * what to set instead.
500
+ *
501
+ * @param rootDir - The repository's root directory.
502
+ */
503
+ private async assertCommitIdentity(rootDir: string): Promise<void> {
504
+ if (await hasCommitIdentity(rootDir)) return;
505
+
506
+ throw new ConfigError(
507
+ "Cannot release: git does not know who is making the commit.\n\n" +
508
+ " A release commits the version bumps and the index, and cuts an annotated tag " +
509
+ "for every version it publishes; git refuses to do either without an author " +
510
+ "identity.\n" +
511
+ " Set one in this repository and run the command again:\n\n" +
512
+ ' git config user.name "Your Name"\n' +
513
+ ' git config user.email "you@example.com"\n\n' +
514
+ " In a continuous integration job, configure the identity of the account the " +
515
+ "release runs as before this command; the workflow 'sous repo init' scaffolds " +
516
+ "already does."
517
+ );
518
+ }
519
+
520
+ /**
521
+ * Reports a failure the way every other sous command reports one: the message
522
+ * on its own, this command's help underneath it when the command line was the
523
+ * problem or a question could not be asked (so every flag that would have
524
+ * answered it is visible without going looking), and a stack trace only when
525
+ * `SOUS_DEBUG` asks for one. The rules live in `utils/command-errors.ts`.
526
+ */
527
+ protected async catch(error: Error & { exitCode?: number }): Promise<unknown> {
528
+ const exitCode = await reportCommandError(this, error);
529
+ if (exitCode === undefined) return super.catch(error);
530
+ return this.exit(exitCode);
531
+ }
532
+ }
533
+
534
+ // --- Helpers ------------------------------------------------------------------------------------
535
+
536
+ /** Refuses flag combinations that would mean two different things at once. */
537
+ function assertFlagsAgree(flags: {
538
+ check: boolean;
539
+ ci: boolean;
540
+ noBump: boolean;
541
+ push: boolean;
542
+ tag: boolean;
543
+ bump?: string;
544
+ "include-unchanged": boolean;
545
+ }): void {
546
+ if (flags.check && flags.ci) {
547
+ throw new ConfigError(
548
+ "'--check' and '--ci' cannot be used together.\n" +
549
+ " '--check' only reads, and reports whether a release is needed; '--ci' makes " +
550
+ "one.\n A pull request runs 'sous repo release --check'; a merge runs " +
551
+ "'sous repo release --ci --push'."
552
+ );
553
+ }
554
+ if (flags.check && (flags.bump !== undefined || flags.push || flags.tag)) {
555
+ throw new ConfigError(
556
+ "'--check' changes nothing, so it cannot be combined with '--bump', '--tag' or " +
557
+ "'--push'.\n" +
558
+ " Run 'sous repo release --check' on its own, or drop '--check' to make a release."
559
+ );
560
+ }
561
+ if (flags.bump !== undefined && flags.noBump) {
562
+ throw new ConfigError(
563
+ "'--bump' and '--no-bump' cannot be used together.\n" +
564
+ " '--bump' says how far to raise a version; '--no-bump' says to raise none. " +
565
+ "'--ci' implies '--no-bump'."
566
+ );
567
+ }
568
+ }
569
+
570
+ /** The plain-language name of what this run is doing, for the preamble. */
571
+ function describeMode(flags: { check: boolean; ci: boolean; "dry-run": boolean }): string {
572
+ if (flags.check) return "Check only";
573
+ if (flags["dry-run"]) return "Plan only";
574
+ if (flags.ci) return "Publish (continuous integration)";
575
+ return "Publish";
576
+ }
577
+
578
+ /** Reads the committed index, warning and starting fresh when it cannot be read. */
579
+ function readExistingIndex(rootDir: string) {
580
+ try {
581
+ return readIndexFile(rootDir);
582
+ } catch (error) {
583
+ warning(
584
+ `The committed ${INDEX_FILENAME} could not be read, so it will be regenerated from ` +
585
+ `the recipe manifests and the release tags.\n` +
586
+ `${isConfigError(error) ? (error as Error).message : String(error)}`
587
+ );
588
+ return undefined;
589
+ }
590
+ }
591
+
592
+ /** Prints every problem, errors first, each with where it was found. */
593
+ function reportProblems(problems: ReadonlyArray<ValidationProblem>): void {
594
+ for (const problem of errorsIn(problems)) {
595
+ displayErrorBlock(`${problem.where}:\n ${problem.message}`);
596
+ }
597
+ for (const problem of warningsIn(problems)) {
598
+ warning(`${problem.where}:\n${problem.message}`);
599
+ }
600
+ }
601
+
602
+ /** Lists the versions that have no tag yet, when there are any. */
603
+ function reportPending(result: IndexBuildResult, headline: string): void {
604
+ if (result.pending.length === 0) return;
605
+ log(` ${headline}`);
606
+ const pending: Record<string, string> = {};
607
+ for (const entry of result.pending) {
608
+ pending[entry.key] = `${entry.version} (the tag would be ${entry.tag})`;
609
+ }
610
+ showVariables(pending);
611
+ }
612
+
613
+ /** Renders a count with its noun, singular or plural, in words a person reads. */
614
+ function describeCount(count: number, noun: string): string {
615
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
616
+ }
617
+
618
+ /** Re-exported so the scope type is nameable from a test. */
619
+ export type { ReleaseScope };