@sous-io/sous 0.1.1 → 0.2.1
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/README.md +115 -35
- package/bin/run.js +10 -1
- package/docs/markdown/README.md +27 -0
- package/docs/markdown/_sidebar.md +18 -0
- package/docs/markdown/commands.md +308 -0
- package/docs/markdown/config-discovery.md +74 -0
- package/docs/markdown/config-inspection.md +69 -0
- package/docs/markdown/config-layers.md +92 -0
- package/docs/markdown/config-variables.md +79 -0
- package/docs/markdown/configuration.md +71 -0
- package/docs/markdown/design-principles.md +59 -0
- package/docs/markdown/repositories-authoring.md +409 -0
- package/docs/markdown/repositories-consuming.md +580 -0
- package/docs/markdown/repositories-file-formats.md +1084 -0
- package/docs/markdown/repositories-variables.md +387 -0
- package/docs/markdown/repositories.md +303 -0
- package/docs/markdown/skill-categories.md +58 -0
- package/package.json +72 -8
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/SKILL.tpl.md +20 -20
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/about-something.md +2 -2
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/do-something.md +1 -1
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/advanced-patterns.md +6 -6
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/commands.md +5 -5
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/frontmatter.md +3 -3
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/SKILL.tpl.md +40 -25
- package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +70 -0
- package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +75 -0
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/create-skill/SKILL.tpl.md +8 -9
- package/recipes/core/sous-skills/sous.recipe.yaml +45 -0
- package/sous.config.schema.json +337 -0
- package/src/base-command.ts +220 -67
- package/src/commands/build.ts +150 -73
- package/src/commands/clear.ts +23 -15
- package/src/commands/compile.ts +74 -16
- package/src/commands/config/get.ts +110 -0
- package/src/commands/config/show.ts +32 -0
- package/src/commands/config/validate.ts +53 -0
- package/src/commands/help.ts +46 -0
- package/src/commands/launch.ts +36 -14
- package/src/commands/lock/rebuild.ts +241 -0
- package/src/commands/lock/show.ts +115 -0
- package/src/commands/namespace/list.ts +117 -0
- package/src/commands/namespace/show.ts +110 -0
- package/src/commands/prune.ts +3 -11
- package/src/commands/recipe/list.ts +95 -0
- package/src/commands/recipe/show.ts +301 -0
- package/src/commands/repo/add.ts +145 -0
- package/src/commands/repo/gc.ts +172 -0
- package/src/commands/repo/init.ts +136 -0
- package/src/commands/repo/link.ts +500 -0
- package/src/commands/repo/list.ts +179 -0
- package/src/commands/repo/release.ts +625 -0
- package/src/commands/repo/remove.ts +193 -0
- package/src/commands/repo/search.ts +189 -0
- package/src/commands/repo/submit.ts +133 -0
- package/src/commands/repo/unlink.ts +147 -0
- package/src/commands/subscription/add.ts +285 -0
- package/src/commands/subscription/list.ts +129 -0
- package/src/commands/subscription/remove.ts +181 -0
- package/src/commands/vars/ask.ts +374 -0
- package/src/commands/vars/index.ts +79 -0
- package/src/commands/vars/list.ts +67 -0
- package/src/commands/vars/show.ts +77 -0
- package/src/config-command.ts +30 -0
- package/src/lib/build-service.ts +206 -54
- package/src/lib/config-discovery.ts +220 -27
- package/src/lib/config-inspect.ts +145 -0
- package/src/lib/config-kernel.mjs +377 -0
- package/src/lib/config-schema.ts +361 -0
- package/src/lib/env-file.ts +328 -0
- package/src/lib/env-local.ts +18 -1
- package/src/lib/errors.ts +32 -0
- package/src/lib/include-resolver.ts +108 -15
- package/src/lib/interactive.ts +165 -0
- package/src/lib/markdown-compiler.ts +118 -37
- package/src/lib/package-info.ts +25 -0
- package/src/lib/pid-service.ts +32 -21
- package/src/lib/refs/find.ts +589 -0
- package/src/lib/refs/index.ts +12 -0
- package/src/lib/refs/pick.ts +147 -0
- package/src/lib/refs/scopes.ts +61 -0
- package/src/lib/repos/catalog-display.ts +116 -0
- package/src/lib/repos/catalog-inputs.ts +160 -0
- package/src/lib/repos/catalog.ts +722 -0
- package/src/lib/repos/core-recipe.ts +105 -0
- package/src/lib/repos/defaults.ts +175 -0
- package/src/lib/repos/formats/common.ts +389 -0
- package/src/lib/repos/formats/index-file.ts +215 -0
- package/src/lib/repos/formats/links-map.ts +96 -0
- package/src/lib/repos/formats/lockfile.ts +167 -0
- package/src/lib/repos/formats/patterns.ts +57 -0
- package/src/lib/repos/formats/recipe-manifest.ts +395 -0
- package/src/lib/repos/formats/repo-manifest.ts +88 -0
- package/src/lib/repos/formats/store-entry.ts +84 -0
- package/src/lib/repos/freshness.ts +208 -0
- package/src/lib/repos/git-clone.ts +312 -0
- package/src/lib/repos/identity.ts +89 -0
- package/src/lib/repos/index.ts +58 -0
- package/src/lib/repos/links.ts +353 -0
- package/src/lib/repos/load-manifest.ts +236 -0
- package/src/lib/repos/lock-service.ts +453 -0
- package/src/lib/repos/locked-namespace-resolver.ts +90 -0
- package/src/lib/repos/locked-recipes.ts +254 -0
- package/src/lib/repos/managed-layer.ts +422 -0
- package/src/lib/repos/namespace-resolver.ts +370 -0
- package/src/lib/repos/providers/base.ts +206 -0
- package/src/lib/repos/providers/git.ts +233 -0
- package/src/lib/repos/providers/github.ts +294 -0
- package/src/lib/repos/providers/gitlab.ts +263 -0
- package/src/lib/repos/providers/http.ts +102 -0
- package/src/lib/repos/providers/index-cache.ts +382 -0
- package/src/lib/repos/providers/index.ts +106 -0
- package/src/lib/repos/providers/local.ts +391 -0
- package/src/lib/repos/providers/provider.ts +401 -0
- package/src/lib/repos/recipe-config-layers.ts +287 -0
- package/src/lib/repos/recipe-targets.ts +223 -0
- package/src/lib/repos/ref-search.ts +46 -0
- package/src/lib/repos/ref.ts +513 -0
- package/src/lib/repos/reference-report.ts +122 -0
- package/src/lib/repos/release/bump.ts +161 -0
- package/src/lib/repos/release/git-state.ts +305 -0
- package/src/lib/repos/release/index-builder.ts +635 -0
- package/src/lib/repos/release/index.ts +16 -0
- package/src/lib/repos/release/plan.ts +512 -0
- package/src/lib/repos/release/submit-service.ts +496 -0
- package/src/lib/repos/release/tags.ts +243 -0
- package/src/lib/repos/release/validate.ts +463 -0
- package/src/lib/repos/resolver.ts +789 -0
- package/src/lib/repos/scaffold/index.ts +238 -0
- package/src/lib/repos/scaffold/templates.ts +415 -0
- package/src/lib/repos/seed.ts +414 -0
- package/src/lib/repos/store/contract.ts +64 -0
- package/src/lib/repos/store/hash.ts +114 -0
- package/src/lib/repos/store/recipe-store.ts +599 -0
- package/src/lib/repos/store/settings.ts +58 -0
- package/src/lib/repos/subscription-service.ts +2678 -0
- package/src/lib/repos/trust.ts +447 -0
- package/src/lib/settings.ts +546 -189
- package/src/lib/sous-home.ts +104 -0
- package/src/lib/state.ts +52 -20
- package/src/lib/vars/ask.ts +1152 -0
- package/src/lib/vars/definition-source.ts +252 -0
- package/src/lib/vars/display.ts +233 -0
- package/src/lib/vars/index.ts +18 -0
- package/src/lib/vars/ladder.ts +282 -0
- package/src/lib/vars/mappings.ts +265 -0
- package/src/lib/vars/names.ts +94 -0
- package/src/lib/vars/preanswers.ts +395 -0
- package/src/lib/vars/question-plan.ts +218 -0
- package/src/lib/vars/report.ts +228 -0
- package/src/lib/vars/safe-regex.ts +235 -0
- package/src/lib/vars/validate.ts +312 -0
- package/src/lib/watch-loop.ts +148 -0
- package/src/templating/init-liquid-engine.ts +58 -16
- package/src/utils/choice-prompt.ts +143 -0
- package/src/utils/command-errors.ts +186 -0
- package/src/utils/command-help.ts +45 -0
- package/src/utils/confirm-prompt.ts +110 -0
- package/src/utils/flags.ts +153 -0
- package/src/utils/formatting.ts +540 -55
- package/src/utils/prompts.ts +35 -1
- package/src/utils/sous-directory.ts +245 -0
- package/src/utils/table.ts +603 -0
- package/src/utils/value-prompt.ts +119 -0
- package/shared-prompts/_partials/resume-task.md +0 -51
- package/shared-prompts/_partials/sub-agent-delegation.md +0 -32
- package/shared-prompts/_partials/update-task-file.md +0 -52
- package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +0 -52
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +0 -102
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +0 -81
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +0 -126
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +0 -92
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +0 -61
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +0 -65
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +0 -96
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +0 -104
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +0 -243
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +0 -148
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +0 -383
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +0 -267
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +0 -56
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +0 -169
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +0 -59
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +0 -25
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +0 -140
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +0 -140
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +0 -1
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +0 -185
- package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +0 -52
- package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +0 -59
- package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +0 -47
- package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +0 -26
- package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +0 -58
- package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +0 -27
- package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +0 -34
- package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +0 -51
- package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +0 -122
- package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +0 -80
- package/shared-prompts/skills/task-files/go/SKILL.tpl.md +0 -14
- package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +0 -13
- package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +0 -93
- package/shared-prompts/skills/task-files/update/SKILL.tpl.md +0 -14
- package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +0 -13
- /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/substitutions.md +0 -0
- /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/references/liquid-filters.md +0 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place the Repositories layer shells out.
|
|
3
|
+
*
|
|
4
|
+
* Every subprocess a provider runs (git itself, and the `gh` / `glab` CLIs when
|
|
5
|
+
* they are present and can hand over a token) goes through the helpers here, so
|
|
6
|
+
* failures are reported the same way everywhere: the command that was run, the
|
|
7
|
+
* exit code it returned, and whatever it wrote to stderr.
|
|
8
|
+
*
|
|
9
|
+
* The runner is injectable. Tests substitute their own, so no test in this
|
|
10
|
+
* layer ever spawns a process or touches the network.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import fs from "node:fs/promises";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { ConfigError } from "../../errors.js";
|
|
17
|
+
|
|
18
|
+
/** What a finished subprocess reports back. */
|
|
19
|
+
export type CommandResult = {
|
|
20
|
+
/** The process exit code; 0 on success. */
|
|
21
|
+
code: number;
|
|
22
|
+
stdout: string;
|
|
23
|
+
stderr: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** How a command is run. Tests replace this with a function of their own. */
|
|
27
|
+
export type CommandRunner = (
|
|
28
|
+
command: string,
|
|
29
|
+
args: string[],
|
|
30
|
+
options: { cwd?: string }
|
|
31
|
+
) => Promise<CommandResult>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The default runner: spawns the command, captures both streams, and resolves
|
|
35
|
+
* once it exits. A command that cannot be started at all (it is not installed)
|
|
36
|
+
* resolves with exit code 127, so callers treat it as an ordinary failure.
|
|
37
|
+
*
|
|
38
|
+
* @param command - The executable to run.
|
|
39
|
+
* @param args - Its arguments, already split.
|
|
40
|
+
* @param options - Optional working directory.
|
|
41
|
+
*/
|
|
42
|
+
export const spawnCommand: CommandRunner = (command, args, options = {}) =>
|
|
43
|
+
new Promise<CommandResult>((resolve) => {
|
|
44
|
+
const child = spawn(command, args, {
|
|
45
|
+
cwd: options.cwd,
|
|
46
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
let stdout = "";
|
|
50
|
+
let stderr = "";
|
|
51
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
52
|
+
stdout += chunk.toString();
|
|
53
|
+
});
|
|
54
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
55
|
+
stderr += chunk.toString();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
child.on("error", (error: Error) => {
|
|
59
|
+
resolve({ code: 127, stdout, stderr: `${stderr}${error.message}` });
|
|
60
|
+
});
|
|
61
|
+
child.on("close", (code) => {
|
|
62
|
+
resolve({ code: code ?? 1, stdout, stderr });
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/** Options shared by every subprocess helper here. */
|
|
67
|
+
export type RunOptions = {
|
|
68
|
+
/** Directory to run in. */
|
|
69
|
+
cwd?: string;
|
|
70
|
+
/** The runner to use. Defaults to spawning a real process. */
|
|
71
|
+
run?: CommandRunner;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Renders a command and its arguments the way a user would have typed them. */
|
|
75
|
+
function describeCommand(command: string, args: string[]): string {
|
|
76
|
+
return [command, ...args].join(" ");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Runs `git` with the given arguments and returns its standard output, trimmed.
|
|
81
|
+
* A non-zero exit is a ConfigError naming the command, the exit code and the
|
|
82
|
+
* error output, because that is what a user needs in order to fix it.
|
|
83
|
+
*
|
|
84
|
+
* @param args - Arguments to pass to git.
|
|
85
|
+
* @param options - Working directory and an optional runner override.
|
|
86
|
+
*/
|
|
87
|
+
export async function runGit(args: string[], options: RunOptions = {}): Promise<string> {
|
|
88
|
+
const run = options.run ?? spawnCommand;
|
|
89
|
+
const result = await run("git", args, { cwd: options.cwd });
|
|
90
|
+
|
|
91
|
+
if (result.code !== 0) {
|
|
92
|
+
const lines = [
|
|
93
|
+
`The command '${describeCommand("git", args)}' failed with exit code ${result.code}.`,
|
|
94
|
+
];
|
|
95
|
+
if (options.cwd !== undefined) lines.push(` It was run in ${options.cwd}.`);
|
|
96
|
+
const detail = result.stderr.trim() || result.stdout.trim();
|
|
97
|
+
if (detail.length > 0) {
|
|
98
|
+
for (const line of detail.split("\n")) lines.push(` ${line}`);
|
|
99
|
+
}
|
|
100
|
+
if (result.code === 127) {
|
|
101
|
+
lines.push(
|
|
102
|
+
" Sous fetches recipes with git, so git must be installed and on your PATH."
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
throw new ConfigError(lines.join("\n"));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return result.stdout.trim();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Runs a command that sous can do without, returning its trimmed output or
|
|
113
|
+
* undefined when it is missing, unauthenticated, or otherwise unhappy. Used for
|
|
114
|
+
* the optional `gh auth token` and `glab auth token` lookups: a missing CLI is
|
|
115
|
+
* never a reason to fail.
|
|
116
|
+
*
|
|
117
|
+
* @param command - The executable to try.
|
|
118
|
+
* @param args - Its arguments.
|
|
119
|
+
* @param options - Working directory and an optional runner override.
|
|
120
|
+
*/
|
|
121
|
+
export async function tryCommand(
|
|
122
|
+
command: string,
|
|
123
|
+
args: string[],
|
|
124
|
+
options: RunOptions = {}
|
|
125
|
+
): Promise<string | undefined> {
|
|
126
|
+
const run = options.run ?? spawnCommand;
|
|
127
|
+
try {
|
|
128
|
+
const result = await run(command, args, { cwd: options.cwd });
|
|
129
|
+
if (result.code !== 0) return undefined;
|
|
130
|
+
const output = result.stdout.trim();
|
|
131
|
+
return output.length > 0 ? output : undefined;
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Fetches ONE subtree of a repository at one tag, and nothing else.
|
|
139
|
+
*
|
|
140
|
+
* A shallow, blobless, sparse checkout is what makes this cheap: git downloads
|
|
141
|
+
* the commit at the tag, then only the blobs inside the recipe folder. Sous
|
|
142
|
+
* never copies a whole repository in order to install one recipe.
|
|
143
|
+
*
|
|
144
|
+
* The temporary checkout is made beside the destination rather than in the
|
|
145
|
+
* system temporary directory, so moving the subtree into place is a rename on
|
|
146
|
+
* one filesystem; a cross-device move still works, it just copies.
|
|
147
|
+
*
|
|
148
|
+
* @param options - The clone URL, the tag, the subtree and where it should land.
|
|
149
|
+
*/
|
|
150
|
+
export async function fetchSubtree(options: {
|
|
151
|
+
/** The repository's HTTPS clone URL. */
|
|
152
|
+
cloneUrl: string;
|
|
153
|
+
/** The git tag to fetch. */
|
|
154
|
+
tag: string;
|
|
155
|
+
/** The subtree's path, relative to the repository root. */
|
|
156
|
+
subPath: string;
|
|
157
|
+
/** Where the subtree's contents should end up. */
|
|
158
|
+
destDir: string;
|
|
159
|
+
/** How subprocesses are run. Defaults to spawning a real process. */
|
|
160
|
+
run?: CommandRunner;
|
|
161
|
+
}): Promise<void> {
|
|
162
|
+
const { cloneUrl, tag, subPath, destDir } = options;
|
|
163
|
+
const parent = path.dirname(destDir);
|
|
164
|
+
await fs.mkdir(parent, { recursive: true });
|
|
165
|
+
|
|
166
|
+
const workDir = await fs.mkdtemp(path.join(parent, ".sous-fetch-"));
|
|
167
|
+
const checkoutDir = path.join(workDir, "checkout");
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
await runGit(
|
|
171
|
+
[
|
|
172
|
+
"clone",
|
|
173
|
+
"--depth",
|
|
174
|
+
"1",
|
|
175
|
+
"--filter=blob:none",
|
|
176
|
+
"--sparse",
|
|
177
|
+
"--branch",
|
|
178
|
+
tag,
|
|
179
|
+
// Everything after this is a path or a URL, never an option, whatever it
|
|
180
|
+
// starts with. `repoUrlSchema` already refuses a leading hyphen; this is
|
|
181
|
+
// the second lock on the same door, and it is what `git-clone.ts` does.
|
|
182
|
+
"--",
|
|
183
|
+
cloneUrl,
|
|
184
|
+
checkoutDir,
|
|
185
|
+
],
|
|
186
|
+
{ run: options.run }
|
|
187
|
+
);
|
|
188
|
+
await runGit(["sparse-checkout", "set", subPath], {
|
|
189
|
+
cwd: checkoutDir,
|
|
190
|
+
run: options.run,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const source = path.join(checkoutDir, subPath);
|
|
194
|
+
if (!(await isDirectory(source))) {
|
|
195
|
+
throw new ConfigError(
|
|
196
|
+
`The repository ${cloneUrl} has no folder '${subPath}' at the tag '${tag}'.\n` +
|
|
197
|
+
` The repository's index says the recipe lives there, so either the index is ` +
|
|
198
|
+
`out of date or the tag points at the wrong commit.`
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
await fs.rm(destDir, { recursive: true, force: true });
|
|
203
|
+
await movePath(source, destDir);
|
|
204
|
+
} finally {
|
|
205
|
+
await fs.rm(workDir, { recursive: true, force: true });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** True when the path exists and is a directory. */
|
|
210
|
+
async function isDirectory(candidate: string): Promise<boolean> {
|
|
211
|
+
try {
|
|
212
|
+
return (await fs.stat(candidate)).isDirectory();
|
|
213
|
+
} catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Moves a directory, falling back to a recursive copy when the source and the
|
|
220
|
+
* destination live on different filesystems.
|
|
221
|
+
*
|
|
222
|
+
* @param source - The directory to move.
|
|
223
|
+
* @param destination - Where it should end up.
|
|
224
|
+
*/
|
|
225
|
+
async function movePath(source: string, destination: string): Promise<void> {
|
|
226
|
+
try {
|
|
227
|
+
await fs.rename(source, destination);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if ((error as NodeJS.ErrnoException).code !== "EXDEV") throw error;
|
|
230
|
+
await fs.cp(source, destination, { recursive: true });
|
|
231
|
+
await fs.rm(source, { recursive: true, force: true });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GitHub provider: the read path, and the write path behind it.
|
|
3
|
+
*
|
|
4
|
+
* Reading a repo needs no GitHub API and no `gh`: the index file is one raw
|
|
5
|
+
* HTTPS GET, and a recipe's subtree comes from git itself. A token is used when
|
|
6
|
+
* one is available, so private repositories work; it is read from GITHUB_TOKEN,
|
|
7
|
+
* or asked of the `gh` command line tool when that is installed and signed in.
|
|
8
|
+
* A missing `gh` is never an error on the read path.
|
|
9
|
+
*
|
|
10
|
+
* The write path is where `gh` becomes load-bearing, and this file is the ONLY
|
|
11
|
+
* place in sous that knows the command exists. Proposing a change is a pull
|
|
12
|
+
* request opened by `gh pr create`, a contributor without push permission works
|
|
13
|
+
* through a fork made by `gh repo fork`, and both are reported back as plain
|
|
14
|
+
* data, so the service that sequences them never learns a GitHub-shaped fact.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { ConfigError } from "../../errors.js";
|
|
18
|
+
import { INDEX_FILENAME } from "../formats/common.js";
|
|
19
|
+
import { ProviderBase, firstUrlIn } from "./base.js";
|
|
20
|
+
import { fetchSubtree, type CommandRunner } from "./git.js";
|
|
21
|
+
import { fetchText, type FetchLike } from "./http.js";
|
|
22
|
+
import {
|
|
23
|
+
buildCanonicalRepo,
|
|
24
|
+
invalidRepoUrl,
|
|
25
|
+
splitRepoUrl,
|
|
26
|
+
type AuthStatus,
|
|
27
|
+
type CanonicalRepo,
|
|
28
|
+
type ChangeProposal,
|
|
29
|
+
type FetchedIndex,
|
|
30
|
+
type ForkedRepo,
|
|
31
|
+
type ProposedChange,
|
|
32
|
+
type ProviderCli,
|
|
33
|
+
type ProviderFeature,
|
|
34
|
+
type ProviderOptions,
|
|
35
|
+
} from "./provider.js";
|
|
36
|
+
|
|
37
|
+
/** The host this provider serves when a URL does not say otherwise. */
|
|
38
|
+
export const GITHUB_HOST = "github.com";
|
|
39
|
+
|
|
40
|
+
/** The environment variable a GitHub token is read from. */
|
|
41
|
+
export const GITHUB_TOKEN_ENV = "GITHUB_TOKEN";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Finds a GitHub token: the environment first, then `gh auth token` when the
|
|
45
|
+
* `gh` command line tool is installed and signed in. Returns undefined when
|
|
46
|
+
* there is none, because public repositories need no token at all.
|
|
47
|
+
*
|
|
48
|
+
* @param options - Environment and subprocess runner overrides.
|
|
49
|
+
*/
|
|
50
|
+
export async function findGithubToken(options: {
|
|
51
|
+
env?: NodeJS.ProcessEnv;
|
|
52
|
+
run?: CommandRunner;
|
|
53
|
+
} = {}): Promise<string | undefined> {
|
|
54
|
+
return new GithubProvider().token(options);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The GitHub provider. */
|
|
58
|
+
export class GithubProvider extends ProviderBase {
|
|
59
|
+
readonly id = "github" as const;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Reads the index and recipe subtrees, and proposes a change through
|
|
63
|
+
* the GitHub CLI ('gh').
|
|
64
|
+
*/
|
|
65
|
+
readonly features: ProviderFeature[] = ["fetch", "submit"];
|
|
66
|
+
|
|
67
|
+
/** The command line tool the write path is built on. */
|
|
68
|
+
readonly cli: ProviderCli = {
|
|
69
|
+
command: "gh",
|
|
70
|
+
label: "the GitHub CLI",
|
|
71
|
+
install: "https://cli.github.com",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** What GitHub calls a proposal. */
|
|
75
|
+
readonly proposalNoun = "pull request";
|
|
76
|
+
|
|
77
|
+
matches(url: string): boolean {
|
|
78
|
+
const parts = splitRepoUrl(url);
|
|
79
|
+
return parts !== undefined && parts.host === GITHUB_HOST;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
canonicalize(url: string): CanonicalRepo {
|
|
83
|
+
const parts = splitRepoUrl(url);
|
|
84
|
+
if (parts === undefined) throw invalidRepoUrl(this.id, url);
|
|
85
|
+
return buildCanonicalRepo(parts.host, parts.owner, parts.name);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A GitHub token, from the environment or from `gh`. Public, because the
|
|
90
|
+
* index cache and the exported `findGithubToken` both ask for one.
|
|
91
|
+
*
|
|
92
|
+
* @param options - Environment and subprocess runner overrides.
|
|
93
|
+
*/
|
|
94
|
+
async token(options: ProviderOptions = {}): Promise<string | undefined> {
|
|
95
|
+
return this.findToken(GITHUB_TOKEN_ENV, ["auth", "token"], options);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Fetches the repo's index file from the raw content host at the repository's
|
|
100
|
+
* default branch, which is what `HEAD` names there.
|
|
101
|
+
*
|
|
102
|
+
* @param repo - The canonicalized repository.
|
|
103
|
+
* @param options - Environment, fetch and subprocess overrides.
|
|
104
|
+
*/
|
|
105
|
+
async fetchIndex(
|
|
106
|
+
repo: CanonicalRepo,
|
|
107
|
+
options: ProviderOptions = {}
|
|
108
|
+
): Promise<FetchedIndex> {
|
|
109
|
+
const token = await this.token(options);
|
|
110
|
+
const url = this.indexUrl(repo);
|
|
111
|
+
const fetched = await fetchText(url, {
|
|
112
|
+
...(token === undefined ? {} : { token }),
|
|
113
|
+
...(options.fetchImpl === undefined
|
|
114
|
+
? {}
|
|
115
|
+
: { fetchImpl: options.fetchImpl as FetchLike }),
|
|
116
|
+
label: "repo index",
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return fetched.etag === undefined
|
|
120
|
+
? { text: fetched.text, ref: "HEAD" }
|
|
121
|
+
: { text: fetched.text, ref: "HEAD", etag: fetched.etag };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Fetches one recipe folder at one tag. Private repositories work through
|
|
126
|
+
* git's own credential helpers, the same way a manual clone would.
|
|
127
|
+
*
|
|
128
|
+
* @param repo - The canonicalized repository.
|
|
129
|
+
* @param recipePath - The recipe folder, relative to the repository root.
|
|
130
|
+
* @param tag - The git tag carrying the version.
|
|
131
|
+
* @param destDir - Where the recipe's files should end up.
|
|
132
|
+
* @param options - Subprocess runner override.
|
|
133
|
+
*/
|
|
134
|
+
async fetchRecipeTree(
|
|
135
|
+
repo: CanonicalRepo,
|
|
136
|
+
recipePath: string,
|
|
137
|
+
tag: string,
|
|
138
|
+
destDir: string,
|
|
139
|
+
options: ProviderOptions = {}
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
await fetchSubtree({
|
|
142
|
+
cloneUrl: repo.httpsUrl,
|
|
143
|
+
tag,
|
|
144
|
+
subPath: recipePath,
|
|
145
|
+
destDir,
|
|
146
|
+
...(options.run === undefined ? {} : { run: options.run }),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The raw URL of a repository's index file at its default branch.
|
|
152
|
+
*
|
|
153
|
+
* @param repo - The canonicalized repository.
|
|
154
|
+
*/
|
|
155
|
+
indexUrl(repo: CanonicalRepo): string {
|
|
156
|
+
return `https://raw.githubusercontent.com/${repo.owner}/${repo.name}/HEAD/${INDEX_FILENAME}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// --- The write path --------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Whether `gh` is installed and signed in. The detail is the whole
|
|
163
|
+
* explanation, ready to print, because only this provider knows what to
|
|
164
|
+
* install and which command signs in.
|
|
165
|
+
*
|
|
166
|
+
* @param options - Subprocess runner and working directory overrides.
|
|
167
|
+
*/
|
|
168
|
+
async authStatus(options: ProviderOptions = {}): Promise<AuthStatus> {
|
|
169
|
+
const ok = await this.commandSucceeds(this.cli.command, ["auth", "status"], options);
|
|
170
|
+
if (ok) {
|
|
171
|
+
return {
|
|
172
|
+
ok: true,
|
|
173
|
+
detail: `${this.cli.label} ('${this.cli.command}') is installed and signed in.`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
detail:
|
|
179
|
+
`Sous proposes a change through ${this.cli.label} ('${this.cli.command}'), and it is ` +
|
|
180
|
+
`either not installed or not signed in.\n` +
|
|
181
|
+
` Install it from ${this.cli.install}, then run '${this.cli.command} auth login'.`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Whether the signed-in contributor may push to the repository itself, as
|
|
187
|
+
* GitHub reports it. Undefined when `gh` could not answer at all, which is
|
|
188
|
+
* not the same as a refusal.
|
|
189
|
+
*
|
|
190
|
+
* @param repo - The canonicalized repository.
|
|
191
|
+
* @param options - Subprocess runner and working directory overrides.
|
|
192
|
+
*/
|
|
193
|
+
async canPush(
|
|
194
|
+
repo: CanonicalRepo,
|
|
195
|
+
options: ProviderOptions = {}
|
|
196
|
+
): Promise<boolean | undefined> {
|
|
197
|
+
const answer = await this.capturedOutput(
|
|
198
|
+
this.cli.command,
|
|
199
|
+
["api", `repos/${repo.owner}/${repo.name}`, "--jq", ".permissions.push"],
|
|
200
|
+
options
|
|
201
|
+
);
|
|
202
|
+
if (answer === undefined) return undefined;
|
|
203
|
+
const value = answer.trim();
|
|
204
|
+
if (value === "true") return true;
|
|
205
|
+
if (value === "false") return false;
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Forks the repository onto the contributor's own account and says where the
|
|
211
|
+
* fork landed. No remote is added here; that is git's business, and the
|
|
212
|
+
* service above does it with the URLs returned.
|
|
213
|
+
*
|
|
214
|
+
* @param repo - The canonicalized repository.
|
|
215
|
+
* @param options - Subprocess runner and working directory overrides.
|
|
216
|
+
*/
|
|
217
|
+
async fork(repo: CanonicalRepo, options: ProviderOptions = {}): Promise<ForkedRepo> {
|
|
218
|
+
const forked = await this.runCommand(
|
|
219
|
+
this.cli.command,
|
|
220
|
+
["repo", "fork", `${repo.owner}/${repo.name}`, "--remote=false"],
|
|
221
|
+
options
|
|
222
|
+
);
|
|
223
|
+
if (forked.code !== 0) {
|
|
224
|
+
throw new ConfigError(
|
|
225
|
+
`'${this.cli.command} repo fork' did not succeed.\n ` +
|
|
226
|
+
`${forked.stderr.trim() || forked.stdout.trim()}`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const who = await this.capturedOutput(
|
|
231
|
+
this.cli.command,
|
|
232
|
+
["api", "user", "--jq", ".login"],
|
|
233
|
+
options
|
|
234
|
+
);
|
|
235
|
+
if (who === undefined) {
|
|
236
|
+
throw new ConfigError(
|
|
237
|
+
"The fork was requested, but sous could not read your GitHub login from " +
|
|
238
|
+
`'${this.cli.command} api user', so it does not know where the fork lives.`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const owner = who.trim();
|
|
243
|
+
return {
|
|
244
|
+
owner,
|
|
245
|
+
name: repo.name,
|
|
246
|
+
httpsUrl: `https://${repo.host}/${owner}/${repo.name}.git`,
|
|
247
|
+
sshUrl: `git@${repo.host}:${owner}/${repo.name}.git`,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Opens a pull request for a branch that has already been pushed. A proposal
|
|
253
|
+
* carrying a head owner came from a fork, which is what a cross-repository
|
|
254
|
+
* pull request spells as `owner:branch`.
|
|
255
|
+
*
|
|
256
|
+
* @param repo - The canonicalized repository the proposal targets.
|
|
257
|
+
* @param proposal - The branch, the text and whether it is a draft.
|
|
258
|
+
* @param options - Subprocess runner and working directory overrides.
|
|
259
|
+
*/
|
|
260
|
+
async proposeChange(
|
|
261
|
+
repo: CanonicalRepo,
|
|
262
|
+
proposal: ChangeProposal,
|
|
263
|
+
options: ProviderOptions = {}
|
|
264
|
+
): Promise<ProposedChange> {
|
|
265
|
+
const head =
|
|
266
|
+
proposal.head === undefined
|
|
267
|
+
? proposal.branch
|
|
268
|
+
: `${proposal.head.owner}:${proposal.branch}`;
|
|
269
|
+
|
|
270
|
+
const args = ["pr", "create", "--repo", `${repo.owner}/${repo.name}`];
|
|
271
|
+
if (proposal.base !== undefined) args.push("--base", proposal.base);
|
|
272
|
+
args.push("--head", head, "--title", proposal.title, "--body", proposal.body);
|
|
273
|
+
if (proposal.draft) args.push("--draft");
|
|
274
|
+
|
|
275
|
+
const result = await this.runCommand(this.cli.command, args, options);
|
|
276
|
+
if (result.code !== 0) {
|
|
277
|
+
const reported = result.stderr.trim() || result.stdout.trim();
|
|
278
|
+
throw new ConfigError(
|
|
279
|
+
`'${this.cli.command} pr create' did not succeed, so no proposal was opened.` +
|
|
280
|
+
(reported.length === 0 ? "" : `\n ${reported}`)
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const url = firstUrlIn(result.stdout);
|
|
285
|
+
if (url === undefined) {
|
|
286
|
+
return {
|
|
287
|
+
detail:
|
|
288
|
+
`The ${this.proposalNoun} was opened, but '${this.cli.command}' printed no address ` +
|
|
289
|
+
`for it.`,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
return { url, detail: `The ${this.proposalNoun} is at ${url}.` };
|
|
293
|
+
}
|
|
294
|
+
}
|