@sous-io/sous 0.1.1 → 0.2.1

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 +409 -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 +625 -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 +415 -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,285 @@
1
+ /**
2
+ * `sous subscription add <ref>`, also reachable as `sous subscribe <ref>`.
3
+ *
4
+ * Subscribing is how a recipe enters a project. The ref names a namespace (every
5
+ * recipe in it, including ones published later) or one recipe, with an optional
6
+ * version range:
7
+ *
8
+ * sous subscription add workflow/task-files
9
+ * sous subscription add workflow/task-files@^1.2.0
10
+ * sous subscription add core
11
+ * sous subscription add my-recipes:workflow/task-files
12
+ *
13
+ * A dry run prints the whole plan, including every question these recipes ask
14
+ * and where each answer would be stored. Those answers can be supplied on the
15
+ * command line with `--answer name=value` (or `--answers-file <path>`), which
16
+ * is how a run with no terminal subscribes to a recipe that asks questions.
17
+ *
18
+ * The whole dependency closure is resolved before anything is downloaded. If it
19
+ * reaches a repository this project has not added, sous stops and asks about it
20
+ * by name, showing which recipe requires it; a run with no terminal fails
21
+ * instead, naming the command that grants the trust. Installs are whole or not
22
+ * at all.
23
+ */
24
+
25
+ import { Args, Flags } from "@oclif/core";
26
+ import { BaseCommand } from "../../base-command.js";
27
+ import { buildProjectOutputs } from "../../lib/build-service.js";
28
+ import { ConfigError } from "../../lib/errors.js";
29
+ import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js";
30
+ import { formatAskReport } from "../../lib/vars/ask.js";
31
+ import { renderTable, type TableColumn } from "../../utils/table.js";
32
+ import {
33
+ collectProvidedAnswers,
34
+ formatQuestionPlan,
35
+ } from "../../lib/vars/index.js";
36
+ import {
37
+ blankLine,
38
+ dryRunNotice,
39
+ footer,
40
+ heading,
41
+ indent,
42
+ log,
43
+ paragraph,
44
+ showCommandVars,
45
+ subheading,
46
+ warning,
47
+ } from "../../utils/formatting.js";
48
+ import { answerFlags, confirmationFlag } from "../../utils/flags.js";
49
+
50
+ /** How far every line of this command's output is indented. */
51
+ const INDENT = 2;
52
+
53
+ /**
54
+ * The columns the installation report shows: what was installed, at what
55
+ * version, and why it is there. The reason wraps rather than being cut, because
56
+ * it is the answer to the question a reader is most likely to have.
57
+ */
58
+ const INSTALLED_COLUMNS: TableColumn[] = [
59
+ { key: "key", header: "Recipe", kind: "path", overflow: "truncate", minWidth: 12 },
60
+ { key: "version", header: "Version", overflow: "truncate" },
61
+ { key: "repo", header: "Repository", overflow: "truncate", priority: "medium" },
62
+ { key: "why", header: "Why", overflow: "wrap", flex: 1, minWidth: 16 },
63
+ ];
64
+
65
+ export default class SubscriptionAdd extends BaseCommand {
66
+ static description =
67
+ "Subscribe this project to a recipe, or to a whole namespace of them";
68
+
69
+ /**
70
+ * `subscriptions:add` is the plural spelling of the topic. `subscribe` is the
71
+ * original spelling of this command and still works; it is hidden so the
72
+ * top-level listing names the command once, under its topic.
73
+ */
74
+ static aliases = ["subscriptions:add"];
75
+
76
+ static hiddenAliases = ["subscribe"];
77
+
78
+ static examples = [
79
+ "<%= config.bin %> subscription add workflow/task-files",
80
+ "<%= config.bin %> subscription add workflow/task-files@^1.2.0",
81
+ "<%= config.bin %> subscription add core",
82
+ "<%= config.bin %> subscription add workflow/task-files --always-pull",
83
+ "<%= config.bin %> subscription add workflow/task-files --dry-run",
84
+ "<%= config.bin %> subscription add workflow/task-files --yes --answer apiUrl=https://api.example.com",
85
+ ];
86
+
87
+ static args = {
88
+ ref: Args.string({
89
+ description:
90
+ "What to subscribe to: 'namespace', 'namespace/recipe', or either with an '@<range>'",
91
+ required: true,
92
+ }),
93
+ };
94
+
95
+ static flags = {
96
+ ...BaseCommand.baseFlags,
97
+ prerelease: Flags.boolean({
98
+ description: "Let prerelease versions take part in version range matching",
99
+ default: false,
100
+ }),
101
+ "always-pull": Flags.boolean({
102
+ description:
103
+ "Install a newer in-range version whenever one exists, rather than holding the locked one",
104
+ default: false,
105
+ }),
106
+ // One flag answers both questions this command can ask: the trust question
107
+ // for a repository it has to add, and the subscribe confirmation. `--trust`
108
+ // is kept as a spelling of it because the trust ceremony reads naturally
109
+ // with that word.
110
+ yes: confirmationFlag({ extraAliases: ["trust"] }),
111
+ "accept-first": Flags.boolean({
112
+ description:
113
+ "When a one-word ref matches several things, take the first one listed",
114
+ default: false,
115
+ }),
116
+ "dry-run": Flags.boolean({
117
+ description: "Print what would be installed without writing or downloading anything",
118
+ default: false,
119
+ }),
120
+ "no-build": Flags.boolean({
121
+ description: "Change the subscription without rebuilding the project",
122
+ default: false,
123
+ }),
124
+ ...answerFlags(),
125
+ };
126
+
127
+ async run(): Promise<void> {
128
+ const { args, flags } = await this.parse(SubscriptionAdd);
129
+ const dryRun = flags["dry-run"];
130
+
131
+ showCommandVars({
132
+ Project: this.projectLabel,
133
+ Config: this.configContext.configPath,
134
+ Subscribing: args.ref,
135
+ "Dry Run": dryRun,
136
+ });
137
+
138
+ heading("Subscribing");
139
+
140
+ if (dryRun) dryRunNotice("Nothing will be downloaded, written or asked.");
141
+
142
+ const service = subscriptionServiceFor({
143
+ configContext: this.configContext,
144
+ settings: this.settings,
145
+ shellEnv: this.shellEnv,
146
+ });
147
+
148
+ // Answers supplied ahead of the questions. They are validated before
149
+ // anything is installed, so a typo fails the run rather than being stored
150
+ // under a name nothing reads.
151
+ const provided = collectProvidedAnswers({
152
+ ...(flags.answer === undefined ? {} : { answer: flags.answer }),
153
+ ...(flags["answers-file"] === undefined
154
+ ? {}
155
+ : { answersFile: flags["answers-file"] }),
156
+ });
157
+
158
+ const outcome = await service.subscribe({
159
+ ref: args.ref,
160
+ answers: provided,
161
+ prerelease: flags.prerelease,
162
+ alwaysPull: flags["always-pull"],
163
+ trust: flags.yes,
164
+ yes: flags.yes,
165
+ acceptFirst: flags["accept-first"],
166
+ dryRun,
167
+ });
168
+
169
+ blankLine();
170
+ subheading(dryRun ? "What would be installed" : "What was installed");
171
+ blankLine();
172
+
173
+ const rows = outcome.resolved.map((recipe) => ({
174
+ key: recipe.key,
175
+ version: recipe.version,
176
+ repo: recipe.repo,
177
+ why: recipe.requestedBy.includes("project")
178
+ ? "you subscribed to it"
179
+ : recipe.kind === "subscribes"
180
+ ? `co-subscribed by ${recipe.requestedBy.join(", ")}`
181
+ : `needed by ${recipe.requestedBy.join(", ")}`,
182
+ }));
183
+
184
+ for (const line of renderTable(INSTALLED_COLUMNS, rows, { indent: INDENT })) {
185
+ log(indent(line, INDENT));
186
+ }
187
+
188
+ if (outcome.trusted.length > 0) {
189
+ blankLine();
190
+ paragraph(
191
+ `Repositories trusted along the way: ${outcome.trusted.join(", ")}. ` +
192
+ `They are now recorded in this project's config, and your colleagues ` +
193
+ `inherit them.`
194
+ );
195
+ }
196
+
197
+ blankLine();
198
+ subheading("Lockfile");
199
+ blankLine();
200
+ if (outcome.diff.unchanged) {
201
+ paragraph("Nothing changed; everything asked for was already locked.");
202
+ } else {
203
+ for (const line of outcome.diff.lines) log(indent(line));
204
+ }
205
+
206
+ if (outcome.answers !== undefined) {
207
+ blankLine();
208
+ subheading("Variables");
209
+ for (const line of formatAskReport(outcome.answers, dryRun)) {
210
+ log(line === "" ? "" : indent(line));
211
+ }
212
+ }
213
+
214
+ // The question plan, printed only by a dry run: it is how a caller with no
215
+ // terminal learns what this subscription will want to know, so that the
216
+ // real run can answer everything with '--answer'.
217
+ if (outcome.questions !== undefined) {
218
+ blankLine();
219
+ subheading("Questions these recipes ask");
220
+ blankLine();
221
+ // Recipes this machine does not hold yet are named inside the plan, so a
222
+ // closure that is only partly readable still lists everything it can.
223
+ for (const line of formatQuestionPlan(outcome.questions, {
224
+ ...(outcome.unreadable === undefined ? {} : { unreadable: outcome.unreadable }),
225
+ })) {
226
+ log(line === "" ? "" : indent(line));
227
+ }
228
+ }
229
+
230
+ if (outcome.cycles.length > 0) {
231
+ warning(
232
+ `Some of these recipes co-subscribe to each other in a circle:\n` +
233
+ outcome.cycles.map((cycle) => ` ${cycle.join(" -> ")}`).join("\n") +
234
+ `\nThat is unusual but not broken, and everything above was installed.`
235
+ );
236
+ }
237
+
238
+ const rebuilding = !dryRun && !flags["no-build"];
239
+
240
+ blankLine();
241
+ paragraph(
242
+ dryRun
243
+ ? `Nothing was written. Run the same command without '--dry-run' to install it.`
244
+ : `The subscription to '${outcome.key}' is recorded in this project's config, ` +
245
+ `and the exact versions above are recorded in its lockfile.` +
246
+ (rebuilding ? `` : ` Run 'sous build' to compile what they contribute.`)
247
+ );
248
+
249
+ footer();
250
+
251
+ if (rebuilding) await this.rebuildProject(outcome.key);
252
+ }
253
+
254
+ /**
255
+ * Rebuilds the project now that the subscription has been written, so the
256
+ * files the new recipes contribute are on disk when this command returns.
257
+ *
258
+ * The subscription lives in a managed `conf.d/` layer, so the settings loaded
259
+ * when this command started no longer describe the project; they are reloaded
260
+ * before the build, or it would compile the old subscription set. A build that
261
+ * fails leaves the subscription in place, because it is already written and
262
+ * locked; the message says so and names the command to run once the cause is
263
+ * fixed.
264
+ *
265
+ * @param key - The subscription that was just added, for the failure message.
266
+ */
267
+ private async rebuildProject(key: string): Promise<void> {
268
+ await this.reloadDiscoveredConfig();
269
+
270
+ heading("Building the project");
271
+
272
+ const succeeded = await buildProjectOutputs(this.settings, this.configContext);
273
+
274
+ footer();
275
+
276
+ if (!succeeded) {
277
+ throw new ConfigError(
278
+ `The subscription to '${key}' was saved, but the build that followed it ` +
279
+ `failed, so this project's outputs may be incomplete. The subscription ` +
280
+ `itself is recorded and locked; fix what the build reported above and run ` +
281
+ `'sous build' again.`
282
+ );
283
+ }
284
+ }
285
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `sous subscription list`.
3
+ *
4
+ * Prints every subscription this project declares: the ref, the version range
5
+ * it resolves within, the versions its lockfile currently pins, where the
6
+ * subscription came from, and whether it is switched on. Entries switched off
7
+ * with `enabled: false` are listed too, because an opt-out is part of what a
8
+ * project declares.
9
+ *
10
+ * It reads only the config and the lockfile, so it is safe offline and never
11
+ * downloads anything.
12
+ */
13
+
14
+ import { BaseCommand } from "../../base-command.js";
15
+ import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js";
16
+ import { BUILT_IN_ADDED_BY } from "../../lib/repos/defaults.js";
17
+ import { USER_ADDED_BY } from "../../lib/repos/trust.js";
18
+ import { renderTable, type TableColumn } from "../../utils/table.js";
19
+ import {
20
+ blankLine,
21
+ footer,
22
+ heading,
23
+ indent,
24
+ log,
25
+ paragraph,
26
+ showCommandVars,
27
+ } from "../../utils/formatting.js";
28
+
29
+ /** How far every line of this command's output is indented. */
30
+ const INDENT = 2;
31
+
32
+ /**
33
+ * The columns the listing shows. What a project subscribed to, the range it
34
+ * asked for and the versions it actually holds are the whole point of the
35
+ * command, so all three stay whatever the terminal's width; where a
36
+ * subscription came from and whether it is switched on give way first.
37
+ */
38
+ const COLUMNS: TableColumn[] = [
39
+ { key: "key", header: "Subscription", kind: "path", overflow: "truncate", minWidth: 12 },
40
+ { key: "range", header: "Range", overflow: "truncate", minWidth: 7 },
41
+ { key: "pinned", header: "Pinned version", flex: 1, minWidth: 14 },
42
+ { key: "origin", header: "Origin", priority: "medium" },
43
+ { key: "enabled", header: "Enabled", priority: "medium" },
44
+ ];
45
+
46
+ export default class SubscriptionList extends BaseCommand {
47
+ static description = "List the recipes and namespaces this project subscribes to";
48
+
49
+ /**
50
+ * The other spelling of the topic. It lives under a hidden topic, so it is
51
+ * typable everywhere without ever reaching the top-level listing.
52
+ */
53
+ static aliases = ["subscriptions:list"];
54
+
55
+ static examples = ["<%= config.bin %> subscription list"];
56
+
57
+ static flags = { ...BaseCommand.baseFlags };
58
+
59
+ async run(): Promise<void> {
60
+ await this.parse(SubscriptionList);
61
+
62
+ showCommandVars({
63
+ Project: this.projectLabel,
64
+ Config: this.configContext.configPath,
65
+ });
66
+
67
+ heading("Subscriptions");
68
+
69
+ const service = subscriptionServiceFor({
70
+ configContext: this.configContext,
71
+ settings: this.settings,
72
+ shellEnv: this.shellEnv,
73
+ });
74
+
75
+ const listings = service.listSubscriptions();
76
+
77
+ blankLine();
78
+
79
+ if (listings.length === 0) {
80
+ paragraph("This project subscribes to nothing yet.");
81
+ footer();
82
+ return;
83
+ }
84
+
85
+ const rows = listings.map((entry) => ({
86
+ key: entry.key,
87
+ range: entry.range ?? "any version",
88
+ pinned: describePinned(entry.pinned, entry.enabled),
89
+ origin: describeOrigin(entry.addedBy),
90
+ enabled: entry.enabled ? "yes" : "no",
91
+ }));
92
+
93
+ for (const line of renderTable(COLUMNS, rows, { indent: INDENT })) {
94
+ log(indent(line, INDENT));
95
+ }
96
+
97
+ footer();
98
+ }
99
+ }
100
+
101
+ /**
102
+ * The versions the lockfile pins for one subscription. A namespace subscription
103
+ * holds several recipes, so each is named with the version beside it.
104
+ *
105
+ * A subscription with nothing pinned has simply not been built yet, which is
106
+ * what the cell says; the one exception is a subscription switched off, which
107
+ * no build will pin.
108
+ *
109
+ * @param pinned - The locked recipes the subscription holds.
110
+ * @param enabled - Whether the subscription is switched on.
111
+ */
112
+ export function describePinned(
113
+ pinned: Array<{ key: string; version: string }>,
114
+ enabled: boolean
115
+ ): string {
116
+ if (pinned.length > 0) return pinned.map((entry) => `${entry.key} ${entry.version}`).join(", ");
117
+ return enabled ? "pinned on first build" : "not pinned";
118
+ }
119
+
120
+ /**
121
+ * Plain-language wording for a subscription entry's `addedBy` field.
122
+ *
123
+ * @param addedBy - What the entry recorded, when it recorded anything.
124
+ */
125
+ function describeOrigin(addedBy: string | undefined): string {
126
+ if (addedBy === BUILT_IN_ADDED_BY) return "built in";
127
+ if (addedBy === undefined || addedBy === USER_ADDED_BY) return "user";
128
+ return addedBy;
129
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * `sous subscription remove <ref>`, also reachable as `sous unsubscribe <ref>`.
3
+ *
4
+ * The exact reverse of adding a subscription, and refcounted: removing a subscription
5
+ * removes what it alone brought in, and leaves alone anything another
6
+ * subscription or another recipe still needs. Whatever stays is reported, with
7
+ * who is holding it, so a removal that appears to do nothing explains itself.
8
+ *
9
+ * The repository the recipes came from stays trusted; withdrawing that is a
10
+ * separate, deliberate act.
11
+ */
12
+
13
+ import { Args, Flags } from "@oclif/core";
14
+ import { BaseCommand } from "../../base-command.js";
15
+ import { buildProjectOutputs } from "../../lib/build-service.js";
16
+ import { ConfigError } from "../../lib/errors.js";
17
+ import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js";
18
+ import { renderTable, type TableColumn } from "../../utils/table.js";
19
+ import {
20
+ blankLine,
21
+ dryRunNotice,
22
+ footer,
23
+ heading,
24
+ indent,
25
+ log,
26
+ paragraph,
27
+ showCommandVars,
28
+ subheading,
29
+ } from "../../utils/formatting.js";
30
+
31
+ /** How far every line of this command's output is indented. */
32
+ const INDENT = 2;
33
+
34
+ /** The columns the report of what stayed behind shows. */
35
+ const STAYED_COLUMNS: TableColumn[] = [
36
+ { key: "key", header: "Recipe", kind: "path", overflow: "truncate", minWidth: 12 },
37
+ { key: "heldBy", header: "Still held by", overflow: "wrap", flex: 1, minWidth: 16 },
38
+ ];
39
+
40
+ export default class SubscriptionRemove extends BaseCommand {
41
+ static description = "Remove a subscription, and everything only it brought in";
42
+
43
+ /**
44
+ * `subscriptions:remove` is the plural spelling of the topic. `unsubscribe` is
45
+ * the original spelling of this command and still works; it is hidden so the
46
+ * top-level listing names the command once, under its topic.
47
+ */
48
+ static aliases = ["subscriptions:remove"];
49
+
50
+ static hiddenAliases = ["unsubscribe"];
51
+
52
+ static examples = [
53
+ "<%= config.bin %> subscription remove workflow/task-files",
54
+ "<%= config.bin %> subscription remove core",
55
+ "<%= config.bin %> subscription remove workflow/task-files --dry-run",
56
+ ];
57
+
58
+ static args = {
59
+ ref: Args.string({
60
+ description: "What to unsubscribe from: 'namespace' or 'namespace/recipe'",
61
+ required: true,
62
+ }),
63
+ };
64
+
65
+ static flags = {
66
+ ...BaseCommand.baseFlags,
67
+ "dry-run": Flags.boolean({
68
+ description: "Print what would be removed without writing anything",
69
+ default: false,
70
+ }),
71
+ "no-build": Flags.boolean({
72
+ description: "Change the subscription without rebuilding the project",
73
+ default: false,
74
+ }),
75
+ };
76
+
77
+ async run(): Promise<void> {
78
+ const { args, flags } = await this.parse(SubscriptionRemove);
79
+ const dryRun = flags["dry-run"];
80
+
81
+ showCommandVars({
82
+ Project: this.projectLabel,
83
+ Config: this.configContext.configPath,
84
+ Unsubscribing: args.ref,
85
+ "Dry Run": dryRun,
86
+ });
87
+
88
+ heading("Unsubscribing");
89
+
90
+ if (dryRun) dryRunNotice("Nothing will be written.");
91
+
92
+ const service = subscriptionServiceFor({
93
+ configContext: this.configContext,
94
+ settings: this.settings,
95
+ shellEnv: this.shellEnv,
96
+ });
97
+
98
+ const outcome = await service.unsubscribe({ ref: args.ref, dryRun });
99
+
100
+ blankLine();
101
+ subheading("Lockfile");
102
+ blankLine();
103
+ if (outcome.diff.unchanged) {
104
+ paragraph("Nothing changed; nothing was locked because of this subscription.");
105
+ } else {
106
+ for (const line of outcome.diff.lines) log(indent(line));
107
+ }
108
+
109
+ if (outcome.stayed.length > 0) {
110
+ blankLine();
111
+ subheading("What stayed, and why");
112
+ blankLine();
113
+ const rows = outcome.stayed.map((entry) => ({
114
+ key: entry.key,
115
+ heldBy: entry.heldBy.join(", "),
116
+ }));
117
+
118
+ for (const line of renderTable(STAYED_COLUMNS, rows, { indent: INDENT })) {
119
+ log(indent(line, INDENT));
120
+ }
121
+ }
122
+
123
+ const rebuilding = !dryRun && !flags["no-build"];
124
+
125
+ // The closing sentence names the build only when this run is not about to do
126
+ // it, so nobody is told to run a command that is already running.
127
+ const pruneHint = rebuilding
128
+ ? ``
129
+ : ` Run 'sous build' to prune what it used to write.`;
130
+
131
+ blankLine();
132
+ paragraph(
133
+ dryRun
134
+ ? "Nothing was written. Run the same command without '--dry-run' to remove it."
135
+ : outcome.optedOut
136
+ ? `The subscription to '${outcome.key}' is one sous provides itself, so it ` +
137
+ `was switched off rather than deleted: this project's config now records ` +
138
+ `'${outcome.key}: { enabled: false }'. The repository it came from is ` +
139
+ `still trusted.${pruneHint}`
140
+ : `The subscription to '${outcome.key}' is gone. The repositories it came ` +
141
+ `from are still trusted; remove one of those deliberately if you want ` +
142
+ `to withdraw that too.${pruneHint}`
143
+ );
144
+
145
+ footer();
146
+
147
+ if (rebuilding) await this.rebuildProject(outcome.key);
148
+ }
149
+
150
+ /**
151
+ * Rebuilds the project now that the subscription has been removed, so the
152
+ * files it used to contribute are pruned before this command returns.
153
+ *
154
+ * The subscription lives in a managed `conf.d/` layer, so the settings loaded
155
+ * when this command started no longer describe the project; they are reloaded
156
+ * before the build, or it would compile the old subscription set straight back
157
+ * onto disk. A build that fails leaves the removal in place, because it is
158
+ * already written and locked; the message says so and names the command to run
159
+ * once the cause is fixed.
160
+ *
161
+ * @param key - The subscription that was just removed, for the failure message.
162
+ */
163
+ private async rebuildProject(key: string): Promise<void> {
164
+ await this.reloadDiscoveredConfig();
165
+
166
+ heading("Building the project");
167
+
168
+ const succeeded = await buildProjectOutputs(this.settings, this.configContext);
169
+
170
+ footer();
171
+
172
+ if (!succeeded) {
173
+ throw new ConfigError(
174
+ `The subscription to '${key}' was removed, but the build that followed it ` +
175
+ `failed, so this project may still hold files it used to write. The removal ` +
176
+ `itself is recorded and locked; fix what the build reported above and run ` +
177
+ `'sous build' again.`
178
+ );
179
+ }
180
+ }
181
+ }