@sous-io/sous 0.2.29 → 0.2.31

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 (37) hide show
  1. package/README.md +12 -0
  2. package/docs/markdown/README.md +52 -0
  3. package/docs/markdown/_sidebar.md +2 -0
  4. package/docs/markdown/commands.md +31 -6
  5. package/docs/markdown/repositories-authoring.md +7 -3
  6. package/docs/markdown/repositories-consuming.md +26 -12
  7. package/docs/markdown/repositories-file-formats.md +35 -5
  8. package/docs/markdown/repositories-variables.md +4 -1
  9. package/package.json +2 -1
  10. package/recipes/core/sous-skills/skills/about-sous-repos/SKILL.tpl.md +7 -4
  11. package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
  12. package/src/base-command.ts +75 -2
  13. package/src/commands/lock/rebuild.ts +5 -4
  14. package/src/commands/recipe/show.ts +163 -64
  15. package/src/commands/update.ts +178 -0
  16. package/src/lib/project-install.d.mts +4 -0
  17. package/src/lib/project-install.mjs +46 -8
  18. package/src/lib/repos/catalog.ts +43 -55
  19. package/src/lib/repos/declarations.ts +177 -0
  20. package/src/lib/repos/formats/common.ts +14 -0
  21. package/src/lib/repos/formats/index-file.ts +40 -1
  22. package/src/lib/repos/formats/lockfile.ts +5 -3
  23. package/src/lib/repos/formats/recipe-manifest.ts +6 -204
  24. package/src/lib/repos/formats/variable-definition.ts +268 -0
  25. package/src/lib/repos/providers/http.ts +37 -16
  26. package/src/lib/repos/release/index-builder.ts +168 -32
  27. package/src/lib/repos/resolver.ts +53 -12
  28. package/src/lib/repos/subscription-service.ts +93 -11
  29. package/src/lib/repos/update-plan.ts +3 -2
  30. package/src/lib/self-update/index.ts +14 -0
  31. package/src/lib/self-update/installs.ts +412 -0
  32. package/src/lib/self-update/managers.ts +126 -0
  33. package/src/lib/self-update/plan.ts +315 -0
  34. package/src/lib/self-update/registry.ts +99 -0
  35. package/src/lib/self-update/run.ts +506 -0
  36. package/src/lib/self-update/versions.ts +243 -0
  37. package/src/lib/vars/question-plan.ts +44 -21
@@ -31,7 +31,7 @@ export type UpdatePlanFacts = {
31
31
  missingRepos: MissingRepo[];
32
32
  /** The questions the new versions ask that nothing answers yet. */
33
33
  questions: PlannedVariable[];
34
- /** Recipes whose files are not on this machine, so their questions are unknown. */
34
+ /** Recipes whose files are not on this machine and whose index does not record their questions. */
35
35
  unreadable: string[];
36
36
  /** Repositories whose index could not be fetched, so their pins stay where they are. */
37
37
  unreachable: Array<{ repo: string; reason: string }>;
@@ -153,7 +153,8 @@ export function formatUpdatePlan(
153
153
  if (facts.unreadable.length > 0) {
154
154
  notes.push(
155
155
  `A dry run downloads no recipe, so the dependencies of the versions not on this ` +
156
- `machine yet are not shown: ${facts.unreadable.join(", ")}.`
156
+ `machine yet, and not described by their index, are not shown: ` +
157
+ `${facts.unreadable.join(", ")}.`
157
158
  );
158
159
  }
159
160
  for (const entry of facts.unreachable) {
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The self-update layer: which copies of sous are installed, which version
3
+ * each should move to, and the package manager command that moves it.
4
+ *
5
+ * Import from here rather than from the individual modules. Nothing exported
6
+ * here installs anything; `run.ts`, imported on its own by `sous update`, is
7
+ * the flow that runs the commands the plans name.
8
+ */
9
+
10
+ export * from "./versions.js";
11
+ export * from "./registry.js";
12
+ export * from "./managers.js";
13
+ export * from "./installs.js";
14
+ export * from "./plan.js";
@@ -0,0 +1,412 @@
1
+ /**
2
+ * Finds every copy of sous an update could move: the project's own install,
3
+ * each global install, and the copy that is running now.
4
+ *
5
+ * Discovery only reads. The package managers are asked through an injectable
6
+ * command runner (capturing output, never installing anything), and the
7
+ * project's manager is read from its files by package-manager-detector, so a
8
+ * test drives every case with a fake runner and a temporary directory.
9
+ */
10
+
11
+ import fs from "node:fs";
12
+ import path from "node:path";
13
+ import { detect } from "package-manager-detector/detect";
14
+ import {
15
+ PACKAGE_NAME,
16
+ findProjectCopy,
17
+ readPackageJson,
18
+ realpathOr,
19
+ } from "../project-install.mjs";
20
+ import { spawnCommand, type CommandRunner } from "../repos/providers/git.js";
21
+ import {
22
+ GLOBAL_MANAGERS,
23
+ isProjectManager,
24
+ type DependencySection,
25
+ type GlobalManager,
26
+ type ProjectManager,
27
+ } from "./managers.js";
28
+
29
+ /** The version reported for a copy whose package.json names none. */
30
+ export const UNKNOWN_VERSION = "unknown";
31
+
32
+ /** How the project's package.json declares sous. */
33
+ export type DeclaredDependency = {
34
+ section: DependencySection;
35
+ /** The range as written, for example `0.2.18` or `^0.2.18`. */
36
+ range: string;
37
+ };
38
+
39
+ /**
40
+ * Which manager owns a project, as far as its files say.
41
+ *
42
+ * - `supported: true`: a manager sous drives. `detected` is false when the
43
+ * project's files named none and npm is assumed, which the plan says.
44
+ * - `supported: false`: a manager package-manager-detector recognized that
45
+ * sous does not drive (`bun`, `deno`, `pnpm-rush`, ...), named by its agent.
46
+ */
47
+ export type ProjectManagerInfo =
48
+ | { supported: true; agent: ProjectManager; detected: boolean }
49
+ | { supported: false; agent: string };
50
+
51
+ /** The project's own install of sous. */
52
+ export type ProjectInstallInfo = {
53
+ /**
54
+ * The directory holding the project's package.json: the one holding the
55
+ * `node_modules` the copy is in, or under Yarn Plug'n'Play the nearest one
56
+ * that declares the package.
57
+ */
58
+ projectRoot: string;
59
+ /**
60
+ * The copy's path under `node_modules`, as found (not resolved through
61
+ * links), or under Yarn Plug'n'Play the `.pnp.cjs` file that maps it, since
62
+ * the package itself stays in Yarn's cache.
63
+ */
64
+ location: string;
65
+ /** The copy's real path, which is what is compared with other copies. */
66
+ realPath: string;
67
+ /**
68
+ * The version in the copy's package.json (under Yarn Plug'n'Play, the one
69
+ * `yarn.lock` resolves the declared range to), or `UNKNOWN_VERSION`.
70
+ */
71
+ installed: string;
72
+ /** How package.json declares sous; undefined when it is installed but not declared. */
73
+ declared?: DeclaredDependency;
74
+ manager: ProjectManagerInfo;
75
+ /** Whether the project is a workspace root, as its manager reads one. */
76
+ workspaceRoot: boolean;
77
+ /**
78
+ * Set only when the project uses Yarn Plug'n'Play: the directory holding
79
+ * `.pnp.cjs` and `yarn.lock` (the project root, or its workspace's root).
80
+ */
81
+ pnpRoot?: string;
82
+ };
83
+
84
+ /** One global install of sous, and the manager whose global root holds it. */
85
+ export type GlobalInstallInfo = {
86
+ manager: GlobalManager;
87
+ /** The copy's path under the manager's global root, as found. */
88
+ location: string;
89
+ /** The copy's real path. */
90
+ realPath: string;
91
+ /** The version in the copy's package.json, or `UNKNOWN_VERSION`. */
92
+ installed: string;
93
+ };
94
+
95
+ /**
96
+ * What the running copy is.
97
+ *
98
+ * - `project` / `global`: one of the installs found (`global` names its manager).
99
+ * - `npx`: a copy in npx's cache, which no install updates.
100
+ * - `volta`: a copy Volta manages, which only Volta updates.
101
+ * - `unknown`: anywhere else, such as a source checkout.
102
+ */
103
+ export type RunningInstall = {
104
+ kind: "project" | "global" | "npx" | "volta" | "unknown";
105
+ /** The global manager, set only when `kind` is `global`. */
106
+ manager?: GlobalManager;
107
+ /** The running copy's real path. */
108
+ realPath: string;
109
+ /** The version in the running copy's package.json, or `UNKNOWN_VERSION`. */
110
+ installed: string;
111
+ };
112
+
113
+ /** Everything discovery found. */
114
+ export type InstallDiscovery = {
115
+ /** The project's own install, when the working directory is inside a project that has one. */
116
+ project?: ProjectInstallInfo;
117
+ /** Every global install found, one per manager whose global root holds a copy. */
118
+ globals: GlobalInstallInfo[];
119
+ running: RunningInstall;
120
+ };
121
+
122
+ /** Options for `discoverInstalls`. */
123
+ export type DiscoverInstallsOptions = {
124
+ /** Where the project lookup starts. */
125
+ cwd: string;
126
+ /** The package root of the running copy. */
127
+ ownRoot: string;
128
+ /** How the managers are asked for their global roots. Defaults to spawning them. */
129
+ run?: CommandRunner;
130
+ };
131
+
132
+ /**
133
+ * Finds the project install, the global installs and what the running copy is.
134
+ * No step fails the run: a manager that is not installed or cannot answer is
135
+ * skipped, and a copy whose package.json is unreadable is left out.
136
+ */
137
+ export async function discoverInstalls(options: DiscoverInstallsOptions): Promise<InstallDiscovery> {
138
+ const run = options.run ?? spawnCommand;
139
+ const project = await findProjectInstallInfo(options.cwd);
140
+ const globals = await findGlobalInstalls(run);
141
+ const running = classifyRunning(options.ownRoot, project, globals);
142
+ return project === undefined ? { globals, running } : { project, globals, running };
143
+ }
144
+
145
+ /**
146
+ * The project install nearest `cwd`: the copy the hand-off finds
147
+ * (`findProjectCopy`), and when there is none, a Yarn Plug'n'Play project
148
+ * that declares the package (`findPnpInstall`). Undefined when there is
149
+ * neither, or the `node_modules` copy found is some other package.
150
+ */
151
+ export async function findProjectInstallInfo(cwd: string): Promise<ProjectInstallInfo | undefined> {
152
+ const location = findProjectCopy(cwd);
153
+ if (location === undefined) return findPnpInstall(cwd);
154
+ const copy = readPackageJson(location);
155
+ if (copy?.name !== PACKAGE_NAME) return undefined;
156
+
157
+ // <projectRoot>/node_modules/@sous-io/sous
158
+ const projectRoot = path.resolve(location, "..", "..", "..");
159
+ return describeProject(projectRoot, await projectManager(projectRoot), {
160
+ location,
161
+ realPath: realpathOr(location),
162
+ installed: versionOf(copy),
163
+ });
164
+ }
165
+
166
+ /** The file Yarn Plug'n'Play writes in place of `node_modules`. */
167
+ export const PNP_FILE = ".pnp.cjs";
168
+
169
+ /**
170
+ * A Yarn Plug'n'Play install, which has no `node_modules` copy to find: the
171
+ * nearest package.json at or above `cwd` that declares the package, with a
172
+ * `.pnp.cjs` in its directory or above it. The version is the one `yarn.lock`
173
+ * (beside `.pnp.cjs`) resolves the declared range to, and the manager is Yarn
174
+ * Berry, the only Yarn that writes `.pnp.cjs`. Undefined when either is missing.
175
+ */
176
+ export function findPnpInstall(cwd: string): ProjectInstallInfo | undefined {
177
+ const projectRoot = findUp(cwd, (dir) => declaredDependency(readPackageJson(dir)) !== undefined);
178
+ if (projectRoot === undefined) return undefined;
179
+ const pnpRoot = findUp(projectRoot, (dir) => fs.existsSync(path.join(dir, PNP_FILE)));
180
+ if (pnpRoot === undefined) return undefined;
181
+
182
+ const range = declaredDependency(readPackageJson(projectRoot))?.range ?? "";
183
+ let lockfile = "";
184
+ try {
185
+ lockfile = fs.readFileSync(path.join(pnpRoot, "yarn.lock"), "utf8");
186
+ } catch {
187
+ // No lockfile: the version is unknown, and the plan says so.
188
+ }
189
+ const location = path.join(pnpRoot, PNP_FILE);
190
+ const info = describeProject(
191
+ projectRoot,
192
+ { supported: true, agent: "yarn@berry", detected: true },
193
+ {
194
+ location,
195
+ realPath: realpathOr(location),
196
+ installed: yarnLockVersion(lockfile, range) ?? UNKNOWN_VERSION,
197
+ }
198
+ );
199
+ info.pnpRoot = pnpRoot;
200
+ return info;
201
+ }
202
+
203
+ /**
204
+ * The version a Yarn Berry `yarn.lock` resolves the package's declared
205
+ * `range` to. Each entry opens with an unindented key listing the
206
+ * descriptors it answers (`"@sous-io/sous@npm:0.2.28, @sous-io/sous@npm:^0.2.0":`)
207
+ * and holds an indented `version:` line. The entry whose key names the range
208
+ * wins; when none does and the lockfile holds exactly one version of the
209
+ * package, that version is the answer. Undefined otherwise.
210
+ *
211
+ * yarnLockVersion('"@sous-io/sous@npm:0.2.28":\n version: 0.2.28\n', "0.2.28") -> "0.2.28"
212
+ */
213
+ export function yarnLockVersion(lockfile: string, range: string): string | undefined {
214
+ const prefix = `${PACKAGE_NAME}@`;
215
+ const wanted = new Set([`${prefix}${range}`, `${prefix}npm:${range}`]);
216
+ const entries: { descriptors: string[]; version?: string }[] = [];
217
+ let entry: { descriptors: string[]; version?: string } | undefined;
218
+ for (const line of lockfile.split(/\r?\n/)) {
219
+ if (/^[^\s#].*:$/.test(line)) {
220
+ const descriptors = line
221
+ .slice(0, -1)
222
+ .split(",")
223
+ .map((part) => part.trim().replace(/^"|"$/g, ""))
224
+ .filter((descriptor) => descriptor.startsWith(prefix));
225
+ entry = descriptors.length > 0 ? { descriptors } : undefined;
226
+ if (entry !== undefined) entries.push(entry);
227
+ continue;
228
+ }
229
+ const version = /^\s+version:\s*"?([^"\s]+)"?\s*$/.exec(line);
230
+ if (entry !== undefined && version !== null && entry.version === undefined) {
231
+ entry.version = version[1];
232
+ }
233
+ }
234
+ const exact = entries.find((candidate) => candidate.descriptors.some((d) => wanted.has(d)));
235
+ if (exact !== undefined) return exact.version;
236
+ const versions = new Set(entries.flatMap((candidate) => candidate.version ?? []));
237
+ return versions.size === 1 ? [...versions][0] : undefined;
238
+ }
239
+
240
+ /** A project install, from its root, its manager and the facts about its copy. */
241
+ function describeProject(
242
+ projectRoot: string,
243
+ manager: ProjectManagerInfo,
244
+ copy: { location: string; realPath: string; installed: string }
245
+ ): ProjectInstallInfo {
246
+ const pkg = readPackageJson(projectRoot);
247
+ const declared = declaredDependency(pkg);
248
+ const info: ProjectInstallInfo = {
249
+ projectRoot,
250
+ ...copy,
251
+ manager,
252
+ workspaceRoot: isWorkspaceRoot(manager.agent, projectRoot, pkg),
253
+ };
254
+ if (declared !== undefined) info.declared = declared;
255
+ return info;
256
+ }
257
+
258
+ /** The nearest directory at or above `start` that `test` accepts, or undefined. */
259
+ function findUp(start: string, test: (dir: string) => boolean): string | undefined {
260
+ let dir = path.resolve(start);
261
+ for (;;) {
262
+ if (test(dir)) return dir;
263
+ const parent = path.dirname(dir);
264
+ if (parent === dir) return undefined;
265
+ dir = parent;
266
+ }
267
+ }
268
+
269
+ /**
270
+ * How a package.json declares sous: `devDependencies` first, then
271
+ * `dependencies`; undefined when neither names it.
272
+ */
273
+ export function declaredDependency(pkg: unknown): DeclaredDependency | undefined {
274
+ if (pkg === null || typeof pkg !== "object") return undefined;
275
+ for (const section of ["devDependencies", "dependencies"] as const) {
276
+ const block = (pkg as Record<string, unknown>)[section];
277
+ if (block === null || typeof block !== "object") continue;
278
+ const range = (block as Record<string, unknown>)[PACKAGE_NAME];
279
+ if (typeof range === "string") return { section, range };
280
+ }
281
+ return undefined;
282
+ }
283
+
284
+ /**
285
+ * Which manager owns the project at `projectRoot`, read by
286
+ * package-manager-detector from that directory alone (its `packageManager`
287
+ * and `devEngines` fields and its lockfile). Nothing found means npm, marked
288
+ * as not detected.
289
+ *
290
+ * Two corrections are made on top: a `yarn` answer from a lockfile is Yarn
291
+ * Berry when the project has a `.yarnrc.yml` or its `yarn.lock` carries Berry's
292
+ * `__metadata` header (the detector reads a lockfile as classic unless the
293
+ * `packageManager` field says otherwise), and `pnpm@6` is plain pnpm, whose
294
+ * `add` flags have not changed.
295
+ */
296
+ export async function projectManager(projectRoot: string): Promise<ProjectManagerInfo> {
297
+ const found = await detect({ cwd: projectRoot, stopDir: projectRoot });
298
+ if (found === null) return { supported: true, agent: "npm", detected: false };
299
+ let agent: string = found.agent;
300
+ if (agent === "yarn" && isYarnBerryProject(projectRoot)) agent = "yarn@berry";
301
+ if (agent === "pnpm@6") agent = "pnpm";
302
+ return isProjectManager(agent)
303
+ ? { supported: true, agent, detected: true }
304
+ : { supported: false, agent };
305
+ }
306
+
307
+ /** Whether the files at `projectRoot` are Yarn Berry's rather than Yarn classic's. */
308
+ function isYarnBerryProject(projectRoot: string): boolean {
309
+ if (fs.existsSync(path.join(projectRoot, ".yarnrc.yml"))) return true;
310
+ try {
311
+ return /^__metadata:/m.test(fs.readFileSync(path.join(projectRoot, "yarn.lock"), "utf8"));
312
+ } catch {
313
+ return false;
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Whether `projectRoot` is a workspace root in the sense its manager needs a
319
+ * flag for: pnpm, when it holds a `pnpm-workspace.yaml`; Yarn classic, when
320
+ * its package.json has `workspaces`. Every other manager reads as false.
321
+ */
322
+ export function isWorkspaceRoot(agent: string, projectRoot: string, pkg: unknown): boolean {
323
+ if (agent === "pnpm") return fs.existsSync(path.join(projectRoot, "pnpm-workspace.yaml"));
324
+ if (agent === "yarn") {
325
+ return pkg !== null && typeof pkg === "object" && "workspaces" in pkg;
326
+ }
327
+ return false;
328
+ }
329
+
330
+ /** The command that prints each global manager's global `node_modules`, and how to read it. */
331
+ const GLOBAL_ROOT_QUERIES: Record<GlobalManager, { args: string[]; suffix?: string }> = {
332
+ npm: { args: ["root", "-g"] },
333
+ pnpm: { args: ["root", "-g"] },
334
+ // `yarn global dir` prints the directory ABOVE its node_modules.
335
+ yarn: { args: ["global", "dir"], suffix: "node_modules" },
336
+ };
337
+
338
+ /**
339
+ * Asks each global manager where its global `node_modules` is, and returns a
340
+ * global install for each one that holds a copy of the package. A manager that
341
+ * is not installed (exit 127), exits non-zero or prints nothing is skipped. Two
342
+ * managers naming the same copy (by real path) count once, under the first.
343
+ */
344
+ export async function findGlobalInstalls(run: CommandRunner): Promise<GlobalInstallInfo[]> {
345
+ const installs: GlobalInstallInfo[] = [];
346
+ for (const manager of GLOBAL_MANAGERS) {
347
+ const root = await globalRoot(manager, run);
348
+ if (root === undefined) continue;
349
+ const location = path.join(root, ...PACKAGE_NAME.split("/"));
350
+ const pkg = readPackageJson(location);
351
+ if (pkg?.name !== PACKAGE_NAME) continue;
352
+ const realPath = realpathOr(location);
353
+ if (installs.some((install) => install.realPath === realPath)) continue;
354
+ installs.push({ manager, location, realPath, installed: versionOf(pkg) });
355
+ }
356
+ return installs;
357
+ }
358
+
359
+ /** One manager's global `node_modules`, or undefined when it cannot say. */
360
+ async function globalRoot(manager: GlobalManager, run: CommandRunner): Promise<string | undefined> {
361
+ const query = GLOBAL_ROOT_QUERIES[manager];
362
+ let output: string;
363
+ try {
364
+ const result = await run(manager, query.args, {});
365
+ if (result.code !== 0) return undefined;
366
+ output = result.stdout.trim().split(/\r?\n/).pop()?.trim() ?? "";
367
+ } catch {
368
+ return undefined;
369
+ }
370
+ if (output.length === 0) return undefined;
371
+ return query.suffix === undefined ? output : path.join(output, query.suffix);
372
+ }
373
+
374
+ /**
375
+ * What the running copy at `ownRoot` is: the project install or a global one
376
+ * when its real path is theirs, otherwise read from where it lives.
377
+ */
378
+ export function classifyRunning(
379
+ ownRoot: string,
380
+ project: ProjectInstallInfo | undefined,
381
+ globals: readonly GlobalInstallInfo[]
382
+ ): RunningInstall {
383
+ const realPath = realpathOr(ownRoot);
384
+ const pkg = readPackageJson(ownRoot);
385
+ const installed = pkg === undefined ? UNKNOWN_VERSION : versionOf(pkg);
386
+ if (project !== undefined && (project.realPath === realPath || isPnpCopy(project, realPath))) {
387
+ return { kind: "project", realPath, installed };
388
+ }
389
+ const global = globals.find((install) => install.realPath === realPath);
390
+ if (global !== undefined) return { kind: "global", manager: global.manager, realPath, installed };
391
+ const slashed = realPath.split(path.sep).join("/");
392
+ if (slashed.includes("/_npx/")) return { kind: "npx", realPath, installed };
393
+ if (slashed.includes("/.volta/")) return { kind: "volta", realPath, installed };
394
+ return { kind: "unknown", realPath, installed };
395
+ }
396
+
397
+ /**
398
+ * Whether `realPath` is the Plug'n'Play project's own copy, unpacked by Yarn
399
+ * into `<pnpRoot>/.yarn/unplugged/` (a copy still inside Yarn's zip cache
400
+ * cannot run sous at all).
401
+ */
402
+ function isPnpCopy(project: ProjectInstallInfo, realPath: string): boolean {
403
+ if (project.pnpRoot === undefined) return false;
404
+ const unplugged = path.join(realpathOr(project.pnpRoot), ".yarn", "unplugged");
405
+ const relative = path.relative(unplugged, realPath);
406
+ return relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative);
407
+ }
408
+
409
+ /** The `version` a package.json names, or `UNKNOWN_VERSION`. */
410
+ function versionOf(pkg: Record<string, unknown>): string {
411
+ return typeof pkg.version === "string" ? pkg.version : UNKNOWN_VERSION;
412
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The command each package manager needs to install a version of sous.
3
+ *
4
+ * The commands come from package-manager-detector's table (`resolveCommand`),
5
+ * so sous spells nothing a manager already publishes; this module adds the
6
+ * flags an update needs on top (an exact pin, the dependency section, the
7
+ * workspace root) and runs nothing.
8
+ */
9
+
10
+ import { resolveCommand } from "package-manager-detector/commands";
11
+ import type { Agent } from "package-manager-detector";
12
+ import { PACKAGE_NAME } from "../project-install.mjs";
13
+
14
+ /** The managers that can update a project's own install. */
15
+ export const PROJECT_MANAGERS = ["npm", "pnpm", "yarn", "yarn@berry"] as const;
16
+
17
+ /** The managers that can update a global install (Yarn Berry has no global installs). */
18
+ export const GLOBAL_MANAGERS = ["npm", "pnpm", "yarn"] as const;
19
+
20
+ /** A manager that can update a project install; `yarn` is Yarn classic (1.x). */
21
+ export type ProjectManager = (typeof PROJECT_MANAGERS)[number];
22
+
23
+ /** A manager that can update a global install; `yarn` is Yarn classic (1.x). */
24
+ export type GlobalManager = (typeof GLOBAL_MANAGERS)[number];
25
+
26
+ /** A command and its arguments, not yet run. */
27
+ export type ManagerCommand = { command: string; args: string[] };
28
+
29
+ /** The section of package.json a project declares sous in. */
30
+ export type DependencySection = "devDependencies" | "dependencies";
31
+
32
+ /** Options for `projectAdd`. */
33
+ export type ProjectAddOptions = {
34
+ /** The section the dependency is declared in; it stays there. */
35
+ section: DependencySection;
36
+ /**
37
+ * Whether the project is a workspace root, as its manager reads one (see
38
+ * `isWorkspaceRoot` in installs.ts). pnpm and Yarn classic then need a flag
39
+ * to add a dependency there at all.
40
+ */
41
+ workspaceRoot: boolean;
42
+ };
43
+
44
+ /** Whether `agent` is one of `PROJECT_MANAGERS`. */
45
+ export function isProjectManager(agent: string): agent is ProjectManager {
46
+ return (PROJECT_MANAGERS as readonly string[]).includes(agent);
47
+ }
48
+
49
+ /** Whether `agent` is one of `GLOBAL_MANAGERS`. */
50
+ export function isGlobalManager(agent: string): agent is GlobalManager {
51
+ return (GLOBAL_MANAGERS as readonly string[]).includes(agent);
52
+ }
53
+
54
+ /**
55
+ * The package spec an install names: `@sous-io/sous@<version>`.
56
+ *
57
+ * packageSpec("0.2.34") -> "@sous-io/sous@0.2.34"
58
+ */
59
+ export function packageSpec(version: string): string {
60
+ return `${PACKAGE_NAME}@${version}`;
61
+ }
62
+
63
+ /**
64
+ * The command that installs `version` globally with `manager`.
65
+ *
66
+ * globalInstall("pnpm", "0.2.34")
67
+ * // -> { command: "pnpm", args: ["add", "-g", "@sous-io/sous@0.2.34"] }
68
+ */
69
+ export function globalInstall(manager: GlobalManager, version: string): ManagerCommand {
70
+ return resolve(manager, "global", [packageSpec(version)]);
71
+ }
72
+
73
+ /**
74
+ * The command that pins a project's dependency on sous to exactly `version`
75
+ * and updates its lockfile, run in the project root.
76
+ *
77
+ * `-E` pins the exact version with every supported manager; `-D` is added only
78
+ * for a devDependency, so a dependency declared under `dependencies` stays
79
+ * there. At a workspace root, pnpm gets `-w` and Yarn classic `-W`; npm adds
80
+ * to the root package.json when run there, and Yarn Berry needs no flag.
81
+ *
82
+ * projectAdd("pnpm", "0.2.34", { section: "devDependencies", workspaceRoot: true })
83
+ * // -> { command: "pnpm", args: ["add", "-D", "-E", "-w", "@sous-io/sous@0.2.34"] }
84
+ */
85
+ export function projectAdd(
86
+ manager: ProjectManager,
87
+ version: string,
88
+ options: ProjectAddOptions
89
+ ): ManagerCommand {
90
+ const flags: string[] = [];
91
+ if (options.section === "devDependencies") flags.push("-D");
92
+ flags.push("-E");
93
+ if (options.workspaceRoot) {
94
+ if (manager === "pnpm") flags.push("-w");
95
+ if (manager === "yarn") flags.push("-W");
96
+ }
97
+ return resolve(manager, "add", [...flags, packageSpec(version)]);
98
+ }
99
+
100
+ /**
101
+ * The command a person would run by hand to pin a project's dependency with a
102
+ * manager sous does not drive (bun, deno), or undefined when
103
+ * package-manager-detector knows no `add` command for it.
104
+ */
105
+ export function manualProjectAdd(
106
+ agent: string,
107
+ version: string,
108
+ section: DependencySection
109
+ ): ManagerCommand | undefined {
110
+ const flags = section === "devDependencies" ? ["-D"] : [];
111
+ try {
112
+ return resolveCommand(agent as Agent, "add", [...flags, packageSpec(version)]) ?? undefined;
113
+ } catch {
114
+ return undefined;
115
+ }
116
+ }
117
+
118
+ /** Resolves a command the table is known to hold for every supported manager. */
119
+ function resolve(agent: Agent, command: "add" | "global", args: string[]): ManagerCommand {
120
+ const resolved = resolveCommand(agent, command, args);
121
+ /* c8 ignore next 3 */
122
+ if (resolved === null) {
123
+ throw new Error(`package-manager-detector has no '${command}' command for ${agent}.`);
124
+ }
125
+ return { command: resolved.command, args: resolved.args };
126
+ }