@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.
- package/docs/markdown/commands.md +59 -13
- package/docs/markdown/repositories-authoring.md +75 -13
- package/docs/markdown/repositories-consuming.md +44 -2
- package/docs/markdown/repositories-file-formats.md +22 -1
- package/docs/markdown/repositories-providers.md +20 -10
- package/package.json +1 -1
- package/recipes/core/sous-skills/sous.recipe.yaml +8 -1
- package/src/commands/repo/release.ts +41 -0
- package/src/commands/repo/submit.ts +245 -35
- package/src/commands/repo/unlink.ts +333 -20
- package/src/commands/subscription/update.ts +215 -0
- package/src/lib/repos/formats/common.ts +20 -0
- package/src/lib/repos/formats/links-map.ts +5 -3
- package/src/lib/repos/formats/recipe-manifest.ts +7 -0
- package/src/lib/repos/formats/repo-manifest.ts +8 -0
- package/src/lib/repos/git-clone.ts +71 -0
- package/src/lib/repos/links.ts +2 -1
- package/src/lib/repos/locked-recipes.ts +22 -0
- package/src/lib/repos/providers/base.ts +33 -1
- package/src/lib/repos/providers/github.ts +275 -3
- package/src/lib/repos/providers/provider.ts +119 -3
- package/src/lib/repos/release/changelog.ts +448 -0
- package/src/lib/repos/release/git-state.ts +101 -15
- package/src/lib/repos/release/index.ts +2 -0
- package/src/lib/repos/release/submissions.ts +214 -0
- package/src/lib/repos/release/submit-checkout.ts +271 -0
- package/src/lib/repos/release/submit-questions.ts +153 -0
- package/src/lib/repos/release/submit-service.ts +581 -174
- package/src/lib/repos/resolver.ts +25 -2
- package/src/lib/repos/seed.ts +64 -5
- package/src/lib/repos/store/hash.ts +68 -8
- package/src/lib/repos/subscription-service.ts +744 -20
- 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
|
|
package/src/lib/repos/seed.ts
CHANGED
|
@@ -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
|
-
/**
|
|
208
|
-
|
|
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 !==
|
|
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: ${
|
|
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:
|
|
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
|
-
|
|
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
|