@sous-io/sous 0.2.32 → 0.2.34
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 +7 -6
- package/docs/markdown/_sidebar.md +1 -0
- package/docs/markdown/commands.md +8 -4
- package/docs/markdown/configuration.md +28 -4
- package/docs/markdown/repositories-authoring.md +22 -2
- package/docs/markdown/repositories-consuming.md +40 -11
- package/docs/markdown/repositories-file-formats.md +28 -13
- package/docs/markdown/repositories-quickstart.md +1 -1
- package/docs/markdown/repositories-variables.md +16 -0
- package/docs/markdown/repositories.md +8 -4
- package/package.json +2 -1
- package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +33 -20
- package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +4 -0
- package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +21 -9
- package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
- package/sous.config.schema.json +27 -15
- package/src/commands/lock/rebuild.ts +3 -6
- package/src/commands/namespace/show.ts +2 -2
- package/src/commands/recipe/show.ts +26 -9
- package/src/commands/repo/contribute.ts +32 -23
- package/src/commands/repo/gc.ts +1 -1
- package/src/commands/repo/remove.ts +1 -1
- package/src/commands/repo/search.ts +1 -1
- package/src/commands/vars/ask.ts +75 -54
- package/src/lib/build-service.ts +93 -15
- package/src/lib/config-schema.ts +59 -19
- package/src/lib/include-resolver.ts +307 -69
- package/src/lib/markdown-compiler.ts +289 -82
- package/src/lib/project-scaffold/templates.ts +19 -11
- package/src/lib/repos/catalog-inputs.ts +1 -1
- package/src/lib/repos/catalog.ts +98 -72
- package/src/lib/repos/declarations.ts +22 -12
- package/src/lib/repos/formats/index-file.ts +1 -1
- package/src/lib/repos/formats/recipe-manifest.ts +3 -4
- package/src/lib/repos/lock-service.ts +1 -1
- package/src/lib/repos/locked-namespace-resolver.ts +14 -6
- package/src/lib/repos/locked-recipe-lookup.ts +123 -0
- package/src/lib/repos/locked-recipes.ts +1 -1
- package/src/lib/repos/memories-hash-name.ts +33 -0
- package/src/lib/repos/namespace-resolver.ts +198 -119
- package/src/lib/repos/recipe-config-layers.ts +29 -1
- package/src/lib/repos/recipe-key-matcher.ts +62 -0
- package/src/lib/repos/recipe-memories.ts +175 -0
- package/src/lib/repos/recipe-targets.ts +32 -28
- package/src/lib/repos/ref-search.ts +1 -1
- package/src/lib/repos/release/index-builder.ts +23 -15
- package/src/lib/repos/release/plan.ts +14 -7
- package/src/lib/repos/release/settle.ts +38 -121
- package/src/lib/repos/release/submit-checkout.ts +42 -23
- package/src/lib/repos/release/tags.ts +1 -1
- package/src/lib/repos/resolver.ts +90 -72
- package/src/lib/repos/subscription-service.ts +192 -170
- package/src/lib/settings.ts +37 -33
- package/src/lib/vars/answers.ts +9 -1
- package/src/lib/vars/ask.ts +3 -3
- package/src/lib/vars/display.ts +1 -3
- package/src/lib/vars/index.ts +1 -0
- package/src/lib/vars/mappings.ts +17 -9
- package/src/lib/vars/secrets.ts +131 -0
- package/src/services/ref-resolver/container.ts +107 -0
- package/src/services/ref-resolver/dependency.ts +60 -0
- package/src/services/ref-resolver/format.ts +197 -0
- package/src/services/ref-resolver/glob.ts +115 -0
- package/src/services/ref-resolver/hash-names.ts +155 -0
- package/src/services/ref-resolver/index.ts +55 -0
- package/src/services/ref-resolver/injectable.ts +36 -0
- package/src/services/ref-resolver/location.ts +62 -0
- package/src/services/ref-resolver/lookups/cached-index-lookup.ts +53 -0
- package/src/services/ref-resolver/lookups/catalog-lookup.ts +23 -0
- package/src/services/ref-resolver/lookups/catalog-matcher.ts +261 -0
- package/src/services/ref-resolver/lookups/chained-lookup.ts +27 -0
- package/src/services/ref-resolver/lookups/env-var-lookup.ts +65 -0
- package/src/services/ref-resolver/lookups/fetched-index-lookup.ts +150 -0
- package/src/services/ref-resolver/lookups/recorded-dependency-lookup.ts +62 -0
- package/src/services/ref-resolver/lookups/ref-lookup.ts +54 -0
- package/src/services/ref-resolver/lookups/variable-lookup.ts +123 -0
- package/src/services/ref-resolver/named-refs.ts +144 -0
- package/src/services/ref-resolver/parser/env-var-splitter.ts +27 -0
- package/src/services/ref-resolver/parser/location-splitter.ts +239 -0
- package/src/services/ref-resolver/parser/name-path-splitter.ts +200 -0
- package/src/services/ref-resolver/parser/partial-ref.ts +84 -0
- package/src/services/ref-resolver/parser/query-splitter.ts +61 -0
- package/src/services/ref-resolver/parser/range-splitter.ts +52 -0
- package/src/services/ref-resolver/parser/ref-parser.ts +115 -0
- package/src/services/ref-resolver/parser/repo-qualifier-splitter.ts +51 -0
- package/src/services/ref-resolver/parts.ts +115 -0
- package/src/services/ref-resolver/pruners/command-line-pruner.ts +47 -0
- package/src/services/ref-resolver/pruners/include-pruner.ts +66 -0
- package/src/services/ref-resolver/pruners/manifest-pruner.ts +130 -0
- package/src/services/ref-resolver/pruners/ref-pruner.ts +135 -0
- package/src/services/ref-resolver/pruners/rule-helpers.ts +44 -0
- package/src/services/ref-resolver/pruners/stored-key-pruner.ts +136 -0
- package/src/services/ref-resolver/ref-helpers.ts +62 -0
- package/src/services/ref-resolver/ref-picker-service.ts +204 -0
- package/src/{lib/repos/reference-report.ts → services/ref-resolver/ref-report.ts} +2 -40
- package/src/services/ref-resolver/ref-resolve-arguments.ts +47 -0
- package/src/services/ref-resolver/ref-resolve-result.ts +36 -0
- package/src/services/ref-resolver/ref-resolver-service.ts +323 -0
- package/src/services/ref-resolver/source.ts +48 -0
- package/src/services/ref-resolver/tokens.ts +25 -0
- package/src/services/ref-resolver/types.ts +155 -0
- package/src/templating/init-liquid-engine.ts +33 -18
- package/src/templating/lib/secret-scope.ts +41 -0
- package/src/templating/tags/exportScalarVarsJs.ts +5 -2
- package/src/templating/tags/showVars.ts +8 -2
- package/src/lib/refs/find.ts +0 -770
- package/src/lib/refs/index.ts +0 -14
- package/src/lib/refs/parse.ts +0 -844
- package/src/lib/refs/pick.ts +0 -147
- package/src/lib/refs/scopes.ts +0 -95
- package/src/lib/refs/settle.ts +0 -219
package/src/lib/settings.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type DiscoveredConfig,
|
|
15
15
|
} from "./config-discovery.js";
|
|
16
16
|
import { ConfigError } from "./errors.js";
|
|
17
|
+
import { sharedHashNames } from "../services/ref-resolver/index.js";
|
|
17
18
|
import { resolveSousHome } from "./sous-home.js";
|
|
18
19
|
import { validateSettings } from "./config-schema.js";
|
|
19
20
|
import { applyRepoDefaults } from "./repos/defaults.js";
|
|
@@ -148,23 +149,25 @@ type StoreConfig = {
|
|
|
148
149
|
};
|
|
149
150
|
|
|
150
151
|
/**
|
|
151
|
-
*
|
|
152
|
-
* destination directories per content kind. Each destination is `${var}`
|
|
153
|
-
* substituted like any other config path, and a kind may name several so the
|
|
154
|
-
* same recipe feeds more than one agent directory.
|
|
152
|
+
* What a project does with each content kind its subscribed recipes publish.
|
|
155
153
|
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
154
|
+
* `skills.outputs` lists the destination directories for recipe skills, each
|
|
155
|
+
* `${var}` substituted; the default is `<project root>/.claude/skills`, the
|
|
156
|
+
* project root being the parent of `.sous/`. `memories.first` and
|
|
157
|
+
* `memories.exclude` are lists of globs, or `/.../` regular expressions, over
|
|
158
|
+
* `namespace/recipe` keys.
|
|
160
159
|
*/
|
|
161
|
-
type
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
160
|
+
type RecipesConfig = {
|
|
161
|
+
skills?: {
|
|
162
|
+
/** Where recipe skill bundles are written. */
|
|
163
|
+
outputs?: string[];
|
|
164
|
+
};
|
|
165
|
+
memories?: {
|
|
166
|
+
/** Recipes whose memories come first. */
|
|
167
|
+
first?: string[];
|
|
168
|
+
/** Recipes whose memories are left out. */
|
|
169
|
+
exclude?: string[];
|
|
170
|
+
};
|
|
168
171
|
};
|
|
169
172
|
|
|
170
173
|
/** Configuration for a launchable tool (e.g. claude, codex). */
|
|
@@ -204,9 +207,9 @@ export type Settings = {
|
|
|
204
207
|
/** Knobs for the machine-wide recipe store. */
|
|
205
208
|
store?: StoreConfig;
|
|
206
209
|
/**
|
|
207
|
-
*
|
|
210
|
+
* What the project does with each content kind its subscribed recipes publish.
|
|
208
211
|
*/
|
|
209
|
-
|
|
212
|
+
recipes?: RecipesConfig;
|
|
210
213
|
/**
|
|
211
214
|
* Variable mapping records, keyed by environment variable name, each bound to
|
|
212
215
|
* one recipe variable written as `namespace/recipe/variableName` with an
|
|
@@ -820,30 +823,29 @@ function resolveAnswerLayer(
|
|
|
820
823
|
}
|
|
821
824
|
|
|
822
825
|
/**
|
|
823
|
-
* Built-in `@include` aliases
|
|
824
|
-
*
|
|
825
|
-
*
|
|
826
|
+
* Built-in `@include` aliases: every `#` name the registry holds
|
|
827
|
+
* (`sharedHashNames`), each standing for its directories in this project. They
|
|
828
|
+
* are reserved (user `_aliases` may not start with `#`), so a user alias can
|
|
829
|
+
* never shadow one.
|
|
826
830
|
*
|
|
827
|
-
* -
|
|
831
|
+
* - `#project` is the consuming project's root (`projectRoot`).
|
|
828
832
|
*
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
833
|
-
*
|
|
834
|
-
*
|
|
833
|
+
* Files that used to be reached through a built-in alias pointing inside the
|
|
834
|
+
* sous package are published as recipes now, and a recipe's files are addressed
|
|
835
|
+
* by its namespace (`@~workflow/task-files/_partials/resume-task.md`),
|
|
836
|
+
* resolved against what the project has pinned. A `~` reference is NOT an
|
|
837
|
+
* alias: it is resolved separately, after the alias map has been tried; see
|
|
838
|
+
* `locked-namespace-resolver.ts`.
|
|
835
839
|
*/
|
|
836
840
|
export function buildBuiltInAliases(scope: VarScope): AliasMap {
|
|
837
|
-
|
|
838
|
-
if (scope.projectRoot) builtIns["~project"] = [scope.projectRoot];
|
|
839
|
-
return builtIns;
|
|
841
|
+
return sharedHashNames().aliasMap(scope as Record<string, string>);
|
|
840
842
|
}
|
|
841
843
|
|
|
842
844
|
/**
|
|
843
845
|
* Resolve the full `@include` alias map: built-ins, then the config's
|
|
844
846
|
* `_aliases` block (user entries prepend, so they are tried first and fall
|
|
845
847
|
* through to built-in bases of the same name). User alias names starting
|
|
846
|
-
* with `~` are
|
|
848
|
+
* with `~` or `#` are a ConfigError (reserved).
|
|
847
849
|
*
|
|
848
850
|
* @param settings - The loaded settings (for the `_aliases` block).
|
|
849
851
|
* @param scope - The resolved settings scope (for ${var} substitution + projectRoot).
|
|
@@ -853,7 +855,9 @@ export function resolveAliases(settings: Settings, scope: VarScope): AliasMap {
|
|
|
853
855
|
builtIns: buildBuiltInAliases(scope),
|
|
854
856
|
userAliases: [settings._aliases],
|
|
855
857
|
scope,
|
|
856
|
-
onError:
|
|
858
|
+
onError: (message) => {
|
|
859
|
+
throw new ConfigError(message);
|
|
860
|
+
},
|
|
857
861
|
});
|
|
858
862
|
}
|
|
859
863
|
|
|
@@ -1002,7 +1006,7 @@ export function resolveCompilation(
|
|
|
1002
1006
|
|
|
1003
1007
|
/* c8 ignore start */
|
|
1004
1008
|
// Glob target: expand pattern into one CompilationTarget per matched file, skipping dirs.
|
|
1005
|
-
// A leading alias (
|
|
1009
|
+
// A leading alias (`#project/skills/**`) expands to one candidate pattern per alias
|
|
1006
1010
|
// base; the first base that matches any files wins, mirroring the first-existing-wins
|
|
1007
1011
|
// rule of @include resolution.
|
|
1008
1012
|
const rawPattern = substituteVarsStrict(target.entryGlob!, targetScope, `${where}.entryGlob`);
|
package/src/lib/vars/answers.ts
CHANGED
|
@@ -23,6 +23,11 @@
|
|
|
23
23
|
* is something to tell the user about, not a reason to refuse to build the
|
|
24
24
|
* rest of the project.
|
|
25
25
|
*
|
|
26
|
+
* Alongside them comes `secrets`: every name some definition declares
|
|
27
|
+
* `secret: true`. The tags that dump a whole scope (`{% showVars %}`,
|
|
28
|
+
* `{% exportScalarVarsJs %}`) read it so a secret answer never lands in
|
|
29
|
+
* compiled output unless a template names it explicitly.
|
|
30
|
+
*
|
|
26
31
|
* A definition's `default` counts as an answer of last resort: the
|
|
27
32
|
* description a publisher writes promises what the default does, and a
|
|
28
33
|
* template rendering an empty string instead would break that promise.
|
|
@@ -49,6 +54,8 @@ export interface RecipeAnswers {
|
|
|
49
54
|
byRecipe: Map<string, VarScope>;
|
|
50
55
|
/** Every required definition that nothing answered and that has no default. */
|
|
51
56
|
unanswered: DefinedVariable[];
|
|
57
|
+
/** Every variable name some definition declares `secret: true`, answered or not. */
|
|
58
|
+
secrets: Set<string>;
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
/** How the answers are resolved. */
|
|
@@ -76,7 +83,7 @@ export interface RecipeAnswerOptions {
|
|
|
76
83
|
|
|
77
84
|
/** The empty result, for a project that locks nothing. */
|
|
78
85
|
export function noRecipeAnswers(): RecipeAnswers {
|
|
79
|
-
return { merged: {}, byRecipe: new Map(), unanswered: [] };
|
|
86
|
+
return { merged: {}, byRecipe: new Map(), unanswered: [], secrets: new Set() };
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
/**
|
|
@@ -103,6 +110,7 @@ export function resolveRecipeAnswers(options: RecipeAnswerOptions): RecipeAnswer
|
|
|
103
110
|
for (const defined of definitions) {
|
|
104
111
|
const value = answerFor(defined, context);
|
|
105
112
|
const name = defined.definition.name;
|
|
113
|
+
if (defined.definition.secret) result.secrets.add(name);
|
|
106
114
|
|
|
107
115
|
if (value === undefined) {
|
|
108
116
|
if (defined.definition.required) result.unanswered.push(defined);
|
package/src/lib/vars/ask.ts
CHANGED
|
@@ -42,7 +42,7 @@ import { choicePrompt } from "../../utils/choice-prompt.js";
|
|
|
42
42
|
import { confirmPrompt } from "../../utils/confirm-prompt.js";
|
|
43
43
|
import { valuePrompt } from "../../utils/value-prompt.js";
|
|
44
44
|
import { ENV_VAR_NAME_PATTERN } from "../repos/formats/patterns.js";
|
|
45
|
-
import {
|
|
45
|
+
import { refKey, variableRefOf } from "../../services/ref-resolver/index.js";
|
|
46
46
|
import type { VariableDefinition } from "../repos/formats/recipe-manifest.js";
|
|
47
47
|
import {
|
|
48
48
|
definedVariableKey,
|
|
@@ -152,7 +152,7 @@ export interface AskOptions {
|
|
|
152
152
|
* Limit the run to these variables. Each entry is a variable's bare name, its
|
|
153
153
|
* `namespace/recipe.name` key, or its fully qualified
|
|
154
154
|
* `repository:namespace/recipe.name` reference. A command that resolved a
|
|
155
|
-
* reference through `src/
|
|
155
|
+
* reference through `src/services/ref-resolver/` passes the fully qualified form, which is
|
|
156
156
|
* the only spelling that cannot mean two variables at once.
|
|
157
157
|
*/
|
|
158
158
|
only?: string[];
|
|
@@ -182,7 +182,7 @@ export function answerFileFor(definition: VariableDefinition): AnswerFile {
|
|
|
182
182
|
function isNamed(defined: DefinedVariable, only: string[] | undefined): boolean {
|
|
183
183
|
if (only === undefined) return false;
|
|
184
184
|
const key = definedVariableKey(defined);
|
|
185
|
-
const qualified =
|
|
185
|
+
const qualified = refKey(variableRefOf(defined));
|
|
186
186
|
return only.some(
|
|
187
187
|
(name) => name === defined.definition.name || name === key || name === qualified
|
|
188
188
|
);
|
package/src/lib/vars/display.ts
CHANGED
|
@@ -19,9 +19,7 @@ import {
|
|
|
19
19
|
type DefiningRecipe,
|
|
20
20
|
} from "./definition-source.js";
|
|
21
21
|
import { constraintBullets } from "./validate.js";
|
|
22
|
-
|
|
23
|
-
/** What the value column shows for a secret whose answer is known. */
|
|
24
|
-
export const HIDDEN_VALUE = "(hidden)";
|
|
22
|
+
import { HIDDEN_VALUE } from "./secrets.js";
|
|
25
23
|
|
|
26
24
|
/** What the value column shows for a variable nothing has answered. */
|
|
27
25
|
export const UNANSWERED_VALUE = "(unanswered)";
|
package/src/lib/vars/index.ts
CHANGED
package/src/lib/vars/mappings.ts
CHANGED
|
@@ -27,8 +27,14 @@ import {
|
|
|
27
27
|
updateManagedLayer,
|
|
28
28
|
} from "../repos/managed-layer.js";
|
|
29
29
|
import { VARIABLE_NAME_PATTERN } from "../repos/formats/patterns.js";
|
|
30
|
-
import {
|
|
31
|
-
|
|
30
|
+
import {
|
|
31
|
+
RefSource,
|
|
32
|
+
formatRef,
|
|
33
|
+
namespaceOf,
|
|
34
|
+
parseNamedRef,
|
|
35
|
+
repoOf,
|
|
36
|
+
type NamedRef,
|
|
37
|
+
} from "../../services/ref-resolver/index.js";
|
|
32
38
|
import type { DefinedVariable } from "./definition-source.js";
|
|
33
39
|
|
|
34
40
|
/** File name of the machine-written mapping record layer. */
|
|
@@ -85,18 +91,20 @@ export function parseMappingTarget(input: string): MappingTarget {
|
|
|
85
91
|
// ref part is read by the one ref parser and held to the stored form.
|
|
86
92
|
const slash = trimmed.lastIndexOf("/");
|
|
87
93
|
const variable = slash === -1 ? "" : trimmed.slice(slash + 1);
|
|
88
|
-
let ref:
|
|
94
|
+
let ref: NamedRef | undefined;
|
|
89
95
|
try {
|
|
90
|
-
ref =
|
|
96
|
+
ref = parseNamedRef(trimmed.slice(0, Math.max(slash, 0)), RefSource.CommandLine);
|
|
91
97
|
} catch (error) {
|
|
92
98
|
fail((error as Error).message.split("\n")[0]!.replace(/^Invalid ref '[^']*': /, ""));
|
|
93
99
|
}
|
|
94
|
-
|
|
95
|
-
if (recipe === undefined || ref.range !== undefined) {
|
|
100
|
+
if (ref?.kind !== "recipe" || ref.range !== undefined) {
|
|
96
101
|
fail("a target names a namespace, a recipe and a variable, joined by slashes.");
|
|
97
102
|
}
|
|
98
|
-
|
|
99
|
-
|
|
103
|
+
const repo = repoOf(ref!)?.name;
|
|
104
|
+
const namespace = namespaceOf(ref!)!.name;
|
|
105
|
+
const recipe = ref!.kind === "recipe" ? ref!.name : "";
|
|
106
|
+
if (formatRef(ref!) !== formatRef(ref!).toLowerCase()) {
|
|
107
|
+
fail(`the repository, namespace and recipe in '${formatRef(ref!)}' must be lowercase.`);
|
|
100
108
|
}
|
|
101
109
|
if (!VARIABLE_NAME_PATTERN.test(variable)) {
|
|
102
110
|
fail(
|
|
@@ -105,7 +113,7 @@ export function parseMappingTarget(input: string): MappingTarget {
|
|
|
105
113
|
);
|
|
106
114
|
}
|
|
107
115
|
|
|
108
|
-
const parsed: MappingTarget = { namespace, recipe
|
|
116
|
+
const parsed: MappingTarget = { namespace, recipe, variable };
|
|
109
117
|
if (repo !== undefined) parsed.repo = repo;
|
|
110
118
|
return parsed;
|
|
111
119
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which variables hold a secret.
|
|
3
|
+
*
|
|
4
|
+
* A recipe marks a variable `secret: true`, and that declaration is the
|
|
5
|
+
* authority. Everything else in a template's scope (an `_env` mapping, a
|
|
6
|
+
* project `_vars` entry, an answer to a definition that forgot the flag) is
|
|
7
|
+
* checked by a deliberately thin heuristic: a name whose last words say it is
|
|
8
|
+
* a secret (`githubToken`, `OPENAI_API_KEY`, `dbPassword`), or a value in one
|
|
9
|
+
* of the widely used token formats (a GitHub or GitLab token, an AWS access
|
|
10
|
+
* key, a private key block, a URL carrying a password). The heuristic only
|
|
11
|
+
* catches what is very probably a secret; it is a safety net, not a scanner.
|
|
12
|
+
*
|
|
13
|
+
* This module imports nothing, so the template tags can use it without
|
|
14
|
+
* pulling the variables layer into the template engine.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** What a hidden value shows in place of the secret. */
|
|
18
|
+
export const HIDDEN_VALUE = "(hidden)";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Name endings that mark a secret, as lowercase words. A name matches when its
|
|
22
|
+
* last words are one of these, so `apiToken` and `GITHUB_TOKEN` match while
|
|
23
|
+
* `tokenLimit` does not.
|
|
24
|
+
*/
|
|
25
|
+
const SECRET_NAME_ENDINGS: readonly (readonly string[])[] = [
|
|
26
|
+
["token"],
|
|
27
|
+
["secret"],
|
|
28
|
+
["secrets"],
|
|
29
|
+
["password"],
|
|
30
|
+
["passwd"],
|
|
31
|
+
["passphrase"],
|
|
32
|
+
["credential"],
|
|
33
|
+
["credentials"],
|
|
34
|
+
["apikey"],
|
|
35
|
+
["api", "key"],
|
|
36
|
+
["private", "key"],
|
|
37
|
+
["access", "key"],
|
|
38
|
+
["secret", "key"],
|
|
39
|
+
["signing", "key"],
|
|
40
|
+
["encryption", "key"],
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** Widely used secret formats, each specific enough that a match is very probably a secret. */
|
|
44
|
+
const SECRET_VALUE_PATTERNS: readonly RegExp[] = [
|
|
45
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/, // a PEM private key
|
|
46
|
+
/\bgh[pousr]_[A-Za-z0-9]{36,}/, // GitHub token
|
|
47
|
+
/\bgithub_pat_[A-Za-z0-9_]{22,}/, // GitHub fine-grained token
|
|
48
|
+
/\bglpat-[A-Za-z0-9_-]{20,}/, // GitLab personal access token
|
|
49
|
+
/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/, // AWS access key id
|
|
50
|
+
/\bxox[abposr]-[A-Za-z0-9-]{10,}/, // Slack token
|
|
51
|
+
/\b[rs]k_live_[A-Za-z0-9]{16,}/, // Stripe live key
|
|
52
|
+
/\bsk-ant-[A-Za-z0-9_-]{20,}/, // Anthropic API key
|
|
53
|
+
/\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}/, // OpenAI API key
|
|
54
|
+
/\bAIza[0-9A-Za-z_-]{35}/, // Google API key
|
|
55
|
+
/\bnpm_[A-Za-z0-9]{36}\b/, // npm token
|
|
56
|
+
/\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/, // JSON Web Token
|
|
57
|
+
/^[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^/\s@]+@/i, // a URL carrying a password
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/** Values a secret-sounding name may hold without being a secret: nothing, a number, a switch. */
|
|
61
|
+
const PLAIN_VALUE = /^(?:|-?\d+(?:\.\d+)?|true|false|yes|no|on|off)$/i;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The words in a variable name, lowercased: split at `_`, `-`, `.`, spaces and
|
|
65
|
+
* camelCase boundaries, so `OPENAI_API_KEY`, `openaiApiKey` and `openai-api-key`
|
|
66
|
+
* all read as `openai api key`.
|
|
67
|
+
*
|
|
68
|
+
* @param name - The variable name.
|
|
69
|
+
*/
|
|
70
|
+
function nameWords(name: string): string[] {
|
|
71
|
+
return name
|
|
72
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
73
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
74
|
+
.split(/[^A-Za-z0-9]+/)
|
|
75
|
+
.filter((word) => word.length > 0)
|
|
76
|
+
.map((word) => word.toLowerCase());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether a variable name says it holds a secret.
|
|
81
|
+
*
|
|
82
|
+
* @param name - The variable name.
|
|
83
|
+
*/
|
|
84
|
+
export function hasSecretName(name: string): boolean {
|
|
85
|
+
const words = nameWords(name);
|
|
86
|
+
return SECRET_NAME_ENDINGS.some(
|
|
87
|
+
(ending) =>
|
|
88
|
+
words.length >= ending.length &&
|
|
89
|
+
ending.every((word, i) => words[words.length - ending.length + i] === word)
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether a value is in one of the widely used secret formats.
|
|
95
|
+
*
|
|
96
|
+
* @param value - The value.
|
|
97
|
+
*/
|
|
98
|
+
export function hasSecretValue(value: string): boolean {
|
|
99
|
+
return SECRET_VALUE_PATTERNS.some((pattern) => pattern.test(value));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Whether a variable very probably holds a secret: its name says so (and its
|
|
104
|
+
* value is more than a number or a switch), or its value is in a known secret
|
|
105
|
+
* format.
|
|
106
|
+
*
|
|
107
|
+
* @param name - The variable name.
|
|
108
|
+
* @param value - Its value.
|
|
109
|
+
*/
|
|
110
|
+
export function looksLikeSecret(name: string, value: string): boolean {
|
|
111
|
+
if (hasSecretValue(value)) return true;
|
|
112
|
+
return hasSecretName(name) && !PLAIN_VALUE.test(value.trim());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether a variable is to be treated as a secret: a recipe declared it one,
|
|
117
|
+
* or the heuristic says it very probably is.
|
|
118
|
+
*
|
|
119
|
+
* @param name - The variable name.
|
|
120
|
+
* @param value - Its value.
|
|
121
|
+
* @param declared - The names recipes declared `secret: true`.
|
|
122
|
+
*/
|
|
123
|
+
export function isSecretVariable(
|
|
124
|
+
name: string,
|
|
125
|
+
value: unknown,
|
|
126
|
+
declared: ReadonlySet<string> = new Set()
|
|
127
|
+
): boolean {
|
|
128
|
+
if (typeof value !== "string") return false;
|
|
129
|
+
if (declared.has(name)) return value.length > 0;
|
|
130
|
+
return looksLikeSecret(name, value);
|
|
131
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Inversify container of the ref services.
|
|
3
|
+
*
|
|
4
|
+
* Every part is bound once and shared: the parser, every splitter, every
|
|
5
|
+
* pruner, the resolver and the picker. Splitters, pruners and providers are
|
|
6
|
+
* bound many times under one token each (`REF_TOKENS`), and the parser and the
|
|
7
|
+
* resolver receive them as lists, so a plugin adds a splitter, a pruner or a
|
|
8
|
+
* provider by binding one more under the same token on a container it builds
|
|
9
|
+
* with `createRefContainer()`:
|
|
10
|
+
*
|
|
11
|
+
* const container = createRefContainer();
|
|
12
|
+
* container.bind(REF_TOKENS.Splitter).to(MyNewFormSplitter);
|
|
13
|
+
* const resolver = container.get<RefResolverService>(REF_TOKENS.Resolver);
|
|
14
|
+
*
|
|
15
|
+
* The `#` names are bound the same way: one more `HashName` under
|
|
16
|
+
* `REF_TOKENS.HashName` registers a view or a built-in, and a name bound twice
|
|
17
|
+
* is an error when the registry is first read.
|
|
18
|
+
*
|
|
19
|
+
* Callers that have no container of their own use `sharedRefResolver`,
|
|
20
|
+
* `sharedHashNames` and `sharedRefPicker`, which come from one container built the first time they
|
|
21
|
+
* are read.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { Container } from "inversify";
|
|
25
|
+
import { builtInProviders } from "../../lib/repos/providers/index.js";
|
|
26
|
+
import { EnvVarSplitter } from "./parser/env-var-splitter.js";
|
|
27
|
+
import { LocationSplitter } from "./parser/location-splitter.js";
|
|
28
|
+
import { NamePathSplitter } from "./parser/name-path-splitter.js";
|
|
29
|
+
import { QuerySplitter } from "./parser/query-splitter.js";
|
|
30
|
+
import { RangeSplitter } from "./parser/range-splitter.js";
|
|
31
|
+
import { RefParser } from "./parser/ref-parser.js";
|
|
32
|
+
import { RepoQualifierSplitter } from "./parser/repo-qualifier-splitter.js";
|
|
33
|
+
import { CommandLinePruner } from "./pruners/command-line-pruner.js";
|
|
34
|
+
import { IncludePruner } from "./pruners/include-pruner.js";
|
|
35
|
+
import { ManifestPruner } from "./pruners/manifest-pruner.js";
|
|
36
|
+
import { ConfigPruner, LockfilePruner } from "./pruners/stored-key-pruner.js";
|
|
37
|
+
import { MemoriesHashName } from "../../lib/repos/memories-hash-name.js";
|
|
38
|
+
import { HashNameRegistry, ProjectHashName } from "./hash-names.js";
|
|
39
|
+
import { RefPickerService } from "./ref-picker-service.js";
|
|
40
|
+
import { RefResolverService } from "./ref-resolver-service.js";
|
|
41
|
+
import { REF_TOKENS } from "./tokens.js";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Builds a container holding every ref service. Each call returns a fresh
|
|
45
|
+
* container, so a plugin or a test may bind more splitters or pruners without
|
|
46
|
+
* touching anyone else's.
|
|
47
|
+
*/
|
|
48
|
+
export function createRefContainer(): Container {
|
|
49
|
+
const container = new Container({ defaultScope: "Singleton" });
|
|
50
|
+
|
|
51
|
+
for (const provider of builtInProviders()) {
|
|
52
|
+
container.bind(REF_TOKENS.Provider).toConstantValue(provider);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const splitter of [
|
|
56
|
+
QuerySplitter,
|
|
57
|
+
RangeSplitter,
|
|
58
|
+
LocationSplitter,
|
|
59
|
+
RepoQualifierSplitter,
|
|
60
|
+
NamePathSplitter,
|
|
61
|
+
EnvVarSplitter,
|
|
62
|
+
]) {
|
|
63
|
+
container.bind(REF_TOKENS.Splitter).to(splitter);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const pruner of [
|
|
67
|
+
CommandLinePruner,
|
|
68
|
+
ConfigPruner,
|
|
69
|
+
ManifestPruner,
|
|
70
|
+
LockfilePruner,
|
|
71
|
+
IncludePruner,
|
|
72
|
+
]) {
|
|
73
|
+
container.bind(REF_TOKENS.Pruner).to(pruner);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
container.bind(REF_TOKENS.HashName).to(ProjectHashName);
|
|
77
|
+
container.bind(REF_TOKENS.HashName).to(MemoriesHashName);
|
|
78
|
+
container.bind(REF_TOKENS.HashNames).to(HashNameRegistry);
|
|
79
|
+
container.bind(REF_TOKENS.Parser).to(RefParser);
|
|
80
|
+
container.bind(REF_TOKENS.Resolver).to(RefResolverService);
|
|
81
|
+
container.bind(REF_TOKENS.Picker).to(RefPickerService);
|
|
82
|
+
return container;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The container the shared instances come from, built on first use. */
|
|
86
|
+
let sharedContainer: Container | undefined;
|
|
87
|
+
|
|
88
|
+
/** The one container callers with no container of their own share. */
|
|
89
|
+
export function getSharedRefContainer(): Container {
|
|
90
|
+
sharedContainer ??= createRefContainer();
|
|
91
|
+
return sharedContainer;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The shared resolver, for callers that have no container. */
|
|
95
|
+
export function sharedRefResolver(): RefResolverService {
|
|
96
|
+
return getSharedRefContainer().get<RefResolverService>(REF_TOKENS.Resolver);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The shared registry of `#` names, for callers that have no container. */
|
|
100
|
+
export function sharedHashNames(): HashNameRegistry {
|
|
101
|
+
return getSharedRefContainer().get<HashNameRegistry>(REF_TOKENS.HashNames);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The shared picker, for callers that have no container. */
|
|
105
|
+
export function sharedRefPicker(): RefPickerService {
|
|
106
|
+
return getSharedRefContainer().get<RefPickerService>(REF_TOKENS.Picker);
|
|
107
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settling a manifest dependency that reads more than one way, from what is
|
|
3
|
+
* already at hand.
|
|
4
|
+
*
|
|
5
|
+
* A manifest entry usually says exactly what it means. A GitLab URL with nested
|
|
6
|
+
* groups does not say where the project path ends, and a browser URL names a
|
|
7
|
+
* folder, so the parser returns every reading. A release settles such an entry
|
|
8
|
+
* and records the repository it settled on beside each key it resolved
|
|
9
|
+
* (`RecordedDependencies`); a consumer reads that record and never probes.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { sharedRefResolver } from "./container.js";
|
|
13
|
+
import {
|
|
14
|
+
isConfirmedByRecord,
|
|
15
|
+
type RecordedDependencies,
|
|
16
|
+
} from "./lookups/recorded-dependency-lookup.js";
|
|
17
|
+
import { RefSource } from "./source.js";
|
|
18
|
+
import type { SousRef } from "./types.js";
|
|
19
|
+
|
|
20
|
+
/** What `settleDependency` may consult, beyond the dependency itself. */
|
|
21
|
+
export type SettleDependencyOptions = {
|
|
22
|
+
/** What the declaring version's index entry records about its dependencies. */
|
|
23
|
+
recorded?: RecordedDependencies;
|
|
24
|
+
/**
|
|
25
|
+
* Whether a reading is already known to be right from what is on this
|
|
26
|
+
* machine: its repository is trusted and its cached index publishes what it
|
|
27
|
+
* names. Used only when the index recorded nothing, and it never fetches.
|
|
28
|
+
*/
|
|
29
|
+
known?: (reading: SousRef) => boolean;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The one reading a manifest dependency settles on, or undefined when it reads
|
|
34
|
+
* more than one way and nothing at hand says which: the declaring recipe's
|
|
35
|
+
* index recorded no answer, and what this machine already knows does not pick
|
|
36
|
+
* exactly one.
|
|
37
|
+
*
|
|
38
|
+
* settleDependency("workflow/alpha");
|
|
39
|
+
* // -> the recipe workflow/alpha
|
|
40
|
+
*
|
|
41
|
+
* @param written - The dependency as the manifest wrote it.
|
|
42
|
+
* @param options - The index's record, and what is known locally.
|
|
43
|
+
* @throws A ConfigError when a manifest may not write the entry that way.
|
|
44
|
+
*/
|
|
45
|
+
export function settleDependency(
|
|
46
|
+
written: string,
|
|
47
|
+
options: SettleDependencyOptions = {}
|
|
48
|
+
): SousRef | undefined {
|
|
49
|
+
const { refs } = sharedRefResolver().parse(written, RefSource.Manifest);
|
|
50
|
+
if (refs.length === 1) return refs[0];
|
|
51
|
+
|
|
52
|
+
const confirmed = refs.filter((ref) => isConfirmedByRecord(ref, options.recorded));
|
|
53
|
+
if (confirmed.length === 1) return confirmed[0];
|
|
54
|
+
|
|
55
|
+
if (options.known !== undefined) {
|
|
56
|
+
const known = refs.filter(options.known);
|
|
57
|
+
if (known.length === 1) return known[0];
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|