@sous-io/sous 0.2.28 → 0.2.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/markdown/_sidebar.md +2 -0
- package/docs/markdown/commands.md +10 -6
- package/docs/markdown/repositories-authoring.md +19 -11
- package/docs/markdown/repositories-consuming.md +34 -13
- package/docs/markdown/repositories-file-formats.md +88 -13
- package/docs/markdown/repositories-providers.md +8 -6
- package/docs/markdown/repositories-troubleshooting.md +6 -0
- package/docs/markdown/repositories-variables.md +13 -3
- package/docs/markdown/repositories.md +3 -3
- package/package.json +1 -1
- package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +11 -7
- package/recipes/core/sous-skills/skills/about-sous-repos/SKILL.tpl.md +7 -4
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/src/commands/lock/rebuild.ts +7 -6
- package/src/commands/recipe/show.ts +163 -64
- package/src/commands/repo/contribute.ts +1 -10
- package/src/commands/repo/gc.ts +3 -2
- package/src/commands/repo/release.ts +20 -3
- package/src/commands/repo/search.ts +2 -1
- package/src/commands/vars/ask.ts +32 -10
- package/src/lib/refs/find.ts +202 -21
- package/src/lib/refs/index.ts +2 -0
- package/src/lib/refs/parse.ts +844 -0
- package/src/lib/refs/scopes.ts +34 -0
- package/src/lib/refs/settle.ts +219 -0
- package/src/lib/repos/catalog.ts +87 -80
- package/src/lib/repos/declarations.ts +177 -0
- package/src/lib/repos/formats/common.ts +14 -0
- package/src/lib/repos/formats/index-file.ts +42 -2
- package/src/lib/repos/formats/lockfile.ts +5 -3
- package/src/lib/repos/formats/recipe-manifest.ts +16 -212
- package/src/lib/repos/formats/variable-definition.ts +268 -0
- package/src/lib/repos/index.ts +0 -1
- package/src/lib/repos/lock-service.ts +3 -2
- package/src/lib/repos/locked-namespace-resolver.ts +21 -15
- package/src/lib/repos/locked-recipes.ts +2 -2
- package/src/lib/repos/namespace-resolver.ts +40 -47
- package/src/lib/repos/providers/base.ts +30 -0
- package/src/lib/repos/providers/github.ts +22 -0
- package/src/lib/repos/providers/gitlab.ts +68 -0
- package/src/lib/repos/providers/provider.ts +96 -0
- package/src/lib/repos/release/index-builder.ts +221 -47
- package/src/lib/repos/release/index.ts +1 -0
- package/src/lib/repos/release/plan.ts +8 -6
- package/src/lib/repos/release/settle.ts +237 -0
- package/src/lib/repos/release/tags.ts +3 -7
- package/src/lib/repos/resolver.ts +251 -53
- package/src/lib/repos/subscription-service.ts +355 -49
- package/src/lib/repos/update-plan.ts +3 -2
- package/src/lib/vars/mappings.ts +17 -30
- package/src/lib/vars/question-plan.ts +44 -21
- package/src/lib/repos/ref.ts +0 -513
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A recipe's variable definitions: what each variable is called, what shape an
|
|
3
|
+
* answer takes, and what question to ask. A recipe manifest declares them
|
|
4
|
+
* (`recipe-manifest.ts` re-exports everything here), and `sous repo release`
|
|
5
|
+
* copies each version's definitions into the index, so the same fields and the
|
|
6
|
+
* same rules read both copies.
|
|
7
|
+
*
|
|
8
|
+
* This module imports nothing but the shared primitives, because the index
|
|
9
|
+
* reads it, and the index sits below the ref parser the manifest needs.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import {
|
|
14
|
+
envVarNameSchema,
|
|
15
|
+
extensibleObject,
|
|
16
|
+
forwardCompatibleObject,
|
|
17
|
+
variableNameSchema,
|
|
18
|
+
} from "./common.js";
|
|
19
|
+
|
|
20
|
+
// --- Variable definitions -----------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/** The value types a recipe may declare for a variable. */
|
|
23
|
+
export const VARIABLE_TYPES = [
|
|
24
|
+
"string",
|
|
25
|
+
"number",
|
|
26
|
+
"boolean",
|
|
27
|
+
"enum",
|
|
28
|
+
"path",
|
|
29
|
+
"url",
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
/** Which env file an answer is written to. */
|
|
33
|
+
export const VARIABLE_SCOPES = ["shared", "local"] as const;
|
|
34
|
+
|
|
35
|
+
/** Constraints checked against an answer before it is accepted or stored. */
|
|
36
|
+
const variableValidationShape = {
|
|
37
|
+
/** A regular expression the answer must match, as a string. */
|
|
38
|
+
pattern: z
|
|
39
|
+
.string()
|
|
40
|
+
.refine(
|
|
41
|
+
(value) => {
|
|
42
|
+
try {
|
|
43
|
+
new RegExp(value);
|
|
44
|
+
return true;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
{ message: "must be a valid regular expression" }
|
|
50
|
+
)
|
|
51
|
+
.optional(),
|
|
52
|
+
/** Minimum length of a string answer. */
|
|
53
|
+
minLength: z.number().int().min(0).optional(),
|
|
54
|
+
/** Maximum length of a string answer. */
|
|
55
|
+
maxLength: z.number().int().min(0).optional(),
|
|
56
|
+
/** Minimum value of a numeric answer. */
|
|
57
|
+
min: z.number().optional(),
|
|
58
|
+
/** Maximum value of a numeric answer. */
|
|
59
|
+
max: z.number().optional(),
|
|
60
|
+
/** The allowed answers. Required when the variable's type is 'enum'. */
|
|
61
|
+
enum: z.array(z.string()).min(1, "must list at least one option").optional(),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Constraints checked against an answer before it is accepted or stored. */
|
|
65
|
+
export const variableValidationSchema = extensibleObject(variableValidationShape);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The fields of a variable definition, shared by the two schemas that read
|
|
69
|
+
* one: the hand-written manifest, and the copy a release records in the index.
|
|
70
|
+
*
|
|
71
|
+
* @param validate - The schema for the `validate` block, strict or forward compatible.
|
|
72
|
+
*/
|
|
73
|
+
function variableDefinitionShape<Validate extends z.ZodType>(validate: Validate) {
|
|
74
|
+
return {
|
|
75
|
+
/** The variable's camelCase name, as templates refer to it. */
|
|
76
|
+
name: variableNameSchema,
|
|
77
|
+
/**
|
|
78
|
+
* The environment variable an answer binds to. Authors may name an existing
|
|
79
|
+
* variable such as GITHUB_TOKEN to reuse a value already in the environment.
|
|
80
|
+
* When omitted, `sous repo release` derives an upper snake case default from
|
|
81
|
+
* the name; the runtime never derives one.
|
|
82
|
+
*/
|
|
83
|
+
env: envVarNameSchema.optional(),
|
|
84
|
+
/** The answer's type, which decides how it is validated and prompted for. */
|
|
85
|
+
type: z.enum(VARIABLE_TYPES),
|
|
86
|
+
/** The one-line question shown when the variable is asked. */
|
|
87
|
+
prompt: z.string().min(1, "must not be empty"),
|
|
88
|
+
/**
|
|
89
|
+
* The paragraph that explains the variable: what it is for, what a good
|
|
90
|
+
* answer looks like, and what changes when it is set. Shown above the
|
|
91
|
+
* question when sous asks, and by `sous vars <name>`. Required, because a
|
|
92
|
+
* consumer reading the question has no other way to learn what a publisher
|
|
93
|
+
* meant by it.
|
|
94
|
+
*/
|
|
95
|
+
description: z
|
|
96
|
+
.string({
|
|
97
|
+
error:
|
|
98
|
+
"is required: every published variable must explain itself in a sentence or " +
|
|
99
|
+
"two. The description is shown above the question when sous asks, and by " +
|
|
100
|
+
"'sous vars <name>'",
|
|
101
|
+
})
|
|
102
|
+
.min(1, "must not be empty"),
|
|
103
|
+
/**
|
|
104
|
+
* A realistic sample answer. It is documentation and prompt copy only; sous
|
|
105
|
+
* never stores it, never offers it as the answer, and never falls back to it.
|
|
106
|
+
* Use `default` for a value a project should actually start with.
|
|
107
|
+
*/
|
|
108
|
+
example: z.union([z.string(), z.number(), z.boolean()], {
|
|
109
|
+
error:
|
|
110
|
+
"is required: every published variable must show what a real answer looks like. " +
|
|
111
|
+
"The example is shown with the question and by 'sous vars <name>'; it is " +
|
|
112
|
+
"documentation only and is never stored as the answer (use 'default' for that)",
|
|
113
|
+
}),
|
|
114
|
+
/** The value offered when the question is asked with nothing else in scope. */
|
|
115
|
+
default: z.union([z.string(), z.number(), z.boolean()]).optional(),
|
|
116
|
+
/** Whether an answer is needed for a build to run. Defaults to true. */
|
|
117
|
+
required: z.boolean().default(true),
|
|
118
|
+
/**
|
|
119
|
+
* Whether the answer is a secret. A secret is always written to the
|
|
120
|
+
* gitignored `.sous/.env.local`, never to the committed `.sous/.env`.
|
|
121
|
+
*/
|
|
122
|
+
secret: z.boolean().default(false),
|
|
123
|
+
/**
|
|
124
|
+
* Which env file the answer is written to: 'shared' is `.sous/.env`, which is
|
|
125
|
+
* committed and shared with the team; 'local' is `.sous/.env.local`, which is
|
|
126
|
+
* gitignored and machine-specific. Defaults to 'shared'.
|
|
127
|
+
*/
|
|
128
|
+
scope: z.enum(VARIABLE_SCOPES).default("shared"),
|
|
129
|
+
/** Constraints checked against an answer. */
|
|
130
|
+
validate: validate.optional(),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** What `checkVariableDefinition` reads from a definition. */
|
|
135
|
+
type CheckedDefinition = {
|
|
136
|
+
type: VariableType;
|
|
137
|
+
secret: boolean;
|
|
138
|
+
scope: VariableScope;
|
|
139
|
+
default?: string | number | boolean | undefined;
|
|
140
|
+
example: string | number | boolean;
|
|
141
|
+
validate?:
|
|
142
|
+
| { minLength?: number; maxLength?: number; min?: number; max?: number; enum?: string[] }
|
|
143
|
+
| undefined;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The rules a variable definition obeys beyond the shape of each field, shared
|
|
148
|
+
* by both schemas so a definition the index records is held to exactly the rules
|
|
149
|
+
* its manifest was.
|
|
150
|
+
*
|
|
151
|
+
* @param definition - The definition, already parsed field by field.
|
|
152
|
+
* @param ctx - The refinement context issues are added to.
|
|
153
|
+
*/
|
|
154
|
+
function checkVariableDefinition(definition: CheckedDefinition, ctx: z.RefinementCtx): void {
|
|
155
|
+
const { type, validate } = definition;
|
|
156
|
+
|
|
157
|
+
if (type === "enum" && (validate?.enum === undefined || validate.enum.length === 0)) {
|
|
158
|
+
ctx.addIssue({
|
|
159
|
+
code: "custom",
|
|
160
|
+
path: ["validate", "enum"],
|
|
161
|
+
message:
|
|
162
|
+
"a variable of type 'enum' must list its options under 'validate.enum'",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (validate !== undefined) {
|
|
167
|
+
const { minLength, maxLength, min, max } = validate;
|
|
168
|
+
if (minLength !== undefined && maxLength !== undefined && minLength > maxLength) {
|
|
169
|
+
ctx.addIssue({
|
|
170
|
+
code: "custom",
|
|
171
|
+
path: ["validate", "maxLength"],
|
|
172
|
+
message: "must not be smaller than 'validate.minLength'",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
if (min !== undefined && max !== undefined && min > max) {
|
|
176
|
+
ctx.addIssue({
|
|
177
|
+
code: "custom",
|
|
178
|
+
path: ["validate", "max"],
|
|
179
|
+
message: "must not be smaller than 'validate.min'",
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// A secret written to the committed env file would leak, so the two fields
|
|
185
|
+
// are not allowed to contradict each other.
|
|
186
|
+
if (definition.secret && definition.scope === "shared") {
|
|
187
|
+
ctx.addIssue({
|
|
188
|
+
code: "custom",
|
|
189
|
+
path: ["scope"],
|
|
190
|
+
message:
|
|
191
|
+
"a secret variable is stored in the gitignored '.sous/.env.local', so its " +
|
|
192
|
+
"scope must be 'local' (or left unset)",
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// `default` and `example` are both literal values written by the publisher,
|
|
197
|
+
// so both have to fit the variable they describe; an example that could
|
|
198
|
+
// never be a valid answer is worse than no example at all.
|
|
199
|
+
checkDeclaredValue(definition.default, "default");
|
|
200
|
+
checkDeclaredValue(definition.example, "example");
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Checks one publisher-written literal against the declared type and the
|
|
204
|
+
* 'validate.enum' options.
|
|
205
|
+
*
|
|
206
|
+
* @param value - The literal to check, or undefined when it was omitted.
|
|
207
|
+
* @param field - The field name, used as the issue path.
|
|
208
|
+
*/
|
|
209
|
+
function checkDeclaredValue(
|
|
210
|
+
value: string | number | boolean | undefined,
|
|
211
|
+
field: "default" | "example"
|
|
212
|
+
): void {
|
|
213
|
+
if (value === undefined) return;
|
|
214
|
+
|
|
215
|
+
const expected =
|
|
216
|
+
type === "boolean" ? "boolean" : type === "number" ? "number" : "string";
|
|
217
|
+
if (typeof value !== expected) {
|
|
218
|
+
ctx.addIssue({
|
|
219
|
+
code: "custom",
|
|
220
|
+
path: [field],
|
|
221
|
+
message: `must be a ${expected}, to match the declared type '${type}'`,
|
|
222
|
+
});
|
|
223
|
+
} else if (
|
|
224
|
+
type === "enum" &&
|
|
225
|
+
validate?.enum !== undefined &&
|
|
226
|
+
!validate.enum.includes(String(value))
|
|
227
|
+
) {
|
|
228
|
+
ctx.addIssue({
|
|
229
|
+
code: "custom",
|
|
230
|
+
path: [field],
|
|
231
|
+
message: "must be one of the options listed under 'validate.enum'",
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* A published variable definition: a specification, not a value. Definitions
|
|
239
|
+
* are inert; a question is asked only when a subscribed recipe needs the
|
|
240
|
+
* variable and no valid answer is already in scope.
|
|
241
|
+
*/
|
|
242
|
+
export const variableDefinitionSchema = extensibleObject(
|
|
243
|
+
variableDefinitionShape(variableValidationSchema)
|
|
244
|
+
).superRefine(checkVariableDefinition);
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* A variable definition as a release records it in the index, so a consumer
|
|
248
|
+
* can list a recipe's questions before its files are fetched. It is the
|
|
249
|
+
* manifest's definition, field for field and rule for rule; the one difference
|
|
250
|
+
* is that a field this sous does not define is kept rather than refused,
|
|
251
|
+
* because the index is read by every sous a project happens to pin, and a
|
|
252
|
+
* later one may add a field.
|
|
253
|
+
*/
|
|
254
|
+
export const publishedVariableDefinitionSchema = forwardCompatibleObject(
|
|
255
|
+
variableDefinitionShape(forwardCompatibleObject(variableValidationShape))
|
|
256
|
+
).superRefine(checkVariableDefinition);
|
|
257
|
+
|
|
258
|
+
/** One validated variable definition from a recipe manifest. */
|
|
259
|
+
export type VariableDefinition = z.infer<typeof variableDefinitionSchema>;
|
|
260
|
+
|
|
261
|
+
/** The constraints attached to a variable definition. */
|
|
262
|
+
export type VariableValidation = z.infer<typeof variableValidationSchema>;
|
|
263
|
+
|
|
264
|
+
/** The value type a variable definition declares. */
|
|
265
|
+
export type VariableType = (typeof VARIABLE_TYPES)[number];
|
|
266
|
+
|
|
267
|
+
/** Which env file an answer is written to. */
|
|
268
|
+
export type VariableScope = (typeof VARIABLE_SCOPES)[number];
|
package/src/lib/repos/index.ts
CHANGED
|
@@ -18,7 +18,6 @@ export * from "./formats/lockfile.js";
|
|
|
18
18
|
export * from "./formats/store-entry.js";
|
|
19
19
|
export * from "./formats/links-map.js";
|
|
20
20
|
export * from "./load-manifest.js";
|
|
21
|
-
export * from "./ref.js";
|
|
22
21
|
export * from "./identity.js";
|
|
23
22
|
|
|
24
23
|
// Phase 2a: the machine-wide recipe store, under the user-level sous directory.
|
|
@@ -34,6 +34,7 @@ import type { ResolvedRecipe } from "./resolver.js";
|
|
|
34
34
|
import type { RecipeStoreLike, StoreKey } from "./store/contract.js";
|
|
35
35
|
import { builtInProviders, requireProvider } from "./providers/index.js";
|
|
36
36
|
import { repoIdentity } from "./identity.js";
|
|
37
|
+
import { splitRecipeKey } from "../refs/parse.js";
|
|
37
38
|
import type { ProviderOptions, RepoProvider } from "./providers/provider.js";
|
|
38
39
|
|
|
39
40
|
/** What the lockfile needs to know about a repository. */
|
|
@@ -382,11 +383,11 @@ export class LockService {
|
|
|
382
383
|
const report: RestoreReport = { restored: [], alreadyPresent: [] };
|
|
383
384
|
|
|
384
385
|
for (const [key, entry] of Object.entries(lock.recipes)) {
|
|
385
|
-
const namespace =
|
|
386
|
+
const { namespace, name } = splitRecipeKey(key);
|
|
386
387
|
const storeKey: StoreKey = {
|
|
387
388
|
identity: lock.repos[entry.repo]!.identity,
|
|
388
389
|
namespace,
|
|
389
|
-
name
|
|
390
|
+
name,
|
|
390
391
|
version: entry.version,
|
|
391
392
|
};
|
|
392
393
|
|
|
@@ -4,14 +4,17 @@
|
|
|
4
4
|
* Phase 4 defined the `NamespaceResolver` contract and shipped an in-memory
|
|
5
5
|
* implementation for tests. This is the implementation backed by the project's
|
|
6
6
|
* actual state: the lockfile says which recipes are in play and at what version,
|
|
7
|
-
* the links map and the store say where each one's files are, and
|
|
8
|
-
*
|
|
7
|
+
* the links map and the store say where each one's files are, and the
|
|
8
|
+
* lockfile's holder lists say what each recipe is allowed to address.
|
|
9
9
|
*
|
|
10
10
|
* The scoping rule is the whole point, and it is enforced by handing
|
|
11
11
|
* `StaticNamespaceResolver` the right two maps:
|
|
12
12
|
*
|
|
13
13
|
* - a file inside a recipe may address only that recipe's own declared
|
|
14
|
-
* dependencies (`depends` plus `subscribes`), at their pinned versions
|
|
14
|
+
* dependencies (`depends` plus `subscribes`), at their pinned versions. The
|
|
15
|
+
* lockfile records which keys each recipe holds, however its manifest wrote
|
|
16
|
+
* them (a sibling ref, a locator, a URL copied from the browser), so that
|
|
17
|
+
* record is what decides it;
|
|
15
18
|
* - a file in the project's own templates may address every recipe the
|
|
16
19
|
* lockfile pins, whatever holds it: the project, a set it subscribes to,
|
|
17
20
|
* or another recipe's `depends`.
|
|
@@ -28,16 +31,16 @@
|
|
|
28
31
|
import semver from "semver";
|
|
29
32
|
import type { Settings } from "../settings.js";
|
|
30
33
|
import { resolveStoreRoot } from "../sous-home.js";
|
|
34
|
+
import { RefSource } from "../refs/scopes.js";
|
|
35
|
+
import { namespaceOfKey, parseShortRef, refKey } from "../refs/parse.js";
|
|
31
36
|
import {
|
|
32
37
|
StaticNamespaceResolver,
|
|
33
|
-
normalizeRef,
|
|
34
38
|
type DroppedRecipe,
|
|
35
39
|
type NamespaceResolver,
|
|
36
40
|
} from "./namespace-resolver.js";
|
|
37
41
|
import {
|
|
38
42
|
listLockedRecipes,
|
|
39
43
|
readProjectLockfile,
|
|
40
|
-
readRecipeManifestIn,
|
|
41
44
|
type LockedRecipeLocation,
|
|
42
45
|
} from "./locked-recipes.js";
|
|
43
46
|
import { createIndexCache } from "./providers/index.js";
|
|
@@ -84,14 +87,11 @@ export function createProjectNamespaceResolver(
|
|
|
84
87
|
for (const recipe of locked) {
|
|
85
88
|
recipes[recipe.key] = recipe.dir;
|
|
86
89
|
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const declared = [...(manifest.depends ?? []), ...(manifest.subscribes ?? [])];
|
|
94
|
-
if (declared.length > 0) dependencies[recipe.key] = declared;
|
|
90
|
+
// Every recipe holding this one declared it, in `depends` or `subscribes`,
|
|
91
|
+
// and the lockfile records the key that declaration resolved to.
|
|
92
|
+
for (const holder of recipe.requestedBy) {
|
|
93
|
+
(dependencies[holder] ??= []).push(recipe.key);
|
|
94
|
+
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
// No `projectScope`: a project template may address every recipe the
|
|
@@ -126,10 +126,16 @@ export function explainUnpinnedRecipe(
|
|
|
126
126
|
recipe: string,
|
|
127
127
|
options: Pick<ProjectNamespaceResolverOptions, "sousDir" | "settings" | "env">
|
|
128
128
|
): DroppedRecipe | undefined {
|
|
129
|
-
const namespace =
|
|
129
|
+
const namespace = namespaceOfKey(recipe);
|
|
130
130
|
for (const [key, entry] of Object.entries(options.settings.subscriptions ?? {})) {
|
|
131
131
|
if (entry?.enabled !== false) continue;
|
|
132
|
-
|
|
132
|
+
let ref: string;
|
|
133
|
+
try {
|
|
134
|
+
ref = refKey(parseShortRef(key, RefSource.Config));
|
|
135
|
+
} catch {
|
|
136
|
+
// The config schema already refuses a key that is not a ref.
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
133
139
|
if (ref === recipe || ref === namespace) {
|
|
134
140
|
return { by: "disabled-subscription", subscription: key };
|
|
135
141
|
}
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from "./load-manifest.js";
|
|
34
34
|
import { readEffectiveLinks } from "./links.js";
|
|
35
35
|
import { identitySegments } from "./identity.js";
|
|
36
|
+
import { splitRecipeKey } from "../refs/parse.js";
|
|
36
37
|
import { PROJECT_HOLDER } from "./formats/lockfile.js";
|
|
37
38
|
|
|
38
39
|
/** One locked recipe, together with the directory its files are read from. */
|
|
@@ -164,8 +165,7 @@ export function listLockedRecipes(
|
|
|
164
165
|
|
|
165
166
|
for (const key of keys.sort()) {
|
|
166
167
|
const entry = lock.recipes[key]!;
|
|
167
|
-
const namespace =
|
|
168
|
-
const name = key.slice(namespace.length + 1);
|
|
168
|
+
const { namespace, name } = splitRecipeKey(key);
|
|
169
169
|
|
|
170
170
|
let dir: string | undefined;
|
|
171
171
|
let linked = false;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { namespaceOfKey } from "../refs/parse.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Namespace addressability for templates: the reserved `~` include sigil.
|
|
@@ -202,7 +203,7 @@ function describeDropped(
|
|
|
202
203
|
dropped: DroppedRecipe,
|
|
203
204
|
opts: { namespace: string; rest: string }
|
|
204
205
|
): string[] {
|
|
205
|
-
const recipe =
|
|
206
|
+
const { recipe } = splitIncludePath(opts.namespace, opts.rest);
|
|
206
207
|
if (dropped.by === "disabled-subscription") {
|
|
207
208
|
return [
|
|
208
209
|
`This project subscribes to "${dropped.subscription}", but that subscription is ` +
|
|
@@ -232,14 +233,15 @@ export type StaticNamespaceResolverOptions = {
|
|
|
232
233
|
*/
|
|
233
234
|
recipes: Record<string, string>;
|
|
234
235
|
/**
|
|
235
|
-
* What each recipe declares, mapping `<namespace>/<recipe>` to the
|
|
236
|
-
* may address. An entry
|
|
237
|
-
* namespace (`workflow`, meaning every recipe in it)
|
|
238
|
-
*
|
|
236
|
+
* What each recipe declares, mapping `<namespace>/<recipe>` to the keys it
|
|
237
|
+
* may address. An entry is a recipe key (`workflow/task-files`) or a bare
|
|
238
|
+
* namespace (`workflow`, meaning every recipe in it), already settled from
|
|
239
|
+
* however the manifest wrote it. A recipe with no entry declares nothing and
|
|
240
|
+
* may address only itself.
|
|
239
241
|
*/
|
|
240
242
|
dependencies?: Record<string, string[]>;
|
|
241
243
|
/**
|
|
242
|
-
* What the project's own templates may address,
|
|
244
|
+
* What the project's own templates may address, as the same kind of keys as
|
|
243
245
|
* `dependencies`. Omit it to make every known recipe addressable from
|
|
244
246
|
* project templates, which is the rule for a real project: its templates may
|
|
245
247
|
* address everything its lockfile pins.
|
|
@@ -293,7 +295,7 @@ export class StaticNamespaceResolver implements NamespaceResolver {
|
|
|
293
295
|
private knownNamespaces(): string[] {
|
|
294
296
|
const names = new Set<string>();
|
|
295
297
|
for (const ref of Object.keys(this.recipes)) {
|
|
296
|
-
names.add(ref
|
|
298
|
+
names.add(namespaceOfKey(ref));
|
|
297
299
|
}
|
|
298
300
|
return [...names].sort();
|
|
299
301
|
}
|
|
@@ -301,7 +303,7 @@ export class StaticNamespaceResolver implements NamespaceResolver {
|
|
|
301
303
|
/** Every known recipe ref inside one namespace, sorted. */
|
|
302
304
|
private recipesIn(namespace: string): string[] {
|
|
303
305
|
return Object.keys(this.recipes)
|
|
304
|
-
.filter((ref) => ref
|
|
306
|
+
.filter((ref) => namespaceOfKey(ref) === namespace)
|
|
305
307
|
.sort();
|
|
306
308
|
}
|
|
307
309
|
|
|
@@ -329,9 +331,7 @@ export class StaticNamespaceResolver implements NamespaceResolver {
|
|
|
329
331
|
resolve(request: NamespaceRequest): NamespaceResolution {
|
|
330
332
|
const { namespace, rest, fromFile } = request;
|
|
331
333
|
|
|
332
|
-
const segments =
|
|
333
|
-
const recipeName = segments[0] ?? "";
|
|
334
|
-
const ref = `${namespace}/${recipeName}`;
|
|
334
|
+
const { recipe: ref, segments } = splitIncludePath(namespace, rest);
|
|
335
335
|
|
|
336
336
|
if (!this.knownNamespaces().includes(namespace)) {
|
|
337
337
|
return {
|
|
@@ -361,7 +361,7 @@ export class StaticNamespaceResolver implements NamespaceResolver {
|
|
|
361
361
|
return { kind: "not-a-dependency", recipe: ref, includingRecipe };
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
-
const inner = segments.
|
|
364
|
+
const inner = segments.join("/");
|
|
365
365
|
const resolved = path.resolve(recipeDir, inner);
|
|
366
366
|
|
|
367
367
|
// A `~namespace` reference addresses a recipe's own files. Without this the
|
|
@@ -370,7 +370,7 @@ export class StaticNamespaceResolver implements NamespaceResolver {
|
|
|
370
370
|
// segment check catches the written form, and the relative check catches
|
|
371
371
|
// everything else, including an absolute inner path and any symlink-free
|
|
372
372
|
// route out that normalisation would otherwise hide.
|
|
373
|
-
if (escapesRecipe(recipeDir, segments
|
|
373
|
+
if (escapesRecipe(recipeDir, segments, inner, resolved)) {
|
|
374
374
|
return { kind: "escapes-recipe", recipe: ref, reference: `${namespace}/${rest}` };
|
|
375
375
|
}
|
|
376
376
|
|
|
@@ -378,6 +378,27 @@ export class StaticNamespaceResolver implements NamespaceResolver {
|
|
|
378
378
|
}
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Takes the part of a `@~namespace/...` include line after the namespace apart:
|
|
383
|
+
* its first segment names the recipe, and the rest is a file path inside it.
|
|
384
|
+
* This is an include path, not a ref; it is the one place such a path is split.
|
|
385
|
+
*
|
|
386
|
+
* splitIncludePath("workflow", "task-files/_partials/resume.md")
|
|
387
|
+
* // -> { recipe: "workflow/task-files", segments: ["_partials", "resume.md"] }
|
|
388
|
+
*
|
|
389
|
+
* @param namespace - The namespace the include line named.
|
|
390
|
+
* @param rest - Everything after `~<namespace>/`.
|
|
391
|
+
*/
|
|
392
|
+
function splitIncludePath(
|
|
393
|
+
namespace: string,
|
|
394
|
+
rest: string
|
|
395
|
+
): { recipe: string; segments: string[] } {
|
|
396
|
+
const [recipeName = "", ...segments] = rest
|
|
397
|
+
.split("/")
|
|
398
|
+
.filter((segment) => segment.length > 0);
|
|
399
|
+
return { recipe: `${namespace}/${recipeName}`, segments };
|
|
400
|
+
}
|
|
401
|
+
|
|
381
402
|
/**
|
|
382
403
|
* Whether an inner path would address something outside the recipe directory.
|
|
383
404
|
*
|
|
@@ -401,46 +422,18 @@ function escapesRecipe(
|
|
|
401
422
|
}
|
|
402
423
|
|
|
403
424
|
/**
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
* `
|
|
407
|
-
*
|
|
408
|
-
* (`misc/stuff@^1.2`).
|
|
409
|
-
*
|
|
410
|
-
* @param ref - A reference as written in a manifest or subscription entry.
|
|
411
|
-
* @returns The bare `<namespace>` or `<namespace>/<recipe>` form.
|
|
412
|
-
*/
|
|
413
|
-
export function normalizeRef(ref: string): string {
|
|
414
|
-
const trimmed = ref.trim();
|
|
415
|
-
|
|
416
|
-
// A locator URL names the repository first and the recipe last, so the two
|
|
417
|
-
// trailing segments are the ref; everything before them is where it lives.
|
|
418
|
-
const scheme = trimmed.indexOf("://");
|
|
419
|
-
const body =
|
|
420
|
-
scheme === -1
|
|
421
|
-
? trimmed.includes(":")
|
|
422
|
-
? trimmed.slice(trimmed.indexOf(":") + 1)
|
|
423
|
-
: trimmed
|
|
424
|
-
: trimmed.slice(scheme + 3).split("/").slice(-2).join("/");
|
|
425
|
-
|
|
426
|
-
const at = body.lastIndexOf("@");
|
|
427
|
-
return (at > 0 ? body.slice(0, at) : body).trim();
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
/**
|
|
431
|
-
* Whether a list of declared refs covers a recipe, either by naming the recipe
|
|
432
|
-
* itself or by naming its whole namespace.
|
|
425
|
+
* Whether a list of declared keys covers a recipe, either by naming the recipe
|
|
426
|
+
* itself or by naming its whole namespace. The keys are already settled
|
|
427
|
+
* (`namespace` or `namespace/recipe`): whatever form a manifest wrote a
|
|
428
|
+
* dependency in, the lockfile records the key it resolved to.
|
|
433
429
|
*
|
|
434
|
-
* @param declared - Declared
|
|
430
|
+
* @param declared - Declared keys (dependencies, or the project's subscriptions).
|
|
435
431
|
* @param namespace - The namespace being addressed.
|
|
436
432
|
* @param ref - The fully qualified recipe ref being addressed.
|
|
437
433
|
* @returns True when the reference is in scope.
|
|
438
434
|
*/
|
|
439
435
|
function declaresRef(declared: string[], namespace: string, ref: string): boolean {
|
|
440
|
-
return declared.some((entry) =>
|
|
441
|
-
const normalized = normalizeRef(entry);
|
|
442
|
-
return normalized === ref || normalized === namespace;
|
|
443
|
-
});
|
|
436
|
+
return declared.some((entry) => entry === ref || entry === namespace);
|
|
444
437
|
}
|
|
445
438
|
|
|
446
439
|
/**
|
|
@@ -37,6 +37,8 @@ import type {
|
|
|
37
37
|
ProviderId,
|
|
38
38
|
ProviderOptions,
|
|
39
39
|
RepoProvider,
|
|
40
|
+
LocationReading,
|
|
41
|
+
WrittenLocation,
|
|
40
42
|
} from "./provider.js";
|
|
41
43
|
|
|
42
44
|
/**
|
|
@@ -68,6 +70,34 @@ export abstract class ProviderBase implements RepoProvider {
|
|
|
68
70
|
options?: ProviderOptions
|
|
69
71
|
): Promise<void>;
|
|
70
72
|
|
|
73
|
+
// --- Reading a location ----------------------------------------------------
|
|
74
|
+
|
|
75
|
+
/** This provider's public host, declared by a provider that has one. */
|
|
76
|
+
declare readonly defaultHost?: string;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* No readings: a provider that does not override this reads no location
|
|
80
|
+
* inside its repositories, so a ref naming one is refused with a sentence.
|
|
81
|
+
*
|
|
82
|
+
* @param _location - The written location; unused.
|
|
83
|
+
*/
|
|
84
|
+
readLocation(_location: WrittenLocation): LocationReading[] {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `<id>://<host>/<repoPath>/<rest>`, with the host left out when it is this
|
|
90
|
+
* provider's default.
|
|
91
|
+
*
|
|
92
|
+
* @param host - The repository's host.
|
|
93
|
+
* @param repoPath - The repository's path on that host.
|
|
94
|
+
* @param rest - What is named inside it.
|
|
95
|
+
*/
|
|
96
|
+
formatLocator(host: string, repoPath: string, rest: string): string {
|
|
97
|
+
const where = host === this.defaultHost ? repoPath : `${host}/${repoPath}`;
|
|
98
|
+
return `${this.id}://${where}${rest.length === 0 ? "" : `/${rest}`}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
71
101
|
// --- Subprocess plumbing ---------------------------------------------------
|
|
72
102
|
|
|
73
103
|
/**
|
|
@@ -24,7 +24,11 @@ import { fetchText, type FetchLike } from "./http.js";
|
|
|
24
24
|
import {
|
|
25
25
|
buildCanonicalRepo,
|
|
26
26
|
invalidRepoUrl,
|
|
27
|
+
readingsAfterRepository,
|
|
27
28
|
splitRepoUrl,
|
|
29
|
+
withoutGitSuffix,
|
|
30
|
+
type LocationReading,
|
|
31
|
+
type WrittenLocation,
|
|
28
32
|
type AuthStatus,
|
|
29
33
|
type CanonicalRepo,
|
|
30
34
|
type ChangeProposal,
|
|
@@ -83,6 +87,24 @@ export class GithubProvider extends ProviderBase {
|
|
|
83
87
|
/** What GitHub calls a proposal. */
|
|
84
88
|
readonly proposalNoun = "pull request";
|
|
85
89
|
|
|
90
|
+
/** The host a `github://` locator means when it names none. */
|
|
91
|
+
readonly defaultHost = GITHUB_HOST;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A GitHub repository is always an owner and a name, so the first two
|
|
95
|
+
* segments are the repository and everything after them names something
|
|
96
|
+
* inside it: a namespace, a recipe, or a browser path after `tree/` or
|
|
97
|
+
* `blob/`.
|
|
98
|
+
*
|
|
99
|
+
* @param location - The host and the segments after it.
|
|
100
|
+
*/
|
|
101
|
+
readLocation(location: WrittenLocation): LocationReading[] {
|
|
102
|
+
const { segments } = location;
|
|
103
|
+
if (segments.length < 2) return [];
|
|
104
|
+
const repoPath = `${segments[0]}/${withoutGitSuffix(segments[1]!)}`;
|
|
105
|
+
return readingsAfterRepository(repoPath, segments.slice(2));
|
|
106
|
+
}
|
|
107
|
+
|
|
86
108
|
matches(url: string): boolean {
|
|
87
109
|
const parts = splitRepoUrl(url);
|
|
88
110
|
return parts !== undefined && parts.host === GITHUB_HOST;
|