@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.
- package/README.md +121 -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 +408 -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 +73 -9
- 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 +619 -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 +413 -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/bin/xcv +0 -5
- 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,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git tags as the cheap enumeration path for published recipe versions.
|
|
3
|
+
*
|
|
4
|
+
* Recipe metadata is the source of truth for versions; a tag shaped
|
|
5
|
+
* `namespace/recipe@1.2.3` is a convenience ref that points at the commit the
|
|
6
|
+
* version was published from. The two must agree, and this module is what makes
|
|
7
|
+
* that checkable: it names tags, lists them, reads a file back out of one, and
|
|
8
|
+
* materializes a tagged recipe folder so its content can be hashed.
|
|
9
|
+
*
|
|
10
|
+
* Every function takes the injectable command runner, so a test drives the whole
|
|
11
|
+
* surface against a repository made with `git init` and never reaches a network.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from "node:fs/promises";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { runGit, type RunOptions } from "../providers/git.js";
|
|
18
|
+
import { RECIPE_KEY_PATTERN } from "../formats/patterns.js";
|
|
19
|
+
|
|
20
|
+
/** One release tag, taken apart into the recipe it publishes. */
|
|
21
|
+
export type RecipeTag = {
|
|
22
|
+
/** The tag exactly as git holds it, `namespace/recipe@version`. */
|
|
23
|
+
tag: string;
|
|
24
|
+
/** The recipe's namespace. */
|
|
25
|
+
namespace: string;
|
|
26
|
+
/** The recipe's name. */
|
|
27
|
+
name: string;
|
|
28
|
+
/** The exact version the tag publishes. */
|
|
29
|
+
version: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The recipe key (`namespace/recipe`) a tag belongs to. */
|
|
33
|
+
export function recipeTagKey(tag: RecipeTag): string {
|
|
34
|
+
return `${tag.namespace}/${tag.name}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The tag that publishes one version of one recipe.
|
|
39
|
+
*
|
|
40
|
+
* @param namespace - The recipe's namespace.
|
|
41
|
+
* @param name - The recipe's name.
|
|
42
|
+
* @param version - The exact version being published.
|
|
43
|
+
*/
|
|
44
|
+
export function tagFor(namespace: string, name: string, version: string): string {
|
|
45
|
+
return `${namespace}/${name}@${version}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Takes a tag apart, or returns undefined when it is not a recipe release tag.
|
|
50
|
+
* A repository may carry tags of its own shape (`v1.2.0`, say), and those are
|
|
51
|
+
* simply not ours.
|
|
52
|
+
*
|
|
53
|
+
* @param tag - The tag name as git holds it.
|
|
54
|
+
*/
|
|
55
|
+
export function parseRecipeTag(tag: string): RecipeTag | undefined {
|
|
56
|
+
const at = tag.lastIndexOf("@");
|
|
57
|
+
if (at <= 0 || at === tag.length - 1) return undefined;
|
|
58
|
+
|
|
59
|
+
const key = tag.slice(0, at);
|
|
60
|
+
const version = tag.slice(at + 1);
|
|
61
|
+
if (!RECIPE_KEY_PATTERN.test(key)) return undefined;
|
|
62
|
+
|
|
63
|
+
const slash = key.indexOf("/");
|
|
64
|
+
return {
|
|
65
|
+
tag,
|
|
66
|
+
namespace: key.slice(0, slash),
|
|
67
|
+
name: key.slice(slash + 1),
|
|
68
|
+
version,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Every recipe release tag in a repository, in the order git lists them. Tags
|
|
74
|
+
* that are not shaped like a recipe release are left out.
|
|
75
|
+
*
|
|
76
|
+
* @param rootDir - The repository's root directory.
|
|
77
|
+
* @param options - The command runner to use.
|
|
78
|
+
*/
|
|
79
|
+
export async function listRecipeTags(
|
|
80
|
+
rootDir: string,
|
|
81
|
+
options: RunOptions = {}
|
|
82
|
+
): Promise<RecipeTag[]> {
|
|
83
|
+
const output = await runGit(["tag", "--list", "*@*"], {
|
|
84
|
+
cwd: rootDir,
|
|
85
|
+
run: options.run,
|
|
86
|
+
});
|
|
87
|
+
if (output.length === 0) return [];
|
|
88
|
+
|
|
89
|
+
const tags: RecipeTag[] = [];
|
|
90
|
+
for (const line of output.split("\n")) {
|
|
91
|
+
const parsed = parseRecipeTag(line.trim());
|
|
92
|
+
if (parsed !== undefined) tags.push(parsed);
|
|
93
|
+
}
|
|
94
|
+
return tags;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The contents of one file as it stood at a tag, or undefined when the tag does
|
|
99
|
+
* not carry that file.
|
|
100
|
+
*
|
|
101
|
+
* @param rootDir - The repository's root directory.
|
|
102
|
+
* @param tag - The tag to read at.
|
|
103
|
+
* @param relativePath - The file's path relative to the repository root.
|
|
104
|
+
* @param options - The command runner to use.
|
|
105
|
+
*/
|
|
106
|
+
export async function readFileAtTag(
|
|
107
|
+
rootDir: string,
|
|
108
|
+
tag: string,
|
|
109
|
+
relativePath: string,
|
|
110
|
+
options: RunOptions = {}
|
|
111
|
+
): Promise<string | undefined> {
|
|
112
|
+
try {
|
|
113
|
+
return await runGit(["show", `${tag}:${toPosix(relativePath)}`], {
|
|
114
|
+
cwd: rootDir,
|
|
115
|
+
run: options.run,
|
|
116
|
+
});
|
|
117
|
+
} catch {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* When the commit a tag points at was made, as an ISO 8601 timestamp, or
|
|
124
|
+
* undefined when git cannot say.
|
|
125
|
+
*
|
|
126
|
+
* @param rootDir - The repository's root directory.
|
|
127
|
+
* @param tag - The tag to date.
|
|
128
|
+
* @param options - The command runner to use.
|
|
129
|
+
*/
|
|
130
|
+
export async function tagCommitDate(
|
|
131
|
+
rootDir: string,
|
|
132
|
+
tag: string,
|
|
133
|
+
options: RunOptions = {}
|
|
134
|
+
): Promise<string | undefined> {
|
|
135
|
+
let raw: string;
|
|
136
|
+
try {
|
|
137
|
+
raw = await runGit(["log", "-1", "--format=%cI", tag], {
|
|
138
|
+
cwd: rootDir,
|
|
139
|
+
run: options.run,
|
|
140
|
+
});
|
|
141
|
+
} catch {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const parsed = new Date(raw.trim());
|
|
146
|
+
if (Number.isNaN(parsed.getTime())) return undefined;
|
|
147
|
+
return parsed.toISOString();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Checks out the tree at a tag into a temporary directory, hands the caller the
|
|
152
|
+
* path of one folder inside it, and cleans up afterwards.
|
|
153
|
+
*
|
|
154
|
+
* A linked worktree is used rather than `git archive` piped into a tar reader:
|
|
155
|
+
* the command runner captures output as text, so an archive's bytes could not
|
|
156
|
+
* survive the trip, while a worktree puts the real files on disk where they can
|
|
157
|
+
* be read and hashed exactly as a fetched copy would be.
|
|
158
|
+
*
|
|
159
|
+
* @param rootDir - The repository's root directory.
|
|
160
|
+
* @param tag - The tag to check out.
|
|
161
|
+
* @param subPath - The folder inside the tree to hand back, relative to the root.
|
|
162
|
+
* @param use - Called with the absolute path of that folder.
|
|
163
|
+
* @param options - The command runner to use.
|
|
164
|
+
*/
|
|
165
|
+
export async function withTaggedTree<T>(
|
|
166
|
+
rootDir: string,
|
|
167
|
+
tag: string,
|
|
168
|
+
subPath: string,
|
|
169
|
+
use: (dir: string) => Promise<T>,
|
|
170
|
+
options: RunOptions = {}
|
|
171
|
+
): Promise<T> {
|
|
172
|
+
const workRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sous-release-"));
|
|
173
|
+
const checkout = path.join(workRoot, "tree");
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
await runGit(["worktree", "add", "--detach", "--quiet", checkout, tag], {
|
|
177
|
+
cwd: rootDir,
|
|
178
|
+
run: options.run,
|
|
179
|
+
});
|
|
180
|
+
return await use(path.join(checkout, ...toPosix(subPath).split("/")));
|
|
181
|
+
} finally {
|
|
182
|
+
// Remove the worktree through git first, so its administrative record goes
|
|
183
|
+
// with it; a plain delete would leave the repository listing a worktree
|
|
184
|
+
// that is no longer there.
|
|
185
|
+
try {
|
|
186
|
+
await runGit(["worktree", "remove", "--force", checkout], {
|
|
187
|
+
cwd: rootDir,
|
|
188
|
+
run: options.run,
|
|
189
|
+
});
|
|
190
|
+
} catch {
|
|
191
|
+
// The worktree was never created, or git already dropped it. Either way
|
|
192
|
+
// the temporary directory below is what actually needs removing.
|
|
193
|
+
}
|
|
194
|
+
await fs.rm(workRoot, { recursive: true, force: true });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Creates an annotated tag on HEAD.
|
|
200
|
+
*
|
|
201
|
+
* @param rootDir - The repository's root directory.
|
|
202
|
+
* @param tag - The tag to create.
|
|
203
|
+
* @param message - The tag's annotation message.
|
|
204
|
+
* @param options - The command runner to use.
|
|
205
|
+
*/
|
|
206
|
+
export async function createAnnotatedTag(
|
|
207
|
+
rootDir: string,
|
|
208
|
+
tag: string,
|
|
209
|
+
message: string,
|
|
210
|
+
options: RunOptions = {}
|
|
211
|
+
): Promise<void> {
|
|
212
|
+
await runGit(["tag", "--annotate", tag, "--message", message], {
|
|
213
|
+
cwd: rootDir,
|
|
214
|
+
run: options.run,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Pushes exactly the named tags to a remote, and nothing else. Sous never
|
|
220
|
+
* pushes a branch as a side effect of tagging.
|
|
221
|
+
*
|
|
222
|
+
* @param rootDir - The repository's root directory.
|
|
223
|
+
* @param remote - The remote to push to, normally `origin`.
|
|
224
|
+
* @param tags - The tags to push.
|
|
225
|
+
* @param options - The command runner to use.
|
|
226
|
+
*/
|
|
227
|
+
export async function pushTags(
|
|
228
|
+
rootDir: string,
|
|
229
|
+
remote: string,
|
|
230
|
+
tags: ReadonlyArray<string>,
|
|
231
|
+
options: RunOptions = {}
|
|
232
|
+
): Promise<void> {
|
|
233
|
+
if (tags.length === 0) return;
|
|
234
|
+
await runGit(["push", remote, ...tags.map((tag) => `refs/tags/${tag}`)], {
|
|
235
|
+
cwd: rootDir,
|
|
236
|
+
run: options.run,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Rewrites a path with forward slashes, which is what git speaks everywhere. */
|
|
241
|
+
function toPosix(value: string): string {
|
|
242
|
+
return value.split(path.sep).join("/");
|
|
243
|
+
}
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publish-side validation for a recipe repository.
|
|
3
|
+
*
|
|
4
|
+
* `sous repo release` and `sous repo submit` both refuse to do anything until
|
|
5
|
+
* the repository they are standing in describes itself consistently: every
|
|
6
|
+
* folder the repo manifest lists exists and holds a recipe manifest, every
|
|
7
|
+
* recipe belongs to a declared namespace, no two recipes share a key, and no
|
|
8
|
+
* two variable definitions quietly claim the same environment variable.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here runs git or touches the network; it reads files and reports.
|
|
11
|
+
* Problems are collected rather than thrown, so one run tells an author
|
|
12
|
+
* everything that is wrong instead of only the first thing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { ConfigError } from "../../errors.js";
|
|
18
|
+
import { MANIFEST_EXTENSIONS, REPO_MANIFEST_BASENAME } from "../formats/common.js";
|
|
19
|
+
import {
|
|
20
|
+
parseRecipeManifest,
|
|
21
|
+
recipeManifestKey,
|
|
22
|
+
type RecipeManifest,
|
|
23
|
+
} from "../formats/recipe-manifest.js";
|
|
24
|
+
import { parseRepoManifest, type RepoManifest } from "../formats/repo-manifest.js";
|
|
25
|
+
import {
|
|
26
|
+
findRecipeManifest,
|
|
27
|
+
findRepoManifest,
|
|
28
|
+
loadManifestFile,
|
|
29
|
+
requireRepoManifest,
|
|
30
|
+
} from "../load-manifest.js";
|
|
31
|
+
import { bareName } from "../../vars/names.js";
|
|
32
|
+
|
|
33
|
+
/** How serious a validation finding is. An error stops a release; a warning does not. */
|
|
34
|
+
export type ProblemLevel = "error" | "warning";
|
|
35
|
+
|
|
36
|
+
/** One validation finding, with enough location to act on it. */
|
|
37
|
+
export type ValidationProblem = {
|
|
38
|
+
/** Whether this stops a release or only deserves saying out loud. */
|
|
39
|
+
level: ProblemLevel;
|
|
40
|
+
/** Where it was found: a repo-relative file path, with a field path when there is one. */
|
|
41
|
+
where: string;
|
|
42
|
+
/** What is wrong, in plain language, and what to do about it. */
|
|
43
|
+
message: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** One recipe folder that was found, read and validated. */
|
|
47
|
+
export type ValidatedRecipe = {
|
|
48
|
+
/** The recipe folder's path relative to the repository root, as the repo manifest lists it. */
|
|
49
|
+
path: string;
|
|
50
|
+
/** Absolute path to the recipe folder. */
|
|
51
|
+
dir: string;
|
|
52
|
+
/** Absolute path to the recipe manifest inside that folder. */
|
|
53
|
+
manifestPath: string;
|
|
54
|
+
/** The validated recipe manifest. */
|
|
55
|
+
manifest: RecipeManifest;
|
|
56
|
+
/** The recipe's key, `namespace/name`. */
|
|
57
|
+
key: string;
|
|
58
|
+
/** The manifest exactly as it was parsed, before the schema dropped its `x-` keys. */
|
|
59
|
+
raw: unknown;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Everything one pass over a repository found. */
|
|
63
|
+
export type RepoValidation = {
|
|
64
|
+
/** Absolute path to the repository root. */
|
|
65
|
+
rootDir: string;
|
|
66
|
+
/** Absolute path to the repo manifest at that root. */
|
|
67
|
+
manifestPath: string;
|
|
68
|
+
/** The validated repo manifest. */
|
|
69
|
+
manifest: RepoManifest;
|
|
70
|
+
/** Every recipe that could be read, in the order the repo manifest lists them. */
|
|
71
|
+
recipes: ValidatedRecipe[];
|
|
72
|
+
/** Everything found wrong, errors and warnings together. */
|
|
73
|
+
problems: ValidationProblem[];
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Environment variable names a recipe should not claim without saying it meant
|
|
78
|
+
* to: the ones an operating system, a shell or a well-known tool already owns.
|
|
79
|
+
* Claiming one is legal and occasionally correct (binding an existing
|
|
80
|
+
* `GITHUB_TOKEN` is the motivating case), so this is a warning, silenced by
|
|
81
|
+
* setting `x-intentional: true` on the definition.
|
|
82
|
+
*/
|
|
83
|
+
export const WELL_KNOWN_ENV_NAMES: readonly string[] = [
|
|
84
|
+
"PATH",
|
|
85
|
+
"HOME",
|
|
86
|
+
"USER",
|
|
87
|
+
"SHELL",
|
|
88
|
+
"GITHUB_TOKEN",
|
|
89
|
+
"GITLAB_TOKEN",
|
|
90
|
+
"NPM_TOKEN",
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Prefixes treated the same way as the names above. `SOUS_VAR_` is deliberately
|
|
95
|
+
* exempt: every name sous derives for an answer starts with it, so warning on
|
|
96
|
+
* it would fire on every well-behaved definition in every repository.
|
|
97
|
+
*/
|
|
98
|
+
export const WELL_KNOWN_ENV_PREFIXES: readonly string[] = ["AWS_", "SOUS_"];
|
|
99
|
+
|
|
100
|
+
/** The extension key that silences the well-known environment name warning. */
|
|
101
|
+
export const INTENTIONAL_EXTENSION_KEY = "x-intentional";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Walks up from a directory to the repository root: the first directory at or
|
|
105
|
+
* above it holding a repo manifest.
|
|
106
|
+
*
|
|
107
|
+
* @param startDir - Where to start looking, normally the working directory.
|
|
108
|
+
* @returns The absolute path of the repository root.
|
|
109
|
+
*/
|
|
110
|
+
export function findRepoRoot(startDir: string): string {
|
|
111
|
+
let current = path.resolve(startDir);
|
|
112
|
+
|
|
113
|
+
for (;;) {
|
|
114
|
+
if (findRepoManifest(current) !== undefined) return current;
|
|
115
|
+
const parent = path.dirname(current);
|
|
116
|
+
if (parent === current) break;
|
|
117
|
+
current = parent;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
throw new ConfigError(
|
|
121
|
+
`${path.resolve(startDir)} is not inside a sous recipe repository.\n` +
|
|
122
|
+
` A recipe repository has a '${REPO_MANIFEST_BASENAME}${MANIFEST_EXTENSIONS[0]}' file at ` +
|
|
123
|
+
`its root, and sous looked in that directory and in every directory above it.\n` +
|
|
124
|
+
` Run the command from inside a repository, or create one with 'sous repo init'.`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Reads and checks a whole recipe repository.
|
|
130
|
+
*
|
|
131
|
+
* The repo manifest itself must parse; there is nothing to report against when
|
|
132
|
+
* it does not, so that failure is thrown as a ConfigError. Everything below it
|
|
133
|
+
* is collected into `problems`, so one run surfaces every fault at once.
|
|
134
|
+
*
|
|
135
|
+
* @param rootDir - Absolute path to the repository root.
|
|
136
|
+
*/
|
|
137
|
+
export function validateRepo(rootDir: string): RepoValidation {
|
|
138
|
+
const manifestPath = requireRepoManifest(rootDir);
|
|
139
|
+
const manifest = parseRepoManifest(loadManifestFile(manifestPath), manifestPath);
|
|
140
|
+
|
|
141
|
+
const problems: ValidationProblem[] = [];
|
|
142
|
+
const recipes: ValidatedRecipe[] = [];
|
|
143
|
+
const seenKeys = new Map<string, string>();
|
|
144
|
+
const manifestName = path.basename(manifestPath);
|
|
145
|
+
|
|
146
|
+
for (const recipePath of manifest.recipes) {
|
|
147
|
+
const dir = path.join(rootDir, recipePath);
|
|
148
|
+
const found = readRecipe(rootDir, recipePath, dir, manifestName, problems);
|
|
149
|
+
if (found === undefined) continue;
|
|
150
|
+
|
|
151
|
+
if (!Object.hasOwn(manifest.namespaces, found.manifest.namespace)) {
|
|
152
|
+
problems.push({
|
|
153
|
+
level: "error",
|
|
154
|
+
where: `${relative(rootDir, found.manifestPath)} namespace`,
|
|
155
|
+
message:
|
|
156
|
+
`the recipe is in the namespace '${found.manifest.namespace}', which ` +
|
|
157
|
+
`${manifestName} does not declare under 'namespaces'. Declare the namespace, or ` +
|
|
158
|
+
`move the recipe into one that exists.`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const firstPath = seenKeys.get(found.key);
|
|
163
|
+
if (firstPath !== undefined) {
|
|
164
|
+
problems.push({
|
|
165
|
+
level: "error",
|
|
166
|
+
where: relative(rootDir, found.manifestPath),
|
|
167
|
+
message:
|
|
168
|
+
`the recipe '${found.key}' is also published by ${firstPath}. A namespace and a ` +
|
|
169
|
+
`name together name exactly one recipe, so rename one of the two.`,
|
|
170
|
+
});
|
|
171
|
+
} else {
|
|
172
|
+
seenKeys.set(found.key, relative(rootDir, found.manifestPath));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
problems.push(...checkSymlinks(recipePath, dir));
|
|
176
|
+
|
|
177
|
+
recipes.push(found);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
problems.push(...checkVariableEnvNames(rootDir, recipes));
|
|
181
|
+
|
|
182
|
+
return { rootDir, manifestPath, manifest, recipes, problems };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** True when any problem in the list is an error. */
|
|
186
|
+
export function hasErrors(problems: ReadonlyArray<ValidationProblem>): boolean {
|
|
187
|
+
return problems.some((problem) => problem.level === "error");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The errors in a problem list, in the order they were found. */
|
|
191
|
+
export function errorsIn(problems: ReadonlyArray<ValidationProblem>): ValidationProblem[] {
|
|
192
|
+
return problems.filter((problem) => problem.level === "error");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The warnings in a problem list, in the order they were found. */
|
|
196
|
+
export function warningsIn(
|
|
197
|
+
problems: ReadonlyArray<ValidationProblem>
|
|
198
|
+
): ValidationProblem[] {
|
|
199
|
+
return problems.filter((problem) => problem.level === "warning");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The reason an environment variable name deserves a warning, or undefined when
|
|
204
|
+
* it is an ordinary name. Exported so a caller can explain the rule without
|
|
205
|
+
* repeating the list.
|
|
206
|
+
*
|
|
207
|
+
* @param envName - The environment variable name a definition claims.
|
|
208
|
+
*/
|
|
209
|
+
export function wellKnownEnvReason(envName: string): string | undefined {
|
|
210
|
+
if (WELL_KNOWN_ENV_NAMES.includes(envName)) {
|
|
211
|
+
return `'${envName}' is a well-known name that the system or another tool already uses`;
|
|
212
|
+
}
|
|
213
|
+
if (envName.startsWith("SOUS_VAR_")) return undefined;
|
|
214
|
+
for (const prefix of WELL_KNOWN_ENV_PREFIXES) {
|
|
215
|
+
if (envName.startsWith(prefix)) {
|
|
216
|
+
return (
|
|
217
|
+
`'${envName}' starts with '${prefix}', a prefix reserved by the system or by ` +
|
|
218
|
+
`another tool`
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// --- Internals ----------------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The message a thrown value should be reported with. A ConfigError already
|
|
229
|
+
* carries plain-language wording, and every other Error at least carries a
|
|
230
|
+
* message; anything else is rendered as it stands.
|
|
231
|
+
*
|
|
232
|
+
* @param error - The value that was thrown.
|
|
233
|
+
*/
|
|
234
|
+
export function describeError(error: unknown): string {
|
|
235
|
+
return error instanceof Error ? error.message : String(error);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Renders an absolute path as a repository-relative one, with forward slashes. */
|
|
239
|
+
function relative(rootDir: string, target: string): string {
|
|
240
|
+
return path.relative(rootDir, target).split(path.sep).join("/");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Reads one recipe folder. Returns undefined, having recorded a problem, when
|
|
245
|
+
* the folder is missing or its manifest cannot be read.
|
|
246
|
+
*/
|
|
247
|
+
function readRecipe(
|
|
248
|
+
rootDir: string,
|
|
249
|
+
recipePath: string,
|
|
250
|
+
dir: string,
|
|
251
|
+
manifestName: string,
|
|
252
|
+
problems: ValidationProblem[]
|
|
253
|
+
): ValidatedRecipe | undefined {
|
|
254
|
+
if (!isDirectory(dir)) {
|
|
255
|
+
problems.push({
|
|
256
|
+
level: "error",
|
|
257
|
+
where: recipePath,
|
|
258
|
+
message:
|
|
259
|
+
`${manifestName} lists this recipe folder, but there is no directory there. Create ` +
|
|
260
|
+
`it, or remove the path from the 'recipes' list.`,
|
|
261
|
+
});
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let manifestPath: string | undefined;
|
|
266
|
+
try {
|
|
267
|
+
manifestPath = findRecipeManifest(dir);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
problems.push({
|
|
270
|
+
level: "error",
|
|
271
|
+
where: recipePath,
|
|
272
|
+
message: describeError(error),
|
|
273
|
+
});
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (manifestPath === undefined) {
|
|
278
|
+
problems.push({
|
|
279
|
+
level: "error",
|
|
280
|
+
where: recipePath,
|
|
281
|
+
message:
|
|
282
|
+
"there is no recipe manifest in this folder. Every folder listed under 'recipes' " +
|
|
283
|
+
"holds one 'sous.recipe.yaml'.",
|
|
284
|
+
});
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let raw: unknown;
|
|
289
|
+
let manifest: RecipeManifest;
|
|
290
|
+
try {
|
|
291
|
+
raw = loadManifestFile(manifestPath);
|
|
292
|
+
manifest = parseRecipeManifest(raw, manifestPath);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
problems.push({
|
|
295
|
+
level: "error",
|
|
296
|
+
where: relative(rootDir, manifestPath),
|
|
297
|
+
message: describeError(error),
|
|
298
|
+
});
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
path: recipePath,
|
|
304
|
+
dir,
|
|
305
|
+
manifestPath,
|
|
306
|
+
manifest,
|
|
307
|
+
key: recipeManifestKey(manifest),
|
|
308
|
+
raw,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** One definition's claim on an environment variable name. */
|
|
313
|
+
type EnvClaim = {
|
|
314
|
+
envName: string;
|
|
315
|
+
variableName: string;
|
|
316
|
+
where: string;
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Checks every variable definition in the repository against every other one.
|
|
321
|
+
*
|
|
322
|
+
* Two definitions of the SAME variable name may share an environment variable:
|
|
323
|
+
* that is the shared rung of the resolution ladder doing its job, and it is how
|
|
324
|
+
* one answer serves every recipe that asks the same question. Two definitions
|
|
325
|
+
* of DIFFERENT variable names sharing one name is a genuine collision, because
|
|
326
|
+
* a single answer would silently satisfy both.
|
|
327
|
+
*/
|
|
328
|
+
function checkVariableEnvNames(
|
|
329
|
+
rootDir: string,
|
|
330
|
+
recipes: ReadonlyArray<ValidatedRecipe>
|
|
331
|
+
): ValidationProblem[] {
|
|
332
|
+
const problems: ValidationProblem[] = [];
|
|
333
|
+
const byEnvName = new Map<string, EnvClaim>();
|
|
334
|
+
const byVariableName = new Map<string, EnvClaim>();
|
|
335
|
+
|
|
336
|
+
for (const recipe of recipes) {
|
|
337
|
+
const definitions = recipe.manifest.variables ?? [];
|
|
338
|
+
definitions.forEach((definition, index) => {
|
|
339
|
+
const envName = bareName(definition);
|
|
340
|
+
const where =
|
|
341
|
+
`${relative(rootDir, recipe.manifestPath)} variables[${index}] ` +
|
|
342
|
+
`('${definition.name}')`;
|
|
343
|
+
const claim: EnvClaim = { envName, variableName: definition.name, where };
|
|
344
|
+
|
|
345
|
+
const byEnv = byEnvName.get(envName);
|
|
346
|
+
if (byEnv !== undefined && byEnv.variableName !== definition.name) {
|
|
347
|
+
problems.push({
|
|
348
|
+
level: "error",
|
|
349
|
+
where,
|
|
350
|
+
message:
|
|
351
|
+
`claims the environment variable '${envName}', which ${byEnv.where} already ` +
|
|
352
|
+
`claims for the different variable '${byEnv.variableName}'. Two definitions ` +
|
|
353
|
+
`cannot share one environment variable; set 'env' explicitly on one of them.`,
|
|
354
|
+
});
|
|
355
|
+
} else if (byEnv === undefined) {
|
|
356
|
+
byEnvName.set(envName, claim);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const byName = byVariableName.get(definition.name);
|
|
360
|
+
if (byName !== undefined && byName.envName !== envName) {
|
|
361
|
+
problems.push({
|
|
362
|
+
level: "warning",
|
|
363
|
+
where,
|
|
364
|
+
message:
|
|
365
|
+
`binds to '${envName}', while ${byName.where} binds the same variable name to ` +
|
|
366
|
+
`'${byName.envName}'. One answer will not serve both; give them one 'env' value ` +
|
|
367
|
+
`if they are meant to be the same question.`,
|
|
368
|
+
});
|
|
369
|
+
} else if (byName === undefined) {
|
|
370
|
+
byVariableName.set(definition.name, claim);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const reason = wellKnownEnvReason(envName);
|
|
374
|
+
if (reason !== undefined && !isIntentional(recipe.raw, index)) {
|
|
375
|
+
problems.push({
|
|
376
|
+
level: "warning",
|
|
377
|
+
where,
|
|
378
|
+
message:
|
|
379
|
+
`${reason}. An answer written there is visible to every process this project ` +
|
|
380
|
+
`starts. If that is what you meant, set '${INTENTIONAL_EXTENSION_KEY}: true' on ` +
|
|
381
|
+
`the definition to say so.`,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return problems;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* True when the raw manifest marks the variable definition at `index` as an
|
|
392
|
+
* intentional claim on a well-known name.
|
|
393
|
+
*
|
|
394
|
+
* The flag is read from the RAW manifest rather than from the validated one
|
|
395
|
+
* because the recipe manifest schema accepts and then drops every key in the
|
|
396
|
+
* reserved `x-` extension namespace, so it never reaches the parsed object.
|
|
397
|
+
*/
|
|
398
|
+
function isIntentional(raw: unknown, index: number): boolean {
|
|
399
|
+
if (typeof raw !== "object" || raw === null) return false;
|
|
400
|
+
const variables = (raw as Record<string, unknown>).variables;
|
|
401
|
+
if (!Array.isArray(variables)) return false;
|
|
402
|
+
const entry = variables[index];
|
|
403
|
+
if (typeof entry !== "object" || entry === null) return false;
|
|
404
|
+
return (entry as Record<string, unknown>)[INTENTIONAL_EXTENSION_KEY] === true;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** True when the path exists and is a directory. */
|
|
408
|
+
function isDirectory(candidate: string): boolean {
|
|
409
|
+
try {
|
|
410
|
+
return fs.statSync(candidate).isDirectory();
|
|
411
|
+
} catch {
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Reports every symbolic link inside a recipe folder as an error.
|
|
418
|
+
*
|
|
419
|
+
* A published version's content hash is computed over the recipe folder, and a
|
|
420
|
+
* link points at bytes the repository does not own. Sous skips links when it
|
|
421
|
+
* hashes and when it copies into the store, so a linked file is simply not part
|
|
422
|
+
* of what a consumer receives; publishing one would ship a recipe that is
|
|
423
|
+
* missing a file it expects. The fix is always the same, so it is refused here
|
|
424
|
+
* rather than surfacing later as a puzzling absence.
|
|
425
|
+
*
|
|
426
|
+
* @param recipePath - The recipe folder's path relative to the repository root.
|
|
427
|
+
* @param dir - The recipe folder's absolute path.
|
|
428
|
+
*/
|
|
429
|
+
function checkSymlinks(recipePath: string, dir: string): ValidationProblem[] {
|
|
430
|
+
const problems: ValidationProblem[] = [];
|
|
431
|
+
|
|
432
|
+
const walk = (current: string, prefix: string): void => {
|
|
433
|
+
let entries;
|
|
434
|
+
try {
|
|
435
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
436
|
+
} catch {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
for (const entry of entries) {
|
|
441
|
+
if (entry.name === ".git") continue;
|
|
442
|
+
const relativePath = prefix.length > 0 ? `${prefix}/${entry.name}` : entry.name;
|
|
443
|
+
|
|
444
|
+
if (entry.isSymbolicLink()) {
|
|
445
|
+
problems.push({
|
|
446
|
+
level: "error",
|
|
447
|
+
where: `${recipePath}/${relativePath}`,
|
|
448
|
+
message:
|
|
449
|
+
"this is a symbolic link, and a recipe may not publish one. A published " +
|
|
450
|
+
"version's content hash covers the recipe folder itself, so sous neither " +
|
|
451
|
+
"hashes nor installs what a link points at; the file would simply be missing " +
|
|
452
|
+
"for everyone who installs the recipe. Replace the link with the file itself.",
|
|
453
|
+
});
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (entry.isDirectory()) walk(path.join(current, entry.name), relativePath);
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
walk(dir, "");
|
|
462
|
+
return problems;
|
|
463
|
+
}
|