@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,370 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Namespace addressability for templates: the reserved `~` include sigil.
|
|
5
|
+
*
|
|
6
|
+
* An include line of the form `@~<namespace>/<rest>` (and the equivalent
|
|
7
|
+
* `{% render "~<namespace>/<rest>" %}`) addresses a recipe namespace rather
|
|
8
|
+
* than the filesystem. `<rest>` begins with the recipe name and continues with
|
|
9
|
+
* the path inside that recipe, so
|
|
10
|
+
*
|
|
11
|
+
* @~workflow/task-files/_partials/resume.md
|
|
12
|
+
*
|
|
13
|
+
* means "the file `_partials/resume.md` inside recipe `workflow/task-files`".
|
|
14
|
+
* A namespace holds many recipes and each recipe is a directory at its pinned
|
|
15
|
+
* version, so resolution is a lookup from (namespace, recipe) to a directory.
|
|
16
|
+
*
|
|
17
|
+
* A bare `@path` (no `~`) never consults a namespace; it stays a relative path
|
|
18
|
+
* or a declared alias.
|
|
19
|
+
*
|
|
20
|
+
* This module defines only the CONTRACT plus a static, in-memory implementation
|
|
21
|
+
* used by tests. The real implementation (backed by the repository store, the
|
|
22
|
+
* lockfile and linked checkouts) is supplied by the repositories layer and is
|
|
23
|
+
* injected into the compiler, so nothing here reads configuration or disk.
|
|
24
|
+
*
|
|
25
|
+
* Scoping is the resolver's responsibility, which is why every request carries
|
|
26
|
+
* the including file:
|
|
27
|
+
* - a file that lives inside a recipe may address only that recipe's declared
|
|
28
|
+
* dependencies (`depends` plus `subscribes`) at their pinned versions;
|
|
29
|
+
* - a file in the project's own templates may address the project's
|
|
30
|
+
* subscriptions.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** A single `~namespace/rest` resolution request. */
|
|
34
|
+
export type NamespaceRequest = {
|
|
35
|
+
/** The namespace name, with the leading `~` already stripped (e.g. `workflow`). */
|
|
36
|
+
namespace: string;
|
|
37
|
+
/**
|
|
38
|
+
* Everything after the namespace segment: the recipe name, then the path
|
|
39
|
+
* inside that recipe (e.g. `task-files/_partials/resume.md`). Never has a
|
|
40
|
+
* leading separator.
|
|
41
|
+
*/
|
|
42
|
+
rest: string;
|
|
43
|
+
/**
|
|
44
|
+
* Absolute path of the file performing the include, used to decide which
|
|
45
|
+
* recipe (if any) is asking. A directory path is accepted for callers that
|
|
46
|
+
* only know the including directory, such as the `{% render %}` filesystem.
|
|
47
|
+
*/
|
|
48
|
+
fromFile: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The outcome of a namespace lookup. `candidates` is the success case; the
|
|
53
|
+
* other members carry enough detail to build a precise error message.
|
|
54
|
+
*
|
|
55
|
+
* Implementations may only return these members. Callers should treat any
|
|
56
|
+
* unrecognized `kind` as "no candidates, no specific advice" so the union can
|
|
57
|
+
* grow without breaking older callers.
|
|
58
|
+
*/
|
|
59
|
+
export type NamespaceResolution =
|
|
60
|
+
/** Ordered absolute paths to try, most preferred first. An empty list means the lookup produced nothing. */
|
|
61
|
+
| { kind: "candidates"; candidates: string[] }
|
|
62
|
+
/** No such namespace is known at all. `known` lists the namespaces that are. */
|
|
63
|
+
| { kind: "unknown-namespace"; known: string[] }
|
|
64
|
+
/**
|
|
65
|
+
* The namespace exists but holds no such recipe. `recipe` is the fully
|
|
66
|
+
* qualified ref that was asked for; `known` lists the recipe refs the
|
|
67
|
+
* namespace does hold.
|
|
68
|
+
*/
|
|
69
|
+
| { kind: "unknown-recipe"; recipe: string; known: string[] }
|
|
70
|
+
/**
|
|
71
|
+
* The recipe exists but the including file is not allowed to address it.
|
|
72
|
+
* `recipe` is the fully qualified ref that was asked for. `includingRecipe`
|
|
73
|
+
* is the ref of the recipe the including file belongs to, or `null` when the
|
|
74
|
+
* including file is one of the project's own templates (in which case the
|
|
75
|
+
* project simply does not subscribe to the recipe).
|
|
76
|
+
*/
|
|
77
|
+
| { kind: "not-a-dependency"; recipe: string; includingRecipe: string | null }
|
|
78
|
+
/**
|
|
79
|
+
* The reference tried to leave the recipe directory: it carried a `.` or `..`
|
|
80
|
+
* segment, or an absolute inner path. A `~namespace` reference addresses a
|
|
81
|
+
* recipe's own files and nothing else, so this is refused rather than
|
|
82
|
+
* resolved. `reference` is the reference as it was written.
|
|
83
|
+
*/
|
|
84
|
+
| { kind: "escapes-recipe"; recipe: string; reference: string };
|
|
85
|
+
|
|
86
|
+
/** Resolves `~namespace/rest` references to candidate absolute paths. */
|
|
87
|
+
export interface NamespaceResolver {
|
|
88
|
+
/**
|
|
89
|
+
* Resolve one `~namespace/rest` reference.
|
|
90
|
+
*
|
|
91
|
+
* @param request - The namespace, the remainder of the reference, and the including file.
|
|
92
|
+
* @returns Candidate absolute paths (most preferred first), or a reason the lookup failed.
|
|
93
|
+
*/
|
|
94
|
+
resolve(request: NamespaceRequest): NamespaceResolution;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Render a namespace lookup failure as human-readable lines for an error
|
|
99
|
+
* message. Each line is indented by two spaces so it can be appended directly
|
|
100
|
+
* to the compiler's "Include not found" block.
|
|
101
|
+
*
|
|
102
|
+
* @param opts.namespace - The namespace that was asked for.
|
|
103
|
+
* @param opts.rest - The remainder of the reference (recipe name plus inner path).
|
|
104
|
+
* @param opts.fromFile - The file (or directory) that performed the include.
|
|
105
|
+
* @param opts.resolution - What the resolver returned.
|
|
106
|
+
* @returns Indented, newline-joined explanation lines; an empty string when there is nothing to add.
|
|
107
|
+
*/
|
|
108
|
+
export function formatNamespaceProblem(opts: {
|
|
109
|
+
namespace: string;
|
|
110
|
+
rest: string;
|
|
111
|
+
fromFile: string;
|
|
112
|
+
resolution: NamespaceResolution;
|
|
113
|
+
}): string {
|
|
114
|
+
const lines: string[] = [`in file: ${opts.fromFile}`, `namespace: ${opts.namespace}`];
|
|
115
|
+
|
|
116
|
+
const resolution = opts.resolution;
|
|
117
|
+
|
|
118
|
+
if (resolution.kind === "candidates") {
|
|
119
|
+
return "";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (resolution.kind === "unknown-namespace") {
|
|
123
|
+
lines.push(`There is no recipe namespace named "${opts.namespace}" available here.`);
|
|
124
|
+
lines.push(
|
|
125
|
+
resolution.known.length > 0
|
|
126
|
+
? `Available namespaces: ${resolution.known.join(", ")}.`
|
|
127
|
+
: "This project has no recipe namespaces available yet."
|
|
128
|
+
);
|
|
129
|
+
} else if (resolution.kind === "unknown-recipe") {
|
|
130
|
+
lines.push(`recipe: ${resolution.recipe}`);
|
|
131
|
+
lines.push(`The namespace "${opts.namespace}" holds no recipe named "${resolution.recipe}".`);
|
|
132
|
+
lines.push(
|
|
133
|
+
resolution.known.length > 0
|
|
134
|
+
? `Recipes in this namespace: ${resolution.known.join(", ")}.`
|
|
135
|
+
: `The namespace "${opts.namespace}" currently holds no recipes.`
|
|
136
|
+
);
|
|
137
|
+
} else if (resolution.kind === "not-a-dependency") {
|
|
138
|
+
lines.push(`recipe: ${resolution.recipe}`);
|
|
139
|
+
if (resolution.includingRecipe) {
|
|
140
|
+
lines.push(
|
|
141
|
+
`The recipe "${resolution.includingRecipe}" does not declare "${resolution.recipe}" as a dependency.`
|
|
142
|
+
);
|
|
143
|
+
lines.push(
|
|
144
|
+
`Add "${resolution.recipe}" to the "depends" list in that recipe's manifest before addressing it as "~${opts.namespace}".`
|
|
145
|
+
);
|
|
146
|
+
} else {
|
|
147
|
+
lines.push(`This project does not subscribe to "${resolution.recipe}".`);
|
|
148
|
+
lines.push(
|
|
149
|
+
`Subscribe to it before addressing it as "~${opts.namespace}" from a project template.`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
} else if (resolution.kind === "escapes-recipe") {
|
|
153
|
+
lines.push(`recipe: ${resolution.recipe}`);
|
|
154
|
+
lines.push(
|
|
155
|
+
`The reference "~${resolution.reference}" points outside the recipe "${resolution.recipe}".`
|
|
156
|
+
);
|
|
157
|
+
lines.push(
|
|
158
|
+
`A "~namespace" reference addresses a recipe's own files, so it may not contain ` +
|
|
159
|
+
`"." or ".." segments and may not be an absolute path.`
|
|
160
|
+
);
|
|
161
|
+
lines.push(
|
|
162
|
+
`Write the path of a file inside the recipe, or include the other file by a ` +
|
|
163
|
+
`relative path or a declared alias.`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return lines.map((line) => ` ${line}`).join("\n");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Options for {@link StaticNamespaceResolver}. */
|
|
171
|
+
export type StaticNamespaceResolverOptions = {
|
|
172
|
+
/**
|
|
173
|
+
* Every known recipe, mapping the fully qualified ref `<namespace>/<recipe>`
|
|
174
|
+
* to the absolute directory holding that recipe's files at its pinned
|
|
175
|
+
* version. These directories double as the recipe roots used to decide which
|
|
176
|
+
* recipe an including file belongs to.
|
|
177
|
+
*/
|
|
178
|
+
recipes: Record<string, string>;
|
|
179
|
+
/**
|
|
180
|
+
* What each recipe declares, mapping `<namespace>/<recipe>` to the refs it
|
|
181
|
+
* may address. An entry may be a full ref (`workflow/task-files`) or a bare
|
|
182
|
+
* namespace (`workflow`, meaning every recipe in it). A recipe with no entry
|
|
183
|
+
* declares nothing and may address only itself.
|
|
184
|
+
*/
|
|
185
|
+
dependencies?: Record<string, string[]>;
|
|
186
|
+
/**
|
|
187
|
+
* What the project's own templates may address, in the same ref forms as
|
|
188
|
+
* `dependencies`. Omit it to make every known recipe addressable from
|
|
189
|
+
* project templates (the convenient default for tests).
|
|
190
|
+
*/
|
|
191
|
+
projectScope?: string[];
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* A dependency-free, in-memory {@link NamespaceResolver} built from a map of
|
|
196
|
+
* recipe refs to directories.
|
|
197
|
+
*
|
|
198
|
+
* It implements the full scoping rule (recipe files see their declared
|
|
199
|
+
* dependencies; project files see the project's subscriptions) without knowing
|
|
200
|
+
* anything about repositories, versions or the store, which makes it the
|
|
201
|
+
* resolver used by tests and a usable core for the real implementation to wrap.
|
|
202
|
+
*/
|
|
203
|
+
export class StaticNamespaceResolver implements NamespaceResolver {
|
|
204
|
+
private readonly recipes: Record<string, string>;
|
|
205
|
+
private readonly dependencies: Record<string, string[]>;
|
|
206
|
+
private readonly projectScope?: string[];
|
|
207
|
+
|
|
208
|
+
constructor(options: StaticNamespaceResolverOptions) {
|
|
209
|
+
this.recipes = {};
|
|
210
|
+
for (const [ref, dir] of Object.entries(options.recipes)) {
|
|
211
|
+
this.recipes[ref] = path.resolve(dir);
|
|
212
|
+
}
|
|
213
|
+
this.dependencies = options.dependencies ?? {};
|
|
214
|
+
this.projectScope = options.projectScope;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Every namespace this resolver knows about, sorted. */
|
|
218
|
+
private knownNamespaces(): string[] {
|
|
219
|
+
const names = new Set<string>();
|
|
220
|
+
for (const ref of Object.keys(this.recipes)) {
|
|
221
|
+
names.add(ref.split("/")[0]);
|
|
222
|
+
}
|
|
223
|
+
return [...names].sort();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Every known recipe ref inside one namespace, sorted. */
|
|
227
|
+
private recipesIn(namespace: string): string[] {
|
|
228
|
+
return Object.keys(this.recipes)
|
|
229
|
+
.filter((ref) => ref.split("/")[0] === namespace)
|
|
230
|
+
.sort();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The ref of the recipe whose directory contains `fromFile`, or null when the
|
|
235
|
+
* file lives outside every recipe (a project template). The deepest matching
|
|
236
|
+
* recipe directory wins, so nested layouts resolve to the innermost recipe.
|
|
237
|
+
*/
|
|
238
|
+
private includingRecipe(fromFile: string): string | null {
|
|
239
|
+
const target = path.resolve(fromFile);
|
|
240
|
+
let best: string | null = null;
|
|
241
|
+
let bestLength = -1;
|
|
242
|
+
|
|
243
|
+
for (const [ref, dir] of Object.entries(this.recipes)) {
|
|
244
|
+
if (!isInside(dir, target)) continue;
|
|
245
|
+
if (dir.length > bestLength) {
|
|
246
|
+
best = ref;
|
|
247
|
+
bestLength = dir.length;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return best;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
resolve(request: NamespaceRequest): NamespaceResolution {
|
|
255
|
+
const { namespace, rest, fromFile } = request;
|
|
256
|
+
|
|
257
|
+
if (!this.knownNamespaces().includes(namespace)) {
|
|
258
|
+
return { kind: "unknown-namespace", known: this.knownNamespaces() };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const segments = rest.split("/").filter((segment) => segment.length > 0);
|
|
262
|
+
const recipeName = segments[0] ?? "";
|
|
263
|
+
const ref = `${namespace}/${recipeName}`;
|
|
264
|
+
const recipeDir = this.recipes[ref];
|
|
265
|
+
|
|
266
|
+
if (!recipeDir) {
|
|
267
|
+
return { kind: "unknown-recipe", recipe: ref, known: this.recipesIn(namespace) };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const includingRecipe = this.includingRecipe(fromFile);
|
|
271
|
+
const declared = includingRecipe
|
|
272
|
+
? [includingRecipe, ...(this.dependencies[includingRecipe] ?? [])]
|
|
273
|
+
: this.projectScope;
|
|
274
|
+
|
|
275
|
+
if (declared !== undefined && !declaresRef(declared, namespace, ref)) {
|
|
276
|
+
return { kind: "not-a-dependency", recipe: ref, includingRecipe };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const inner = segments.slice(1).join("/");
|
|
280
|
+
const resolved = path.resolve(recipeDir, inner);
|
|
281
|
+
|
|
282
|
+
// A `~namespace` reference addresses a recipe's own files. Without this the
|
|
283
|
+
// reference could walk out of the recipe with `..` segments and have the
|
|
284
|
+
// compiler render anything on the machine into the project's output. The
|
|
285
|
+
// segment check catches the written form, and the relative check catches
|
|
286
|
+
// everything else, including an absolute inner path and any symlink-free
|
|
287
|
+
// route out that normalisation would otherwise hide.
|
|
288
|
+
if (escapesRecipe(recipeDir, segments.slice(1), inner, resolved)) {
|
|
289
|
+
return { kind: "escapes-recipe", recipe: ref, reference: `${namespace}/${rest}` };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return { kind: "candidates", candidates: [resolved] };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Whether an inner path would address something outside the recipe directory.
|
|
298
|
+
*
|
|
299
|
+
* @param recipeDir - The recipe's absolute directory.
|
|
300
|
+
* @param innerSegments - The inner path's segments, as they were written.
|
|
301
|
+
* @param inner - Those segments rejoined.
|
|
302
|
+
* @param resolved - What the inner path resolved to.
|
|
303
|
+
*/
|
|
304
|
+
function escapesRecipe(
|
|
305
|
+
recipeDir: string,
|
|
306
|
+
innerSegments: string[],
|
|
307
|
+
inner: string,
|
|
308
|
+
resolved: string
|
|
309
|
+
): boolean {
|
|
310
|
+
if (innerSegments.some((segment) => segment === "." || segment === "..")) return true;
|
|
311
|
+
if (inner !== "" && path.isAbsolute(inner)) return true;
|
|
312
|
+
if (resolved === recipeDir) return false;
|
|
313
|
+
|
|
314
|
+
const relative = path.relative(recipeDir, resolved);
|
|
315
|
+
return relative === "" || relative.startsWith("..") || path.isAbsolute(relative);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Strip the decorations a written reference may carry so it can be compared to
|
|
320
|
+
* a plain `<namespace>/<recipe>` ref: a repository qualifier (a subscription's
|
|
321
|
+
* `sous-public:misc/stuff`, or a manifest's
|
|
322
|
+
* `github://owner/repo/misc/stuff` locator) and a trailing version range
|
|
323
|
+
* (`misc/stuff@^1.2`).
|
|
324
|
+
*
|
|
325
|
+
* @param ref - A reference as written in a manifest or subscription entry.
|
|
326
|
+
* @returns The bare `<namespace>` or `<namespace>/<recipe>` form.
|
|
327
|
+
*/
|
|
328
|
+
export function normalizeRef(ref: string): string {
|
|
329
|
+
const trimmed = ref.trim();
|
|
330
|
+
|
|
331
|
+
// A locator URL names the repository first and the recipe last, so the two
|
|
332
|
+
// trailing segments are the ref; everything before them is where it lives.
|
|
333
|
+
const scheme = trimmed.indexOf("://");
|
|
334
|
+
const body =
|
|
335
|
+
scheme === -1
|
|
336
|
+
? trimmed.includes(":")
|
|
337
|
+
? trimmed.slice(trimmed.indexOf(":") + 1)
|
|
338
|
+
: trimmed
|
|
339
|
+
: trimmed.slice(scheme + 3).split("/").slice(-2).join("/");
|
|
340
|
+
|
|
341
|
+
const at = body.lastIndexOf("@");
|
|
342
|
+
return (at > 0 ? body.slice(0, at) : body).trim();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Whether a list of declared refs covers a recipe, either by naming the recipe
|
|
347
|
+
* itself or by naming its whole namespace.
|
|
348
|
+
*
|
|
349
|
+
* @param declared - Declared refs (dependencies, or the project's subscriptions).
|
|
350
|
+
* @param namespace - The namespace being addressed.
|
|
351
|
+
* @param ref - The fully qualified recipe ref being addressed.
|
|
352
|
+
* @returns True when the reference is in scope.
|
|
353
|
+
*/
|
|
354
|
+
function declaresRef(declared: string[], namespace: string, ref: string): boolean {
|
|
355
|
+
return declared.some((entry) => {
|
|
356
|
+
const normalized = normalizeRef(entry);
|
|
357
|
+
return normalized === ref || normalized === namespace;
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Whether `target` is the directory `dir` itself or sits underneath it.
|
|
363
|
+
*
|
|
364
|
+
* @param dir - An absolute directory path.
|
|
365
|
+
* @param target - An absolute file or directory path.
|
|
366
|
+
* @returns True when target is inside dir.
|
|
367
|
+
*/
|
|
368
|
+
function isInside(dir: string, target: string): boolean {
|
|
369
|
+
return target === dir || target.startsWith(dir + path.sep);
|
|
370
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The base class every built-in provider extends.
|
|
3
|
+
*
|
|
4
|
+
* It carries the plumbing no provider should repeat: running a subprocess
|
|
5
|
+
* through the injectable runner, capturing what one printed, finding a token in
|
|
6
|
+
* the environment or from the host's own command line tool, and pulling the
|
|
7
|
+
* address out of a tool's output.
|
|
8
|
+
*
|
|
9
|
+
* It also answers the whole write path with a refusal. A provider that does not
|
|
10
|
+
* declare the `submit` feature (the local one, for instance) inherits four
|
|
11
|
+
* methods that raise a ConfigError naming the provider and what was asked of
|
|
12
|
+
* it, so a caller that skips the feature check gets a sentence rather than a
|
|
13
|
+
* `TypeError`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { ConfigError } from "../../errors.js";
|
|
17
|
+
import {
|
|
18
|
+
spawnCommand,
|
|
19
|
+
tryCommand,
|
|
20
|
+
type CommandResult,
|
|
21
|
+
type CommandRunner,
|
|
22
|
+
} from "./git.js";
|
|
23
|
+
import type {
|
|
24
|
+
AuthStatus,
|
|
25
|
+
CanonicalRepo,
|
|
26
|
+
ChangeProposal,
|
|
27
|
+
FetchedIndex,
|
|
28
|
+
ForkedRepo,
|
|
29
|
+
ProposedChange,
|
|
30
|
+
ProviderCli,
|
|
31
|
+
ProviderFeature,
|
|
32
|
+
ProviderId,
|
|
33
|
+
ProviderOptions,
|
|
34
|
+
RepoProvider,
|
|
35
|
+
} from "./provider.js";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The first URL in a command's output, which is where a host's own tool prints
|
|
39
|
+
* the thing it just created.
|
|
40
|
+
*
|
|
41
|
+
* firstUrlIn("https://github.com/o/r/pull/7\n"); // -> "https://github.com/o/r/pull/7"
|
|
42
|
+
*
|
|
43
|
+
* @param output - Whatever the command printed.
|
|
44
|
+
*/
|
|
45
|
+
export function firstUrlIn(output: string): string | undefined {
|
|
46
|
+
const match = /https?:\/\/\S+/.exec(output);
|
|
47
|
+
return match === null ? undefined : match[0];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Everything a provider inherits rather than writes for itself. */
|
|
51
|
+
export abstract class ProviderBase implements RepoProvider {
|
|
52
|
+
abstract readonly id: ProviderId;
|
|
53
|
+
abstract readonly features: ProviderFeature[];
|
|
54
|
+
|
|
55
|
+
abstract matches(url: string): boolean;
|
|
56
|
+
abstract canonicalize(url: string): CanonicalRepo;
|
|
57
|
+
abstract fetchIndex(repo: CanonicalRepo, options?: ProviderOptions): Promise<FetchedIndex>;
|
|
58
|
+
abstract fetchRecipeTree(
|
|
59
|
+
repo: CanonicalRepo,
|
|
60
|
+
recipePath: string,
|
|
61
|
+
tag: string,
|
|
62
|
+
destDir: string,
|
|
63
|
+
options?: ProviderOptions
|
|
64
|
+
): Promise<void>;
|
|
65
|
+
|
|
66
|
+
// --- Subprocess plumbing ---------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The runner a call should use: the injected one when a caller supplied it,
|
|
70
|
+
* and a real process otherwise. Tests substitute their own, which is why no
|
|
71
|
+
* provider reaches for `spawn` directly.
|
|
72
|
+
*
|
|
73
|
+
* @param options - The call's options.
|
|
74
|
+
*/
|
|
75
|
+
protected runnerFor(options: ProviderOptions): CommandRunner {
|
|
76
|
+
return options.run ?? spawnCommand;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Runs a command and hands back everything it reported, including a non-zero
|
|
81
|
+
* exit. A command that cannot be started at all comes back as exit code 127.
|
|
82
|
+
*
|
|
83
|
+
* @param command - The executable to run.
|
|
84
|
+
* @param args - Its arguments, already split.
|
|
85
|
+
* @param options - The call's options; `cwd` and `run` are used.
|
|
86
|
+
*/
|
|
87
|
+
protected async runCommand(
|
|
88
|
+
command: string,
|
|
89
|
+
args: string[],
|
|
90
|
+
options: ProviderOptions = {}
|
|
91
|
+
): Promise<CommandResult> {
|
|
92
|
+
return this.runnerFor(options)(command, args, { cwd: options.cwd });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* True when a command ran and exited successfully, whatever it printed. Used
|
|
97
|
+
* for the checks whose answer is the exit code itself, such as `auth status`.
|
|
98
|
+
*
|
|
99
|
+
* @param command - The executable to run.
|
|
100
|
+
* @param args - Its arguments.
|
|
101
|
+
* @param options - The call's options.
|
|
102
|
+
*/
|
|
103
|
+
protected async commandSucceeds(
|
|
104
|
+
command: string,
|
|
105
|
+
args: string[],
|
|
106
|
+
options: ProviderOptions = {}
|
|
107
|
+
): Promise<boolean> {
|
|
108
|
+
try {
|
|
109
|
+
const result = await this.runCommand(command, args, options);
|
|
110
|
+
return result.code === 0;
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A command's trimmed standard output, or undefined when it did not succeed,
|
|
118
|
+
* is not installed, or printed nothing at all.
|
|
119
|
+
*
|
|
120
|
+
* @param command - The executable to run.
|
|
121
|
+
* @param args - Its arguments.
|
|
122
|
+
* @param options - The call's options.
|
|
123
|
+
*/
|
|
124
|
+
protected async capturedOutput(
|
|
125
|
+
command: string,
|
|
126
|
+
args: string[],
|
|
127
|
+
options: ProviderOptions = {}
|
|
128
|
+
): Promise<string | undefined> {
|
|
129
|
+
return tryCommand(command, args, {
|
|
130
|
+
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
131
|
+
...(options.run === undefined ? {} : { run: options.run }),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Finds a host token: the environment first, then the host's own command line
|
|
137
|
+
* tool when it is installed and signed in. Undefined is a normal answer,
|
|
138
|
+
* because a public repository needs no token at all.
|
|
139
|
+
*
|
|
140
|
+
* @param envName - The environment variable to read, such as `GITHUB_TOKEN`.
|
|
141
|
+
* @param args - The arguments that make the tool print a token.
|
|
142
|
+
* @param options - Environment and subprocess runner overrides.
|
|
143
|
+
*/
|
|
144
|
+
protected async findToken(
|
|
145
|
+
envName: string,
|
|
146
|
+
args: string[],
|
|
147
|
+
options: ProviderOptions = {}
|
|
148
|
+
): Promise<string | undefined> {
|
|
149
|
+
const env = options.env ?? process.env;
|
|
150
|
+
const fromEnv = env[envName];
|
|
151
|
+
if (fromEnv !== undefined && fromEnv.trim().length > 0) return fromEnv.trim();
|
|
152
|
+
if (this.cli === undefined) return undefined;
|
|
153
|
+
return this.capturedOutput(this.cli.command, args, options);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// --- The write path, refused unless a provider overrides it ----------------
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The command line tool this provider drives, and what its proposals are
|
|
160
|
+
* called. Both are declared by the provider that has them; `declare` here
|
|
161
|
+
* only tells the type system they may exist, so a subclass's own field is the
|
|
162
|
+
* one that ends up on the instance.
|
|
163
|
+
*/
|
|
164
|
+
declare readonly cli?: ProviderCli;
|
|
165
|
+
declare readonly proposalNoun?: string;
|
|
166
|
+
|
|
167
|
+
async authStatus(_options: ProviderOptions = {}): Promise<AuthStatus> {
|
|
168
|
+
throw this.unsupported("submit", "check whether you are signed in to it");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async canPush(
|
|
172
|
+
_repo: CanonicalRepo,
|
|
173
|
+
_options: ProviderOptions = {}
|
|
174
|
+
): Promise<boolean | undefined> {
|
|
175
|
+
throw this.unsupported("submit", "check whether you can push to it");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async fork(_repo: CanonicalRepo, _options: ProviderOptions = {}): Promise<ForkedRepo> {
|
|
179
|
+
throw this.unsupported("submit", "fork it on your behalf");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async proposeChange(
|
|
183
|
+
_repo: CanonicalRepo,
|
|
184
|
+
_proposal: ChangeProposal,
|
|
185
|
+
_options: ProviderOptions = {}
|
|
186
|
+
): Promise<ProposedChange> {
|
|
187
|
+
throw this.unsupported("submit", "propose a change to it");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The refusal a provider gives when it is asked for something it never
|
|
192
|
+
* claimed. It names the provider and the feature, so the caller learns why
|
|
193
|
+
* rather than only that.
|
|
194
|
+
*
|
|
195
|
+
* @param feature - The feature the call belongs to.
|
|
196
|
+
* @param what - What was being attempted, in plain language.
|
|
197
|
+
*/
|
|
198
|
+
protected unsupported(feature: ProviderFeature, what: string): ConfigError {
|
|
199
|
+
return new ConfigError(
|
|
200
|
+
`The '${this.id}' provider does not support the '${feature}' feature, so sous cannot ` +
|
|
201
|
+
`${what}.\n` +
|
|
202
|
+
` A provider answers only what its features promise; this one promises ` +
|
|
203
|
+
`${this.features.map((entry) => `'${entry}'`).join(", ")}.`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|