@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,500 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { Args, Flags } from "@oclif/core";
4
+ import { BaseCommand } from "../../base-command.js";
5
+ import { ConfigError } from "../../lib/errors.js";
6
+ import {
7
+ MANIFEST_EXTENSIONS,
8
+ REPO_MANIFEST_BASENAME,
9
+ } from "../../lib/repos/formats/common.js";
10
+ import { findRepoManifest } from "../../lib/repos/load-manifest.js";
11
+ import { enabledRepos } from "../../lib/repos/defaults.js";
12
+ import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js";
13
+ import type { LinkOrigin } from "../../lib/repos/formats/links-map.js";
14
+ import {
15
+ cloneRepo,
16
+ isGitCheckout,
17
+ looksLikeRepoUrl,
18
+ remoteUrlOf,
19
+ repoSlugFromUrl,
20
+ sameRemote,
21
+ } from "../../lib/repos/git-clone.js";
22
+ import {
23
+ assertLocalRepoDirectory,
24
+ expandHomePath,
25
+ looksLikeLocalPath,
26
+ resolveRepoArgument,
27
+ } from "../../lib/repos/providers/local.js";
28
+ import { readRepoManifestIn } from "../../lib/repos/locked-recipes.js";
29
+ import {
30
+ ensureReposIgnoreFiles,
31
+ globalReposDir,
32
+ projectReposDir,
33
+ readGlobalLinks,
34
+ readProjectLinks,
35
+ writeGlobalLinks,
36
+ writeProjectLinks,
37
+ } from "../../lib/repos/links.js";
38
+ import {
39
+ blankLine,
40
+ dryRunNotice,
41
+ footer,
42
+ heading,
43
+ log,
44
+ showCommandVars,
45
+ showVariables,
46
+ warning,
47
+ } from "../../utils/formatting.js";
48
+ import { confirmationFlag } from "../../utils/flags.js";
49
+ import {
50
+ ensureGlobalReposDirectory,
51
+ ensureProjectReposDirectory,
52
+ } from "../../utils/sous-directory.js";
53
+
54
+ /**
55
+ * `sous repo link` points this project (or this machine) at a working copy of a
56
+ * repository instead of at a published version, which is how a maintainer edits
57
+ * recipes: edits happen in a checkout, never in the store.
58
+ *
59
+ * The command is written three ways. A path on its own links the checkout that
60
+ * is already at that path, in place. A repository name (or URL) on its own
61
+ * clones the repository into `.sous/repos/<owner>/<name>`, or into
62
+ * `$SOUS_HOME/repos/<owner>/<name>` with --global, where two projects can share
63
+ * one checkout. A name (or URL) followed by a path links the checkout at that
64
+ * path to that repository, and clones nothing.
65
+ *
66
+ * A linked repository's recipes are read from the checkout with no version, no
67
+ * lockfile and no hash check, so linking one is at least as consequential as
68
+ * adding one. Naming a repository this project has not added therefore runs the
69
+ * same trust ceremony `sous repo add` runs, rather than skipping it; there is no
70
+ * way to read from a repository this project does not trust.
71
+ */
72
+ export default class RepoLink extends BaseCommand {
73
+ static description = [
74
+ "Point a repository at a working copy on this machine instead of a published version",
75
+ "",
76
+ "'sous repo link <path>' links the checkout already at that path, where it is, " +
77
+ "adding the repository to this project first if it has not been added yet.",
78
+ "'sous repo link <name-or-url>' clones the repository into .sous/repos and links " +
79
+ "that clone.",
80
+ "'sous repo link <name-or-url> <path>' links the checkout at that path to that " +
81
+ "repository, and clones nothing.",
82
+ ].join("\n");
83
+
84
+ /**
85
+ * The other spelling of the topic. It lives under a hidden topic, so it is
86
+ * typable everywhere without ever reaching the top-level listing.
87
+ */
88
+ static aliases = ["repos:link"];
89
+
90
+ static examples = [
91
+ "<%= config.bin %> repo link ../sous-recipes",
92
+ "<%= config.bin %> repo link sous-recipes",
93
+ "<%= config.bin %> repo link sous-recipes ~/Projects/sous-recipes",
94
+ "<%= config.bin %> repo link https://github.com/sous-io/sous-recipes",
95
+ "<%= config.bin %> repo link sous-recipes --global",
96
+ ];
97
+
98
+ static args = {
99
+ repo: Args.string({
100
+ description:
101
+ "The repository's short name from this project's config, its full URL, or the " +
102
+ "path of a checkout to link in place",
103
+ required: true,
104
+ }),
105
+ path: Args.string({
106
+ description:
107
+ "An existing checkout to link. Without it, the repository is cloned.",
108
+ required: false,
109
+ }),
110
+ };
111
+
112
+ static flags = {
113
+ ...BaseCommand.baseFlags,
114
+ global: Flags.boolean({
115
+ description:
116
+ "Link for every project on this machine, sharing one checkout, rather than for this project",
117
+ default: false,
118
+ }),
119
+ // Linking a repository this project has not added yet asks the trust
120
+ // question first; the shared confirmation flag answers it, under `--trust`
121
+ // as well as the usual spellings.
122
+ yes: confirmationFlag({ extraAliases: ["trust"] }),
123
+ "dry-run": Flags.boolean({
124
+ description: "Print what would change without cloning or writing anything",
125
+ default: false,
126
+ }),
127
+ };
128
+
129
+ async run(): Promise<void> {
130
+ const { args, flags } = await this.parse(RepoLink);
131
+ const { sousDir } = this.configContext;
132
+ const isGlobal = flags.global;
133
+ const dryRun = flags["dry-run"];
134
+
135
+ // A path in the REPO slot means "link the checkout that is already here",
136
+ // so it settles both what is being linked and where it lives; a second path
137
+ // would have to contradict one of the two.
138
+ const checkout = this.checkoutInRepoSlot(args.repo);
139
+ if (checkout !== undefined && args.path !== undefined) {
140
+ throw new ConfigError(
141
+ `'${args.repo}' is the path of a checkout on this machine, so it already says ` +
142
+ `which checkout to link, and a second path cannot say it again.\n` +
143
+ ` Link a checkout where it already is: sous repo link ${args.repo}\n` +
144
+ ` Link a checkout to a named repository: sous repo link <name-or-url> ` +
145
+ `${args.path}\n` +
146
+ ` Drop whichever of the two paths you did not mean.`
147
+ );
148
+ }
149
+
150
+ const { name, url } = await this.resolveRepo(
151
+ args.repo,
152
+ flags.yes,
153
+ dryRun,
154
+ checkout
155
+ );
156
+
157
+ showCommandVars({
158
+ Project: this.projectLabel,
159
+ Repository: name,
160
+ Location: url ?? "(not needed; an existing checkout was given)",
161
+ Scope: isGlobal ? "this machine" : "this project",
162
+ "Dry Run": dryRun,
163
+ });
164
+
165
+ // The heading is not followed by a blank line here: the block that comes
166
+ // next opens with one of its own, and the trust ceremony this may run
167
+ // prints in between.
168
+ heading("Linking a repository");
169
+
170
+ // Both written forms that name a checkout link it exactly where it is; only
171
+ // a repository named on its own is cloned.
172
+ const existingCheckout = checkout ?? args.path;
173
+ const plan =
174
+ existingCheckout !== undefined
175
+ ? this.planLinkToPath(existingCheckout)
176
+ : this.planClone(name, url, isGlobal, dryRun);
177
+
178
+ if (dryRun) {
179
+ blankLine();
180
+ dryRunNotice(`would link '${name}' to ${plan.directory}`);
181
+ dryRunNotice(
182
+ `would record it in ${isGlobal ? "the machine-wide" : "this project's"} links map`
183
+ );
184
+ footer();
185
+ return;
186
+ }
187
+
188
+ const map = isGlobal ? readGlobalLinks() : readProjectLinks(sousDir);
189
+ const previous = map.links[name];
190
+ map.links[name] = {
191
+ path: plan.directory,
192
+ linkedAt: new Date().toISOString(),
193
+ origin: plan.origin,
194
+ };
195
+
196
+ const linksPath = isGlobal ? writeGlobalLinks(map) : writeProjectLinks(sousDir, map);
197
+
198
+ // Ignore hygiene runs for both scopes. Both files are idempotent, and a
199
+ // project that links anything at all wants its machine-local sous files kept
200
+ // out of version control whichever scope the link was recorded in.
201
+ ensureReposIgnoreFiles(sousDir);
202
+
203
+ blankLine();
204
+ for (const line of plan.notes) log(` ${line}`);
205
+ if (plan.notes.length > 0) blankLine();
206
+
207
+ showVariables({
208
+ Repository: name,
209
+ Checkout: plan.directory,
210
+ "Recorded in": linksPath,
211
+ });
212
+
213
+ if (previous !== undefined && previous.path !== plan.directory) {
214
+ blankLine();
215
+ log(` This replaces an earlier link to ${previous.path}, which is untouched.`);
216
+ }
217
+
218
+ warning(
219
+ `The repository '${name}' is now LINKED.\n` +
220
+ `Its recipes are read from the checkout above, so versions, the lockfile\n` +
221
+ `and freshness checks no longer apply to it. Builds say so every time.\n` +
222
+ `\n` +
223
+ `Run 'sous repo unlink ${name}${isGlobal ? " --global" : ""}' to go back to ` +
224
+ `published versions.`
225
+ );
226
+
227
+ footer();
228
+ }
229
+
230
+ /**
231
+ * The checkout a REPO argument names outright, as an absolute path, or
232
+ * undefined when the argument is a short name or a URL instead.
233
+ *
234
+ * A repository this project has already added wins, because a short name is
235
+ * what a person types most often and a directory of the same name sitting in
236
+ * the working directory must not quietly take its place. Anything else that
237
+ * reads as a path is checked here rather than later: the directory has to
238
+ * exist and hold a repo manifest, and the message explains the path itself
239
+ * when it does not.
240
+ *
241
+ * @param input - The repo argument as the user typed it.
242
+ */
243
+ private checkoutInRepoSlot(input: string): string | undefined {
244
+ if (enabledRepos(this.settings)[input] !== undefined) return undefined;
245
+ if (!looksLikeLocalPath(input)) return undefined;
246
+
247
+ const resolved = resolveRepoArgument(input);
248
+ assertLocalRepoDirectory(input, resolved);
249
+ return resolved;
250
+ }
251
+
252
+ /**
253
+ * Works out which repository is being linked and where it lives. A short name
254
+ * is looked up in the project's `repos:` config, which is where `sous repo
255
+ * add` records a trusted repository.
256
+ *
257
+ * Neither a URL nor a path is taken on its own. Linking reads recipes straight
258
+ * out of a checkout, so either one, for a repository this project has not
259
+ * added, goes through `addRepo`, which is the trust ceremony: it asks (or
260
+ * requires `--trust`), writes the repository into the managed layer, and
261
+ * refuses outright when the short name it derives already belongs to a
262
+ * different repository. Only then is anything linked or cloned.
263
+ *
264
+ * @param input - The repo argument as the user typed it.
265
+ * @param trustFlag - The `--trust` flag, passed through to the ceremony.
266
+ * @param dryRun - When true, nothing is trusted, written or downloaded.
267
+ * @param checkout - The checkout the repo argument named, when it named one.
268
+ */
269
+ private async resolveRepo(
270
+ input: string,
271
+ trustFlag: boolean,
272
+ dryRun: boolean,
273
+ checkout?: string
274
+ ): Promise<{ name: string; url?: string }> {
275
+ const configured = enabledRepos(this.settings)[input];
276
+ if (configured !== undefined) {
277
+ return { name: input, url: configured.url };
278
+ }
279
+
280
+ // A checkout named in the REPO slot is registered from where it already is;
281
+ // its own manifest suggests the short name, falling back to the directory's
282
+ // name, which is what `addRepo` uses when it is given none.
283
+ if (checkout !== undefined) {
284
+ const suggested = suggestedShortName(checkout);
285
+ const outcome = await this.addThroughCeremony(
286
+ checkout,
287
+ trustFlag,
288
+ dryRun,
289
+ suggested === undefined ? {} : { name: suggested }
290
+ );
291
+ return { name: outcome.name, url: outcome.url };
292
+ }
293
+
294
+ if (looksLikeRepoUrl(input)) {
295
+ const outcome = await this.addThroughCeremony(input, trustFlag, dryRun, {});
296
+ return { name: outcome.name, url: outcome.url };
297
+ }
298
+
299
+ const known = Object.keys(enabledRepos(this.settings)).sort();
300
+ const knownList =
301
+ known.length > 0
302
+ ? ` This project knows about: ${known.join(", ")}.\n`
303
+ : " This project has no repositories configured yet.\n";
304
+
305
+ throw new ConfigError(
306
+ `'${input}' is not a repository this project knows about, and it is neither a URL ` +
307
+ `nor the path of a checkout on this machine.\n` +
308
+ knownList +
309
+ ` Add the repository first with 'sous repo add <url>', then link it by its ` +
310
+ `short name.`
311
+ );
312
+ }
313
+
314
+ /**
315
+ * Runs the trust ceremony for a repository this project has not added, and
316
+ * reports the name and location it was recorded under. Nothing is trusted,
317
+ * written or downloaded on a dry run.
318
+ *
319
+ * @param location - The repository's URL, or the absolute path of one on this machine.
320
+ * @param trustFlag - The `--trust` flag, passed through to the ceremony.
321
+ * @param dryRun - When true, work out what would happen and write nothing.
322
+ * @param naming - The short name to record it under, when one has been worked out.
323
+ */
324
+ private async addThroughCeremony(
325
+ location: string,
326
+ trustFlag: boolean,
327
+ dryRun: boolean,
328
+ naming: { name?: string }
329
+ ): Promise<{ name: string; url: string }> {
330
+ const service = subscriptionServiceFor({
331
+ configContext: this.configContext,
332
+ settings: this.settings,
333
+ shellEnv: this.shellEnv,
334
+ });
335
+
336
+ const outcome = await service.addRepo({
337
+ url: location,
338
+ ...naming,
339
+ trust: trustFlag,
340
+ dryRun,
341
+ });
342
+
343
+ return { name: outcome.name, url: outcome.url };
344
+ }
345
+
346
+ /**
347
+ * Plans a link to a checkout that already exists. The directory must be there
348
+ * and must hold a repo manifest, because a directory without one is not a
349
+ * repository and linking it would fail later, further from the mistake.
350
+ *
351
+ * @param given - The path as the user typed it, resolved against the working directory.
352
+ */
353
+ private planLinkToPath(given: string): LinkPlan {
354
+ const directory = path.resolve(process.cwd(), expandHomePath(given));
355
+
356
+ if (!fs.existsSync(directory)) {
357
+ throw new ConfigError(
358
+ `There is no directory at ${directory}.\n` +
359
+ ` Pass the path of a checkout that already exists, or leave the path off ` +
360
+ `to have sous clone the repository for you.`
361
+ );
362
+ }
363
+
364
+ if (!fs.statSync(directory).isDirectory()) {
365
+ throw new ConfigError(
366
+ `${directory} is a file, not a directory.\n` +
367
+ ` Pass the root directory of a repository checkout.`
368
+ );
369
+ }
370
+
371
+ const manifest = findRepoManifest(directory);
372
+ if (manifest === undefined) {
373
+ throw new ConfigError(
374
+ `${directory} is not a sous repository.\n` +
375
+ ` A repository declares itself with a '${REPO_MANIFEST_BASENAME}` +
376
+ `${MANIFEST_EXTENSIONS[0]}' file at its root, and there is none there.\n` +
377
+ ` Check the path, or create a repository with 'sous repo init'.`
378
+ );
379
+ }
380
+
381
+ return {
382
+ directory,
383
+ origin: "path",
384
+ notes: [`Linked the checkout already at ${directory}.`],
385
+ };
386
+ }
387
+
388
+ /**
389
+ * Plans a link backed by a clone. A directory that is already a checkout of
390
+ * the same repository is reused rather than re-cloned, so running the command
391
+ * twice is harmless; a checkout of a different repository in the same place is
392
+ * an error, because silently reading the wrong recipes would be worse than
393
+ * stopping.
394
+ *
395
+ * @param name - The repository's short name.
396
+ * @param url - Where the repository lives.
397
+ * @param isGlobal - Whether the checkout is shared by every project on the machine.
398
+ * @param dryRun - When true, work the plan out but clone nothing.
399
+ */
400
+ private planClone(
401
+ name: string,
402
+ url: string | undefined,
403
+ isGlobal: boolean,
404
+ dryRun: boolean
405
+ ): LinkPlan {
406
+ if (url === undefined) {
407
+ throw new ConfigError(
408
+ `sous does not know where the repository '${name}' lives, so it cannot clone it.\n` +
409
+ ` Add it with 'sous repo add <url>', or pass the path of a checkout that ` +
410
+ `already exists.`
411
+ );
412
+ }
413
+
414
+ const slug = repoSlugFromUrl(url);
415
+ const base = isGlobal ? globalReposDir() : projectReposDir(this.configContext.sousDir);
416
+ const directory = path.join(base, slug.owner, slug.name);
417
+
418
+ if (isGitCheckout(directory)) {
419
+ const existing = remoteUrlOf(directory);
420
+ if (existing !== undefined && !sameRemote(existing, url)) {
421
+ throw new ConfigError(
422
+ `${directory} is already a checkout of a different repository.\n` +
423
+ ` It points at ${existing}, but '${name}' is ${url}.\n` +
424
+ ` Move or delete that directory, or pass an explicit path to link the ` +
425
+ `checkout you meant.`
426
+ );
427
+ }
428
+
429
+ return {
430
+ directory,
431
+ origin: "clone",
432
+ notes: [
433
+ `Reused the checkout already at ${directory}; nothing was cloned.`,
434
+ ],
435
+ };
436
+ }
437
+
438
+ if (dryRun) {
439
+ return {
440
+ directory,
441
+ origin: "clone",
442
+ notes: [`Would clone ${url} into ${directory}.`],
443
+ };
444
+ }
445
+
446
+ // The directory holding the checkouts gets its README before the clone puts
447
+ // anything in it, whichever scope the link is for.
448
+ if (isGlobal) ensureGlobalReposDirectory(base);
449
+ else ensureProjectReposDirectory(base);
450
+
451
+ log(` Cloning ${url} into ${directory} ...`);
452
+ const clone = cloneRepo(url, directory);
453
+
454
+ const manifest = findRepoManifest(directory);
455
+ if (manifest === undefined) {
456
+ throw new ConfigError(
457
+ `${url} was cloned into ${directory}, but it is not a sous repository.\n` +
458
+ ` A repository declares itself with a '${REPO_MANIFEST_BASENAME}` +
459
+ `${MANIFEST_EXTENSIONS[0]}' file at its root, and there is none there.\n` +
460
+ ` The checkout has been left in place so you can look at it.`
461
+ );
462
+ }
463
+
464
+ const notes = [`Cloned ${url} into ${directory}.`];
465
+ if (clone.fellBackToFullClone) {
466
+ notes.push(
467
+ "That remote would not serve a shallow clone, so the full history was fetched."
468
+ );
469
+ }
470
+
471
+ return { directory, origin: "clone", notes };
472
+ }
473
+ }
474
+
475
+ /**
476
+ * The short name a checkout suggests for itself: the `name` its repo manifest
477
+ * declares. Undefined when the manifest cannot be read or validated, which
478
+ * leaves the caller with the directory's own name; a working copy is edited by
479
+ * hand and is allowed to be mid-change, so an unreadable manifest is not a
480
+ * reason to refuse the link.
481
+ *
482
+ * @param directory - The checkout's root directory.
483
+ */
484
+ function suggestedShortName(directory: string): string | undefined {
485
+ try {
486
+ return readRepoManifestIn(directory)?.name;
487
+ } catch {
488
+ return undefined;
489
+ }
490
+ }
491
+
492
+ /** Where a link will point, how the working copy got there, and what to report. */
493
+ type LinkPlan = {
494
+ /** Absolute path to the working copy. */
495
+ directory: string;
496
+ /** Whether sous cloned it or was pointed at it. */
497
+ origin: LinkOrigin;
498
+ /** Lines describing what happened, printed before the summary. */
499
+ notes: string[];
500
+ };
@@ -0,0 +1,179 @@
1
+ /**
2
+ * `sous repo list`.
3
+ *
4
+ * Shows every repository this project trusts, what it publishes, and whether it
5
+ * is currently being read from a working copy instead of a published version.
6
+ * It reads only what sous already has on disk: a repository whose index has
7
+ * never been fetched says so in its own row rather than triggering a download,
8
+ * so the command is safe to run offline.
9
+ */
10
+
11
+ import { Flags } from "@oclif/core";
12
+ import { color } from "@oclif/color";
13
+ import { BaseCommand } from "../../base-command.js";
14
+ import { subscriptionServiceFor } from "../../lib/repos/subscription-service.js";
15
+ import { readEffectiveLinks } from "../../lib/repos/links.js";
16
+ import { BUILT_IN_ADDED_BY } from "../../lib/repos/defaults.js";
17
+ import { requireProvider } from "../../lib/repos/providers/index.js";
18
+ import { renderTable, type TableColumn } from "../../utils/table.js";
19
+ import {
20
+ footer,
21
+ indent,
22
+ log,
23
+ paragraph,
24
+ section,
25
+ showCommandVars,
26
+ } from "../../utils/formatting.js";
27
+
28
+ /** How far every line of this command's output is indented. */
29
+ const INDENT = 2;
30
+
31
+ /**
32
+ * The columns the listing shows, widest-mattering first. The URL is the column
33
+ * that steps aside on a narrow terminal: it is the longest and the least often
34
+ * read, and its middle is what a cut gives up, so the host and the repository
35
+ * name both survive.
36
+ */
37
+ const COLUMNS: TableColumn[] = [
38
+ { key: "name", header: "Repository", overflow: "truncate", minWidth: 8 },
39
+ { key: "provider", header: "Provider", priority: "medium" },
40
+ { key: "origin", header: "Origin" },
41
+ {
42
+ key: "linked",
43
+ header: "Linked",
44
+ kind: "path",
45
+ overflow: "truncate",
46
+ priority: "medium",
47
+ minWidth: 6,
48
+ },
49
+ { key: "recipes", header: "Recipes", kind: "number", priority: "low", minWidth: 11 },
50
+ {
51
+ key: "url",
52
+ header: "URL",
53
+ kind: "url",
54
+ overflow: "truncate",
55
+ truncate: "middle",
56
+ priority: "low",
57
+ flex: 1,
58
+ minWidth: 12,
59
+ },
60
+ ];
61
+
62
+ export default class RepoList extends BaseCommand {
63
+ static description = "List the recipe repositories this project trusts";
64
+
65
+ /**
66
+ * The other spelling of the topic. It lives under a hidden topic, so it is
67
+ * typable everywhere without ever reaching the top-level listing.
68
+ */
69
+ static aliases = ["repos:list"];
70
+
71
+ static examples = [
72
+ "<%= config.bin %> repo list",
73
+ "<%= config.bin %> repo list --verbose",
74
+ ];
75
+
76
+ static flags = {
77
+ ...BaseCommand.baseFlags,
78
+ verbose: Flags.boolean({
79
+ description: "Show the namespaces each repository publishes, under its row",
80
+ default: false,
81
+ }),
82
+ };
83
+
84
+ async run(): Promise<void> {
85
+ const { flags } = await this.parse(RepoList);
86
+
87
+ showCommandVars({
88
+ Project: this.projectLabel,
89
+ Config: this.configContext.configPath,
90
+ });
91
+
92
+ section("Repositories this project trusts");
93
+
94
+ const service = subscriptionServiceFor({
95
+ configContext: this.configContext,
96
+ settings: this.settings,
97
+ shellEnv: this.shellEnv,
98
+ });
99
+
100
+ const repos = service.currentRepos();
101
+ const names = Object.keys(repos).sort();
102
+
103
+ if (names.length === 0) {
104
+ paragraph(
105
+ "This project trusts no repositories yet. Add one with " +
106
+ "'sous repo add <url>'; adding a repository is how you trust it."
107
+ );
108
+ footer();
109
+ return;
110
+ }
111
+
112
+ const links = readEffectiveLinks(this.configContext.sousDir);
113
+
114
+ const rows = names.map((name) => {
115
+ const entry = repos[name]!;
116
+ const index = service.cachedIndex(name);
117
+ const namespaces =
118
+ index === undefined ? "not fetched" : Object.keys(index.namespaces).sort().join(", ");
119
+ // A repository whose index has never been fetched says so in the cell
120
+ // itself. The count is not unknown in any interesting sense; sous simply
121
+ // has not downloaded the one file that holds it, and saying that in the
122
+ // row saves a note under the table.
123
+ const recipes =
124
+ index === undefined ? "not fetched" : String(Object.keys(index.recipes).length);
125
+ return {
126
+ name,
127
+ url: entry.url,
128
+ provider: describeProvider(entry.url, entry.provider),
129
+ origin: describeOrigin(entry.addedBy),
130
+ namespaces: namespaces.length > 0 ? namespaces : "none",
131
+ recipes,
132
+ linked: links[name] === undefined ? "no" : `yes: ${links[name]!.path}`,
133
+ };
134
+ });
135
+
136
+ for (const line of renderTable(COLUMNS, rows, {
137
+ indent: INDENT,
138
+ rowNote: flags.verbose
139
+ ? (row) => color.gray(indent(`Namespaces: ${row.namespaces}`, INDENT))
140
+ : undefined,
141
+ })) {
142
+ log(indent(line, INDENT));
143
+ }
144
+
145
+ // Everything the table can say, the table says; nothing goes under it.
146
+ footer();
147
+ }
148
+ }
149
+
150
+ /**
151
+ * The identifier of the provider that actually handles a repository entry: the
152
+ * one the entry names, otherwise the one that recognizes its URL. The column
153
+ * reports the provider doing the work, not how sous arrived at it, so an entry
154
+ * that leaves `provider` out still reads `local` or `github` rather than a note
155
+ * about detection. An entry no provider can claim reads `unknown`; the listing
156
+ * is a read-only view of what is on disk and refuses nothing.
157
+ *
158
+ * @param url - The repository entry's URL.
159
+ * @param providerId - The provider the entry named, when it named one.
160
+ */
161
+ function describeProvider(url: string, providerId: string | undefined): string {
162
+ try {
163
+ return requireProvider(url, providerId).id;
164
+ } catch {
165
+ return "unknown";
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Plain-language wording for a repository entry's `addedBy` field, so the table
171
+ * says who wanted the repository rather than printing a bare marker value.
172
+ *
173
+ * @param addedBy - What the entry recorded, when it recorded anything.
174
+ */
175
+ function describeOrigin(addedBy: string | undefined): string {
176
+ if (addedBy === BUILT_IN_ADDED_BY) return "built in";
177
+ if (addedBy === undefined || addedBy === "user") return "user";
178
+ return `required by ${addedBy}`;
179
+ }