@sous-io/sous 0.2.16 → 0.2.18

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 (33) hide show
  1. package/docs/markdown/commands.md +59 -13
  2. package/docs/markdown/repositories-authoring.md +75 -13
  3. package/docs/markdown/repositories-consuming.md +44 -2
  4. package/docs/markdown/repositories-file-formats.md +22 -1
  5. package/docs/markdown/repositories-providers.md +20 -10
  6. package/package.json +1 -1
  7. package/recipes/core/sous-skills/sous.recipe.yaml +8 -1
  8. package/src/commands/repo/release.ts +41 -0
  9. package/src/commands/repo/submit.ts +245 -35
  10. package/src/commands/repo/unlink.ts +333 -20
  11. package/src/commands/subscription/update.ts +215 -0
  12. package/src/lib/repos/formats/common.ts +20 -0
  13. package/src/lib/repos/formats/links-map.ts +5 -3
  14. package/src/lib/repos/formats/recipe-manifest.ts +7 -0
  15. package/src/lib/repos/formats/repo-manifest.ts +8 -0
  16. package/src/lib/repos/git-clone.ts +71 -0
  17. package/src/lib/repos/links.ts +2 -1
  18. package/src/lib/repos/locked-recipes.ts +22 -0
  19. package/src/lib/repos/providers/base.ts +33 -1
  20. package/src/lib/repos/providers/github.ts +275 -3
  21. package/src/lib/repos/providers/provider.ts +119 -3
  22. package/src/lib/repos/release/changelog.ts +448 -0
  23. package/src/lib/repos/release/git-state.ts +101 -15
  24. package/src/lib/repos/release/index.ts +2 -0
  25. package/src/lib/repos/release/submissions.ts +214 -0
  26. package/src/lib/repos/release/submit-checkout.ts +271 -0
  27. package/src/lib/repos/release/submit-questions.ts +153 -0
  28. package/src/lib/repos/release/submit-service.ts +581 -174
  29. package/src/lib/repos/resolver.ts +25 -2
  30. package/src/lib/repos/seed.ts +64 -5
  31. package/src/lib/repos/store/hash.ts +68 -8
  32. package/src/lib/repos/subscription-service.ts +744 -20
  33. package/src/lib/repos/update-plan.ts +234 -0
@@ -95,6 +95,14 @@ export type ResolveContext = {
95
95
  loadManifest: RecipeManifestLoader;
96
96
  /** Whether prereleases are allowed when a request does not say. Defaults to false. */
97
97
  prerelease?: boolean;
98
+ /**
99
+ * Versions to hold where they are, keyed by recipe key. A recipe named here
100
+ * resolves to that version whenever it still satisfies every range asked of
101
+ * it, rather than to the newest one that does; a range that no longer allows
102
+ * it wins, and the newest satisfying version is chosen as usual. This is how
103
+ * an update moves only the pins it was asked to move.
104
+ */
105
+ keep?: Record<string, string>;
98
106
  };
99
107
 
100
108
  /** One recipe version the resolver settled on. */
@@ -608,7 +616,7 @@ function resolveRecipeRef(
608
616
  (previous?.prerelease ?? false) ||
609
617
  (item.prerelease ?? context.prerelease ?? false);
610
618
 
611
- const version = pickVersion(key, entry, ranges, prerelease);
619
+ const version = pickVersion(key, entry, ranges, prerelease, context.keep?.[key]);
612
620
  const versionEntry = entry.versions[version]!;
613
621
 
614
622
  const requestedBy = [...(previous?.requestedBy ?? [])];
@@ -645,12 +653,14 @@ function resolveRecipeRef(
645
653
  * @param entry - The recipe's index entry.
646
654
  * @param ranges - Every range that has to hold, with who asked for it.
647
655
  * @param prerelease - Whether prereleases may match.
656
+ * @param keep - A version to hold, chosen whenever it is among the candidates.
648
657
  */
649
658
  function pickVersion(
650
659
  key: string,
651
660
  entry: IndexFile["recipes"][string],
652
661
  ranges: Array<{ range: string; requestedBy: string }>,
653
- prerelease: boolean
662
+ prerelease: boolean,
663
+ keep?: string
654
664
  ): string {
655
665
  const published = Object.keys(entry.versions);
656
666
  const eligible = prerelease
@@ -664,6 +674,19 @@ function pickVersion(
664
674
  );
665
675
  }
666
676
 
677
+ // A held version is kept exactly as long as every range still allows it. It
678
+ // is checked against the published list rather than the eligible one, so a
679
+ // prerelease a subscription once opted into is not moved just for being one.
680
+ if (
681
+ keep !== undefined &&
682
+ Object.hasOwn(entry.versions, keep) &&
683
+ ranges.every(({ range }) =>
684
+ semver.satisfies(keep, range, { includePrerelease: true })
685
+ )
686
+ ) {
687
+ return keep;
688
+ }
689
+
667
690
  const best = semver.maxSatisfying(candidates, "*", { includePrerelease: prerelease });
668
691
  if (best !== null) return best;
669
692
 
@@ -48,6 +48,7 @@ import {
48
48
  type IndexOverlay,
49
49
  } from "./providers/index-cache.js";
50
50
  import { warning } from "../../utils/formatting.js";
51
+ import { hashDirectorySync } from "./store/hash.js";
51
52
  import { identitySegments } from "./identity.js";
52
53
  import { ensureIndexCacheDirectory } from "../../utils/sous-directory.js";
53
54
  import type { RecipeStoreLike, StoreKey } from "./store/contract.js";
@@ -204,14 +205,70 @@ export async function seedCoreRecipe(
204
205
  export type CoreIndexOverlayOptions = {
205
206
  /** The packaged version, which is by rule the running sous version. */
206
207
  version: string;
207
- /** The content hash of the entry the seed put in the store. */
208
- hash: string;
208
+ /**
209
+ * The content hash of the entry the seed put in the store, or a function
210
+ * that works it out the first time it is needed. The function form is what
211
+ * lets an overlay be installed before anything has been seeded.
212
+ */
213
+ hash: string | (() => string);
209
214
  /** The installed package's root directory. Defaults to the running CLI's own. */
210
215
  packageRoot?: string;
211
216
  /** Where the one warning this can produce goes. Defaults to the console banner. */
212
217
  warn?: (message: string) => void;
213
218
  };
214
219
 
220
+ /**
221
+ * The overlay every index cache a command builds starts with: the packaged core
222
+ * version, hashed from the package itself the first time the official
223
+ * repository's index is read.
224
+ *
225
+ * Seeding installs a precise overlay of its own (carrying the hash of the entry
226
+ * it just wrote), but only the commands that seed get that one. This is what
227
+ * makes the packaged version resolvable everywhere else too: a lockfile rebuild,
228
+ * a browsing command, anything that reads the official index. The hash of the
229
+ * packaged folder is the hash a seeded store entry carries, because the store
230
+ * copies the folder byte for byte.
231
+ *
232
+ * A package whose core recipe cannot be read adds nothing, silently: seeding
233
+ * reports that failure in full, and a read-only command has nothing better to
234
+ * say about it.
235
+ *
236
+ * @param options - The packaged version, and where the package is.
237
+ */
238
+ export function packagedCoreIndexOverlay(options: {
239
+ /** The packaged version, which is by rule the running sous version. */
240
+ version: string;
241
+ /** The installed package's root directory. Defaults to the running CLI's own. */
242
+ packageRoot?: string;
243
+ /** Where the one warning the overlay can produce goes. */
244
+ warn?: (message: string) => void;
245
+ }): IndexOverlay {
246
+ let hash: string | null | undefined;
247
+ const packagedHash = (): string | null => {
248
+ if (hash === undefined) {
249
+ try {
250
+ hash = hashDirectorySync(packagedCoreRecipeDir(options.packageRoot));
251
+ } catch {
252
+ hash = null;
253
+ }
254
+ }
255
+ return hash;
256
+ };
257
+
258
+ const overlay = coreIndexOverlay({
259
+ version: options.version,
260
+ hash: () => packagedHash()!,
261
+ ...(options.packageRoot === undefined ? {} : { packageRoot: options.packageRoot }),
262
+ ...(options.warn === undefined ? {} : { warn: options.warn }),
263
+ });
264
+
265
+ return (identity, index) => {
266
+ if (identity !== OFFICIAL_REPO_IDENTITY) return index;
267
+ if (packagedHash() === null) return index;
268
+ return overlay(identity, index);
269
+ };
270
+ }
271
+
215
272
  /**
216
273
  * Builds the overlay that makes the packaged core recipe resolvable whatever
217
274
  * the official repository has published so far.
@@ -236,6 +293,8 @@ export type CoreIndexOverlayOptions = {
236
293
  */
237
294
  export function coreIndexOverlay(options: CoreIndexOverlayOptions): IndexOverlay {
238
295
  let warned = false;
296
+ const packagedHash = (): string =>
297
+ typeof options.hash === "function" ? options.hash() : options.hash;
239
298
 
240
299
  return (identity: string, index: IndexFile): IndexFile => {
241
300
  if (identity !== OFFICIAL_REPO_IDENTITY) return index;
@@ -243,14 +302,14 @@ export function coreIndexOverlay(options: CoreIndexOverlayOptions): IndexOverlay
243
302
  const published = index.recipes[CORE_RECIPE_KEY]?.versions[options.version];
244
303
 
245
304
  if (published !== undefined) {
246
- if (published.hash !== options.hash && !warned) {
305
+ if (published.hash !== packagedHash() && !warned) {
247
306
  warned = true;
248
307
  (options.warn ?? warning)(
249
308
  `The repository '${OFFICIAL_REPO_NAME}' publishes version ${options.version} of ` +
250
309
  `'${CORE_RECIPE_KEY}' with different contents from the copy inside this ` +
251
310
  `installation of sous, so sous is using the published one.\n` +
252
311
  ` Published: ${published.hash}\n` +
253
- ` Packaged: ${options.hash}\n` +
312
+ ` Packaged: ${packagedHash()}\n` +
254
313
  ` Reinstalling sous will bring the two back into line.`
255
314
  );
256
315
  }
@@ -276,7 +335,7 @@ export function coreIndexOverlay(options: CoreIndexOverlayOptions): IndexOverlay
276
335
  versions: {
277
336
  ...recipe?.versions,
278
337
  [options.version]: {
279
- hash: options.hash,
338
+ hash: packagedHash(),
280
339
  tag: `${CORE_RECIPE_KEY}@${options.version}`,
281
340
  prerelease: semver.prerelease(options.version) !== null,
282
341
  seeded: true,
@@ -29,7 +29,8 @@
29
29
  * recipe folder containing a link.
30
30
  */
31
31
 
32
- import { createHash } from "node:crypto";
32
+ import { createHash, type Hash } from "node:crypto";
33
+ import fsSync from "node:fs";
33
34
  import fs from "node:fs/promises";
34
35
  import path from "node:path";
35
36
  import { STORE_ENTRY_FILENAME } from "../formats/common.js";
@@ -89,18 +90,77 @@ export async function hashDirectory(dir: string): Promise<string> {
89
90
  const hash = createHash("sha256");
90
91
 
91
92
  for (const relative of files) {
92
- const bytes = await fs.readFile(path.join(root, ...relative.split("/")));
93
- hash.update(Buffer.from(relative, "utf8"));
94
- hash.update(FIELD_SEPARATOR);
95
- hash.update(Buffer.from(String(bytes.byteLength), "utf8"));
96
- hash.update(FIELD_SEPARATOR);
97
- hash.update(bytes);
98
- hash.update(FIELD_SEPARATOR);
93
+ updateWithFile(hash, relative, await fs.readFile(path.join(root, ...relative.split("/"))));
99
94
  }
100
95
 
101
96
  return `sha256-${hash.digest("hex")}`;
102
97
  }
103
98
 
99
+ /**
100
+ * The same hash as `hashDirectory`, computed synchronously. It exists for the
101
+ * one place that needs a hash inside a synchronous read: folding the packaged
102
+ * core recipe into a cached index as the index is read.
103
+ *
104
+ * hashDirectorySync(dir) === (await hashDirectory(dir))
105
+ * // -> true, for any directory
106
+ *
107
+ * @param dir - Absolute path to the directory to hash.
108
+ */
109
+ export function hashDirectorySync(dir: string): string {
110
+ const root = path.resolve(dir);
111
+ const files = collectFilesSync(root).sort(bytewiseCompare);
112
+ const hash = createHash("sha256");
113
+
114
+ for (const relative of files) {
115
+ updateWithFile(hash, relative, fsSync.readFileSync(path.join(root, ...relative.split("/"))));
116
+ }
117
+
118
+ return `sha256-${hash.digest("hex")}`;
119
+ }
120
+
121
+ /**
122
+ * Collects every hashable file under `dir`, synchronously. The rules are exactly
123
+ * those of `collectFiles`; the two differ only in how they wait.
124
+ *
125
+ * @param dir - The directory to walk.
126
+ * @param prefix - The relative path of `dir` within the tree being hashed.
127
+ */
128
+ function collectFilesSync(dir: string, prefix = ""): string[] {
129
+ const entries = fsSync.readdirSync(dir, { withFileTypes: true });
130
+ const found: string[] = [];
131
+
132
+ for (const entry of entries) {
133
+ if (entry.name === GIT_DIR_NAME) continue;
134
+ if (entry.name === STORE_ENTRY_FILENAME) continue;
135
+ if (entry.isSymbolicLink()) continue;
136
+
137
+ const relative = prefix.length > 0 ? `${prefix}/${entry.name}` : entry.name;
138
+ const absolute = path.join(dir, entry.name);
139
+
140
+ if (entry.isDirectory()) found.push(...collectFilesSync(absolute, relative));
141
+ else if (entry.isFile()) found.push(relative);
142
+ }
143
+
144
+ return found;
145
+ }
146
+
147
+ /**
148
+ * Feeds one file's canonical record into a hash: its path, its byte length and
149
+ * its bytes, each followed by the separator.
150
+ *
151
+ * @param hash - The hash being built.
152
+ * @param relative - The file's path relative to the tree root, posix separators.
153
+ * @param bytes - The file's contents.
154
+ */
155
+ function updateWithFile(hash: Hash, relative: string, bytes: Buffer): void {
156
+ hash.update(Buffer.from(relative, "utf8"));
157
+ hash.update(FIELD_SEPARATOR);
158
+ hash.update(Buffer.from(String(bytes.byteLength), "utf8"));
159
+ hash.update(FIELD_SEPARATOR);
160
+ hash.update(bytes);
161
+ hash.update(FIELD_SEPARATOR);
162
+ }
163
+
104
164
  /**
105
165
  * Compares two content hashes. Both are canonical lowercase strings, so this is
106
166
  * an exact comparison; it exists so callers read as intent rather than as