@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,208 @@
1
+ /**
2
+ * Freshness: when sous bothers to look upstream.
3
+ *
4
+ * A build does not want to talk to the network on every run, and a lockfile
5
+ * means it usually has no reason to. So the rule is deliberately dull: a
6
+ * repository is checked when it has never been checked, when the freshness
7
+ * window has lapsed since the last check, or when something asks to always
8
+ * pull. Watch mode passes a shorter window; a one-off command can force it.
9
+ *
10
+ * The last-check time is remembered in the sidecar the index cache already
11
+ * writes, so there is one record per repository rather than two.
12
+ *
13
+ * A failed check NEVER breaks a build. That belongs to the index cache, which
14
+ * falls back to the copy it already holds; this module only decides whether to
15
+ * look.
16
+ */
17
+
18
+ import semver from "semver";
19
+ import type { IndexFile } from "./formats/index-file.js";
20
+ import type { IndexMeta } from "./providers/index-cache.js";
21
+
22
+ /** How long a check stays good when nothing says otherwise: five minutes. */
23
+ // The freshness default is shared with the store settings so there is one number.
24
+ import { DEFAULT_FRESHNESS_SECONDS } from "./store/settings.js";
25
+
26
+ /** The part of the index cache this module uses; IndexCache satisfies it. */
27
+ export type UpstreamCheckRecord = {
28
+ readMeta(repoName: string): IndexMeta | undefined;
29
+ writeMeta(repoName: string, meta: IndexMeta): void;
30
+ };
31
+
32
+ /** What decides whether to look upstream. */
33
+ export type FreshnessInput = {
34
+ /** When sous last asked upstream, from the repository's sidecar. */
35
+ lastCheckedAt?: string;
36
+ /** How long a check stays good, in seconds. Defaults to five minutes. */
37
+ freshnessSeconds?: number;
38
+ /**
39
+ * Whether something in play prefers a newer in-range version over the locked
40
+ * one. Always-pull still respects the freshness window; it changes what
41
+ * happens after the check, not how often the check happens.
42
+ */
43
+ alwaysPull?: boolean;
44
+ /** Check regardless, which is what an explicit update command does. */
45
+ force?: boolean;
46
+ /** The clock. Defaults to now. */
47
+ now?: Date;
48
+ };
49
+
50
+ /**
51
+ * True when sous should ask upstream whether there is anything newer.
52
+ *
53
+ * shouldCheckUpstream({ lastCheckedAt: "2026-01-01T00:00:00.000Z", freshnessSeconds: 300 })
54
+ * // -> false a minute later, true six minutes later
55
+ *
56
+ * @param input - The last check, the window, and the flags.
57
+ */
58
+ export function shouldCheckUpstream(input: FreshnessInput): boolean {
59
+ if (input.force === true) return true;
60
+ if (input.lastCheckedAt === undefined) return true;
61
+
62
+ const last = Date.parse(input.lastCheckedAt);
63
+ if (Number.isNaN(last)) return true;
64
+
65
+ const now = (input.now ?? new Date()).getTime();
66
+ const windowSeconds = input.freshnessSeconds ?? DEFAULT_FRESHNESS_SECONDS;
67
+ if (windowSeconds <= 0) return true;
68
+
69
+ const ageSeconds = (now - last) / 1000;
70
+ // A last-check time in the future means a clock changed under sous; check
71
+ // rather than trusting an answer that cannot be right.
72
+ if (ageSeconds < 0) return true;
73
+ return ageSeconds >= windowSeconds;
74
+ }
75
+
76
+ /**
77
+ * Records that sous just looked upstream for a repository, whether or not the
78
+ * look succeeded. Recording a failed check too is what stops an unreachable
79
+ * host from being retried on every single build.
80
+ *
81
+ * @param record - The index cache, or anything with its sidecar methods.
82
+ * @param identity - The repository's canonical identity, which the cache is keyed by.
83
+ * @param when - The moment to record. Defaults to now.
84
+ */
85
+ export function recordUpstreamCheck(
86
+ record: UpstreamCheckRecord,
87
+ identity: string,
88
+ when: Date = new Date()
89
+ ): IndexMeta {
90
+ const existing = record.readMeta(identity);
91
+ const meta: IndexMeta = {
92
+ fetchedAt: existing?.fetchedAt ?? when.toISOString(),
93
+ ...(existing?.etag === undefined ? {} : { etag: existing.etag }),
94
+ ...(existing?.ref === undefined ? {} : { ref: existing.ref }),
95
+ lastCheckedAt: when.toISOString(),
96
+ };
97
+ record.writeMeta(identity, meta);
98
+ return meta;
99
+ }
100
+
101
+ /** What an always-pull check found. */
102
+ export type NewerVersion = {
103
+ /** The recipe key. */
104
+ key: string;
105
+ /** The version currently locked. */
106
+ from: string;
107
+ /** The newer version that also satisfies the range. */
108
+ to: string;
109
+ };
110
+
111
+ /**
112
+ * Finds a newer in-range version of a locked recipe, or undefined when the
113
+ * locked one is still the best the range allows. This is the whole of what
114
+ * always-pull does: it re-resolves WITHIN the declared range and never widens
115
+ * it, so a dependency's constraint still holds.
116
+ *
117
+ * @param options - The index, the recipe, its locked version and its range.
118
+ */
119
+ export function findNewerInRange(options: {
120
+ /** The repository's index. */
121
+ index: IndexFile;
122
+ /** The recipe key, `namespace/recipe`. */
123
+ key: string;
124
+ /** The version the lockfile pins. */
125
+ lockedVersion: string;
126
+ /** The range the subscription or dependency declared. Defaults to any version. */
127
+ range?: string;
128
+ /** Whether prereleases take part. */
129
+ prerelease?: boolean;
130
+ }): NewerVersion | undefined {
131
+ const entry = options.index.recipes[options.key];
132
+ if (entry === undefined) return undefined;
133
+
134
+ const prerelease = options.prerelease ?? false;
135
+ const candidates = Object.keys(entry.versions).filter((version) => {
136
+ if (!prerelease && entry.versions[version]!.prerelease === true) return false;
137
+ return semver.satisfies(version, options.range ?? "*", { includePrerelease: prerelease });
138
+ });
139
+
140
+ const best = semver.maxSatisfying(candidates, "*", { includePrerelease: prerelease });
141
+ if (best === null) return undefined;
142
+ if (semver.lte(best, options.lockedVersion)) return undefined;
143
+
144
+ return { key: options.key, from: options.lockedVersion, to: best };
145
+ }
146
+
147
+ /** The holder meaning the project subscribed to a recipe itself. */
148
+ const PROJECT = "project";
149
+
150
+ /** Every range a locked recipe's holders declared, or nothing when one cannot be told. */
151
+ export type DeclaredRangeLookup = {
152
+ /**
153
+ * The range the project's subscription declares for a recipe key, `"*"` when
154
+ * the subscription declares none, or undefined when the project holds nothing
155
+ * matching it any more.
156
+ */
157
+ subscriptionRange(key: string): string | undefined;
158
+ /**
159
+ * The range one holding recipe's manifest declares for a dependency, `"*"`
160
+ * when it declares none, or undefined when its manifest cannot be read or no
161
+ * longer names that dependency.
162
+ */
163
+ dependencyRange(holder: string, key: string): string | undefined;
164
+ };
165
+
166
+ /**
167
+ * The version range an always-pull check may move ONE locked recipe within, or
168
+ * undefined when sous cannot tell and therefore must not move it at all.
169
+ *
170
+ * Always-pull re-resolves within what was declared; it never widens it. What was
171
+ * declared depends on who holds the recipe. The project's own hold means the
172
+ * subscription's range, which may genuinely be any version. A hold by another
173
+ * recipe means the range that recipe's manifest declared in `depends`, and no
174
+ * subscription anywhere carries it, so falling back to `*` for such an entry
175
+ * would move it straight past the constraint the dependency declared. Several
176
+ * holders mean every one of their ranges at once: whitespace between comparator
177
+ * sets is AND in semver, which is exactly that.
178
+ *
179
+ * Returning undefined is the safe answer, and the entry stays where the lockfile
180
+ * pins it. It happens when a holder's declaration cannot be read and when the
181
+ * combined range is not one semver can express.
182
+ *
183
+ * @param key - The locked recipe key, `namespace/recipe`.
184
+ * @param requestedBy - Its lockfile holders: "project", and any recipe keys.
185
+ * @param lookup - How to read what each holder declared.
186
+ */
187
+ export function effectiveRangeForHolders(
188
+ key: string,
189
+ requestedBy: readonly string[],
190
+ lookup: DeclaredRangeLookup
191
+ ): string | undefined {
192
+ const ranges: string[] = [];
193
+
194
+ for (const holder of requestedBy) {
195
+ const declared =
196
+ holder === PROJECT ? lookup.subscriptionRange(key) : lookup.dependencyRange(holder, key);
197
+ if (declared === undefined) return undefined;
198
+ ranges.push(declared);
199
+ }
200
+
201
+ if (ranges.length === 0) return undefined;
202
+
203
+ const constraints = [...new Set(ranges.filter((range) => range !== "*"))];
204
+ if (constraints.length === 0) return "*";
205
+
206
+ const combined = constraints.join(" ");
207
+ return semver.validRange(combined) === null ? undefined : combined;
208
+ }
@@ -0,0 +1,312 @@
1
+ /**
2
+ * The thin git layer used by `sous repo link`.
3
+ *
4
+ * Linking a repository sometimes means cloning it, and always means asking
5
+ * whether a directory already on disk is the right checkout. Both jobs are done
6
+ * by shelling out to the user's own `git`, rather than by bundling a git
7
+ * implementation: the user's credentials, SSH agent, proxy settings and
8
+ * `insteadOf` rewrites are already configured there, and sous inheriting all of
9
+ * that for free is worth more than any library.
10
+ *
11
+ * Every function takes an optional `runner`, so tests can drive the whole
12
+ * surface without a real git or a network connection.
13
+ */
14
+
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { spawnSync } from "node:child_process";
18
+ import { ConfigError } from "../errors.js";
19
+
20
+ /** What one git invocation produced. */
21
+ export type GitResult = {
22
+ /** The process exit code, or null when it was killed by a signal. */
23
+ status: number | null;
24
+ /** Everything git wrote to standard output, trimmed. */
25
+ stdout: string;
26
+ /** Everything git wrote to standard error, trimmed. */
27
+ stderr: string;
28
+ };
29
+
30
+ /**
31
+ * Runs one git command. Swappable so tests never need a real git binary.
32
+ *
33
+ * @param args - The arguments passed to git, without the leading "git".
34
+ * @param options - Where to run it.
35
+ */
36
+ export type GitRunner = (args: string[], options: { cwd?: string }) => GitResult;
37
+
38
+ /** Options shared by every function here. */
39
+ export type GitOptions = {
40
+ /** The git runner to use. Defaults to the real `git` on PATH. */
41
+ runner?: GitRunner;
42
+ };
43
+
44
+ /**
45
+ * The default runner: invokes the real `git` on PATH and captures its output.
46
+ *
47
+ * @param args - The arguments passed to git, without the leading "git".
48
+ * @param options - Where to run it.
49
+ */
50
+ export const runGit: GitRunner = (args, options = {}) => {
51
+ const result = spawnSync("git", args, {
52
+ cwd: options.cwd,
53
+ encoding: "utf8",
54
+ // A clone must never stop to ask for a password; a prompt in a
55
+ // non-interactive run would hang the command with no explanation.
56
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
57
+ });
58
+
59
+ if (result.error !== undefined) {
60
+ const reason = (result.error as NodeJS.ErrnoException).code === "ENOENT"
61
+ ? "git is not installed, or is not on your PATH"
62
+ : result.error.message;
63
+ throw new ConfigError(
64
+ `Could not run git.\n` +
65
+ ` ${reason}.\n` +
66
+ ` sous uses your own git so that your credentials and configuration apply; ` +
67
+ `install git and try again.`
68
+ );
69
+ }
70
+
71
+ return {
72
+ status: result.status,
73
+ stdout: (result.stdout ?? "").trim(),
74
+ stderr: (result.stderr ?? "").trim(),
75
+ };
76
+ };
77
+
78
+ /** Runs git and throws a ConfigError, carrying git's own message, on failure. */
79
+ function runGitOrThrow(
80
+ args: string[],
81
+ options: { cwd?: string; runner?: GitRunner; what: string }
82
+ ): GitResult {
83
+ const runner = options.runner ?? runGit;
84
+ const result = runner(args, { cwd: options.cwd });
85
+ if (result.status !== 0) {
86
+ const detail = result.stderr.length > 0 ? result.stderr : result.stdout;
87
+ throw new ConfigError(
88
+ `${options.what} failed.\n` +
89
+ ` Command: git ${args.join(" ")}\n` +
90
+ (detail.length > 0 ? ` git said: ${detail}\n` : "") +
91
+ ` Fix the problem git reported, then run the command again.`
92
+ );
93
+ }
94
+ return result;
95
+ }
96
+
97
+ /**
98
+ * True when the directory is inside a git working tree whose root is that same
99
+ * directory. A subdirectory of a checkout is deliberately not a checkout here:
100
+ * linking half a repository would silently produce a repo with no manifest at
101
+ * its root.
102
+ *
103
+ * @param directory - The directory to test.
104
+ * @param options - The git runner to use.
105
+ */
106
+ export function isGitCheckout(directory: string, options: GitOptions = {}): boolean {
107
+ if (!directoryExists(directory)) return false;
108
+
109
+ const runner = options.runner ?? runGit;
110
+ const result = runner(["rev-parse", "--show-toplevel"], { cwd: directory });
111
+ if (result.status !== 0) return false;
112
+
113
+ return samePath(result.stdout, directory);
114
+ }
115
+
116
+ /**
117
+ * The URL of a checkout's `origin` remote, or undefined when it has none (a
118
+ * repository created locally with `git init` has no remote until one is added).
119
+ *
120
+ * @param directory - The checkout to inspect.
121
+ * @param options - The git runner to use.
122
+ */
123
+ export function remoteUrlOf(directory: string, options: GitOptions = {}): string | undefined {
124
+ const runner = options.runner ?? runGit;
125
+ const result = runner(["remote", "get-url", "origin"], { cwd: directory });
126
+ if (result.status !== 0 || result.stdout.length === 0) return undefined;
127
+ return result.stdout;
128
+ }
129
+
130
+ /** What a clone actually did. */
131
+ export type CloneResult = {
132
+ /** The depth git was finally asked for; 0 means the full history. */
133
+ depth: number;
134
+ /** True when a shallow clone was refused and the full history was fetched instead. */
135
+ fellBackToFullClone: boolean;
136
+ };
137
+
138
+ /**
139
+ * Clones a repository into a directory that does not yet exist, creating its
140
+ * parents. A shallow clone is the default for a link, because a linked checkout
141
+ * exists to be read and edited, not to carry the project's whole history; pass
142
+ * `depth: 0` to ask for the full history outright.
143
+ *
144
+ * Not every remote will serve a shallow clone (`file://` transports and some
145
+ * servers refuse one), so a failed shallow attempt is retried in full rather
146
+ * than reported as a failure. The result says whether that happened, so the
147
+ * caller can tell the user why the clone took longer than they expected.
148
+ *
149
+ * @param url - Where the repository lives.
150
+ * @param destDir - Absolute path the working copy is created at.
151
+ * @param options - Clone depth and the git runner to use.
152
+ */
153
+ export function cloneRepo(
154
+ url: string,
155
+ destDir: string,
156
+ options: GitOptions & { depth?: number } = {}
157
+ ): CloneResult {
158
+ if (fs.existsSync(destDir) && !isEmptyDirectory(destDir)) {
159
+ throw new ConfigError(
160
+ `Cannot clone into ${destDir}: the directory already exists and is not empty.\n` +
161
+ ` Move or delete it, or link the checkout that is already there by passing ` +
162
+ `its path to 'sous repo link'.`
163
+ );
164
+ }
165
+
166
+ fs.mkdirSync(path.dirname(destDir), { recursive: true });
167
+
168
+ const depth = options.depth ?? 1;
169
+ const runner = options.runner ?? runGit;
170
+
171
+ if (depth > 0) {
172
+ const shallow = runner(["clone", "--depth", String(depth), "--", url, destDir], {});
173
+ if (shallow.status === 0) return { depth, fellBackToFullClone: false };
174
+ // git leaves the destination behind on some failures; clear it so the retry
175
+ // is not refused by its own leftovers.
176
+ fs.rmSync(destDir, { recursive: true, force: true });
177
+ }
178
+
179
+ runGitOrThrow(["clone", "--", url, destDir], {
180
+ runner: options.runner,
181
+ what: `Cloning ${url}`,
182
+ });
183
+
184
+ return { depth: 0, fellBackToFullClone: depth > 0 };
185
+ }
186
+
187
+ /**
188
+ * True when two remote URLs name the same repository, ignoring the differences
189
+ * that never change what is fetched: a `.git` suffix, a trailing slash, the
190
+ * host's letter case, and SCP-style syntax (`git@host:owner/repo`) against URL
191
+ * syntax (`https://host/owner/repo`).
192
+ *
193
+ * @param a - One remote URL.
194
+ * @param b - The other remote URL.
195
+ */
196
+ export function sameRemote(a: string, b: string): boolean {
197
+ return normalizeRemoteUrl(a) === normalizeRemoteUrl(b);
198
+ }
199
+
200
+ /**
201
+ * Reduces a remote URL to `host/path` in lower case, with any `.git` suffix,
202
+ * trailing slash, scheme, port and user info removed, so two spellings of one
203
+ * repository compare equal.
204
+ *
205
+ * @param url - The remote URL to normalize.
206
+ */
207
+ export function normalizeRemoteUrl(url: string): string {
208
+ let value = url.trim();
209
+
210
+ // SCP-style syntax, which is not a URL: git@github.com:sous-io/sous.git
211
+ const scp = /^(?:[^@/]+@)?([^/:]+):(?!\/\/)(.+)$/.exec(value);
212
+ if (scp !== null) {
213
+ value = `${scp[1]}/${scp[2]}`;
214
+ } else {
215
+ value = value.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, "");
216
+ // Strip user info (user@ or user:password@) from an authority.
217
+ value = value.replace(/^[^/]*@/, "");
218
+ // Strip a port from the host.
219
+ value = value.replace(/^([^/]+):\d+/, "$1");
220
+ }
221
+
222
+ value = value.replace(/\/+$/, "");
223
+ value = value.replace(/\.git$/i, "");
224
+ return value.toLowerCase();
225
+ }
226
+
227
+ /** A repository's owner and name, as taken from its URL. */
228
+ export type RepoSlug = {
229
+ /** The owning user or organization, or "repos" when the URL has no owner segment. */
230
+ owner: string;
231
+ /** The repository's own name, with any `.git` suffix removed. */
232
+ name: string;
233
+ };
234
+
235
+ /**
236
+ * Splits a remote URL into the owner and name that decide where a default clone
237
+ * lands (`.sous/repos/<owner>/<name>`). A URL with no owner segment yields the
238
+ * literal owner "repos", so the layout stays two levels deep whatever the URL
239
+ * looked like.
240
+ *
241
+ * @param url - The remote URL to read.
242
+ */
243
+ export function repoSlugFromUrl(url: string): RepoSlug {
244
+ const normalized = normalizeRemoteUrl(url);
245
+ const segments = normalized.split("/").filter((segment) => segment.length > 0);
246
+
247
+ if (segments.length < 2) {
248
+ throw new ConfigError(
249
+ `Could not work out an owner and a repository name from the URL '${url}'.\n` +
250
+ ` A repository URL looks like 'https://github.com/sous-io/sous-recipes'. ` +
251
+ `Pass a path to 'sous repo link' to link a checkout that is already on disk.`
252
+ );
253
+ }
254
+
255
+ const name = segments[segments.length - 1]!;
256
+ const owner = segments.length >= 3 ? segments[segments.length - 2]! : "repos";
257
+ return { owner: sanitizeSegment(owner), name: sanitizeSegment(name) };
258
+ }
259
+
260
+ /**
261
+ * The short name a repository is known by when its URL was given on the command
262
+ * line rather than configured: the last segment of the URL, with any `.git`
263
+ * suffix removed.
264
+ *
265
+ * @param url - The remote URL to read.
266
+ */
267
+ export function repoNameFromUrl(url: string): string {
268
+ return repoSlugFromUrl(url).name;
269
+ }
270
+
271
+ /**
272
+ * True when the string looks like something git can clone rather than a short
273
+ * name: an explicit scheme, SCP-style syntax, or an absolute path.
274
+ *
275
+ * @param value - The string to test.
276
+ */
277
+ export function looksLikeRepoUrl(value: string): boolean {
278
+ if (value.includes("://")) return true;
279
+ if (/^[^@/\s]+@[^/\s:]+:/.test(value)) return true;
280
+ return value.startsWith("/") || value.startsWith("./") || value.startsWith("../");
281
+ }
282
+
283
+ // --- Small filesystem helpers -------------------------------------------------------------------
284
+
285
+ /** Replaces anything outside a safe path segment, so a URL can never escape the store. */
286
+ function sanitizeSegment(segment: string): string {
287
+ const cleaned = segment.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^\.+/, "");
288
+ return cleaned.length > 0 ? cleaned : "repo";
289
+ }
290
+
291
+ /** True when the path exists and is a directory. */
292
+ function directoryExists(candidate: string): boolean {
293
+ try {
294
+ return fs.statSync(candidate).isDirectory();
295
+ } catch {
296
+ return false;
297
+ }
298
+ }
299
+
300
+ /** True when the path is a directory holding nothing at all. */
301
+ function isEmptyDirectory(candidate: string): boolean {
302
+ try {
303
+ return fs.readdirSync(candidate).length === 0;
304
+ } catch {
305
+ return false;
306
+ }
307
+ }
308
+
309
+ /** Compares two paths after resolving them, so a trailing slash never matters. */
310
+ function samePath(a: string, b: string): boolean {
311
+ return path.resolve(a) === path.resolve(b);
312
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Canonical repository identity: what the machine-wide store, the index cache
3
+ * and the lockfile key a repository by.
4
+ *
5
+ * A repository's short name (`sous-recipes`) is a CONSUMER's label. Two
6
+ * projects may call the same repository different things, and two different
7
+ * repositories may be called the same thing in two projects, so a short name
8
+ * can never key anything shared between projects. The identity can: it is
9
+ * derived from the location the repository actually lives at, so every project
10
+ * on a machine agrees about it.
11
+ *
12
+ * github.com/sous-io/sous-recipes
13
+ * gitlab.example.com/group/subgroup/project
14
+ * localhost/home/me/Projects/my-recipes
15
+ *
16
+ * The shape is `<host>/<owner path>/<name>`, lowercased, with any `.git`
17
+ * suffix already gone (the providers strip it while canonicalizing). Short
18
+ * names stay exactly where they were: in a project's config, in its lockfile
19
+ * keys, and in everything sous prints, because that is what a person typed.
20
+ */
21
+
22
+ import type { CanonicalRepo } from "./providers/provider.js";
23
+ import { REPO_NAME_PATTERN } from "./formats/patterns.js";
24
+
25
+ /**
26
+ * The canonical identity of a repository, as every machine-wide key spells it.
27
+ *
28
+ * repoIdentity({ host: "GitHub.com", owner: "sous-io", name: "sous-recipes" });
29
+ * // -> "github.com/sous-io/sous-recipes"
30
+ *
31
+ * @param canonical - The repository, taken apart by its provider.
32
+ */
33
+ export function repoIdentity(canonical: CanonicalRepo): string {
34
+ const name = canonical.name.replace(/\.git$/i, "");
35
+ return identitySegments(`${canonical.host}/${canonical.owner}/${name}`).join("/");
36
+ }
37
+
38
+ /**
39
+ * The path segments an identity is made of, with empty segments dropped and
40
+ * every segment lowercased. A local repository's owner is an absolute path, so
41
+ * its leading separator would otherwise produce an empty first segment.
42
+ *
43
+ * identitySegments("localhost//home/me/recipes");
44
+ * // -> ["localhost", "home", "me", "recipes"]
45
+ *
46
+ * @param identity - The identity, or the pieces of one joined with slashes.
47
+ */
48
+ export function identitySegments(identity: string): string[] {
49
+ return identity
50
+ .split("/")
51
+ .map((segment) => segment.trim().toLowerCase())
52
+ .filter((segment) => segment.length > 0);
53
+ }
54
+
55
+ /**
56
+ * A short name derived from an identity, used when sous has to name a
57
+ * repository a project has never added (a dependency's locator URL, for
58
+ * instance) before the person has chosen a name for it.
59
+ *
60
+ * shortNameFromIdentity("github.com/sous-io/sous-recipes"); // -> "sous-recipes"
61
+ *
62
+ * @param identity - The repository's canonical identity.
63
+ */
64
+ export function shortNameFromIdentity(identity: string): string {
65
+ const segments = identitySegments(identity);
66
+ const last = segments[segments.length - 1] ?? "";
67
+ const cleaned = last
68
+ .replace(/[^a-z0-9-]+/g, "-")
69
+ .replace(/-+/g, "-")
70
+ .replace(/^-+|-+$/g, "");
71
+
72
+ // A short name is lowercase kebab-case starting with a letter. A repository
73
+ // whose name starts with a digit, or is made entirely of characters a name
74
+ // may not carry, still needs something to be called.
75
+ if (REPO_NAME_PATTERN.test(cleaned)) return cleaned;
76
+ return cleaned.length === 0 ? "repository" : `repo-${cleaned}`;
77
+ }
78
+
79
+ /**
80
+ * True when two identities name the same repository. Identities are already
81
+ * normalized, so this is a plain comparison; it exists so callers read as what
82
+ * they mean rather than as a string equality.
83
+ *
84
+ * @param left - One identity.
85
+ * @param right - The other.
86
+ */
87
+ export function sameRepoIdentity(left: string, right: string): boolean {
88
+ return left === right;
89
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The Repositories layer: every on-disk format, the loaders that read them, and
3
+ * the ref parser that names what is inside them.
4
+ *
5
+ * Import from here rather than from the individual modules, so later phases
6
+ * (the store, the providers, the resolver, the CLI surface) have one place to
7
+ * look for what this layer offers.
8
+ */
9
+
10
+ // The regular expressions in formats/patterns.js reach here through
11
+ // formats/common.js, which re-exports them; listing patterns.js again would
12
+ // make every one of those names an ambiguous star export.
13
+ export * from "./formats/common.js";
14
+ export * from "./formats/repo-manifest.js";
15
+ export * from "./formats/recipe-manifest.js";
16
+ export * from "./formats/index-file.js";
17
+ export * from "./formats/lockfile.js";
18
+ export * from "./formats/store-entry.js";
19
+ export * from "./formats/links-map.js";
20
+ export * from "./load-manifest.js";
21
+ export * from "./ref.js";
22
+ export * from "./identity.js";
23
+
24
+ // Phase 2a: the machine-wide recipe store, under the user-level sous directory.
25
+ export * from "./store/contract.js";
26
+ export * from "./store/hash.js";
27
+ export * from "./store/recipe-store.js";
28
+ export * from "./store/settings.js";
29
+
30
+ // Phase 6a: the CLI surface for editable checkouts (`sous repo init`, `link`,
31
+ // `unlink`). The links map and its ignore hygiene, the thin git layer both of
32
+ // them use, and the scaffold that `repo init` writes.
33
+ export * from "./links.js";
34
+ export * from "./git-clone.js";
35
+ export * from "./scaffold/index.js";
36
+
37
+ // Phase 2b: the store contract, the providers, the resolver, the trust layer
38
+ // and the lockfile service.
39
+ export * from "./providers/index.js";
40
+ export * from "./resolver.js";
41
+ export * from "./managed-layer.js";
42
+ export * from "./trust.js";
43
+ export * from "./lock-service.js";
44
+ export * from "./freshness.js";
45
+
46
+ // Phase 3: the consumer surface. Where a locked recipe's files are, the
47
+ // namespace resolver and compile targets built from that, and the service the
48
+ // `repo`, `subscribe` and `unsubscribe` commands drive.
49
+ export * from "./locked-recipes.js";
50
+ export * from "./locked-namespace-resolver.js";
51
+ export * from "./subscription-service.js";
52
+
53
+ // Phase 7: the core namespace. The recipe that ships inside the package, the
54
+ // seed that puts it in the store on first run, and the built-in repository and
55
+ // subscription every project gets unless it opts out.
56
+ export * from "./core-recipe.js";
57
+ export * from "./seed.js";
58
+ export * from "./defaults.js";