@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.
Files changed (111) hide show
  1. package/README.md +7 -6
  2. package/docs/markdown/_sidebar.md +1 -0
  3. package/docs/markdown/commands.md +8 -4
  4. package/docs/markdown/configuration.md +28 -4
  5. package/docs/markdown/repositories-authoring.md +22 -2
  6. package/docs/markdown/repositories-consuming.md +40 -11
  7. package/docs/markdown/repositories-file-formats.md +28 -13
  8. package/docs/markdown/repositories-quickstart.md +1 -1
  9. package/docs/markdown/repositories-variables.md +16 -0
  10. package/docs/markdown/repositories.md +8 -4
  11. package/package.json +2 -1
  12. package/recipes/core/sous-skills/skills/about-liquid-templates/SKILL.tpl.md +33 -20
  13. package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +4 -0
  14. package/recipes/core/sous-skills/skills/about-sous-recipes/SKILL.tpl.md +21 -9
  15. package/recipes/core/sous-skills/sous.recipe.yaml +1 -1
  16. package/sous.config.schema.json +27 -15
  17. package/src/commands/lock/rebuild.ts +3 -6
  18. package/src/commands/namespace/show.ts +2 -2
  19. package/src/commands/recipe/show.ts +26 -9
  20. package/src/commands/repo/contribute.ts +32 -23
  21. package/src/commands/repo/gc.ts +1 -1
  22. package/src/commands/repo/remove.ts +1 -1
  23. package/src/commands/repo/search.ts +1 -1
  24. package/src/commands/vars/ask.ts +75 -54
  25. package/src/lib/build-service.ts +93 -15
  26. package/src/lib/config-schema.ts +59 -19
  27. package/src/lib/include-resolver.ts +307 -69
  28. package/src/lib/markdown-compiler.ts +289 -82
  29. package/src/lib/project-scaffold/templates.ts +19 -11
  30. package/src/lib/repos/catalog-inputs.ts +1 -1
  31. package/src/lib/repos/catalog.ts +98 -72
  32. package/src/lib/repos/declarations.ts +22 -12
  33. package/src/lib/repos/formats/index-file.ts +1 -1
  34. package/src/lib/repos/formats/recipe-manifest.ts +3 -4
  35. package/src/lib/repos/lock-service.ts +1 -1
  36. package/src/lib/repos/locked-namespace-resolver.ts +14 -6
  37. package/src/lib/repos/locked-recipe-lookup.ts +123 -0
  38. package/src/lib/repos/locked-recipes.ts +1 -1
  39. package/src/lib/repos/memories-hash-name.ts +33 -0
  40. package/src/lib/repos/namespace-resolver.ts +198 -119
  41. package/src/lib/repos/recipe-config-layers.ts +29 -1
  42. package/src/lib/repos/recipe-key-matcher.ts +62 -0
  43. package/src/lib/repos/recipe-memories.ts +175 -0
  44. package/src/lib/repos/recipe-targets.ts +32 -28
  45. package/src/lib/repos/ref-search.ts +1 -1
  46. package/src/lib/repos/release/index-builder.ts +23 -15
  47. package/src/lib/repos/release/plan.ts +14 -7
  48. package/src/lib/repos/release/settle.ts +38 -121
  49. package/src/lib/repos/release/submit-checkout.ts +42 -23
  50. package/src/lib/repos/release/tags.ts +1 -1
  51. package/src/lib/repos/resolver.ts +90 -72
  52. package/src/lib/repos/subscription-service.ts +192 -170
  53. package/src/lib/settings.ts +37 -33
  54. package/src/lib/vars/answers.ts +9 -1
  55. package/src/lib/vars/ask.ts +3 -3
  56. package/src/lib/vars/display.ts +1 -3
  57. package/src/lib/vars/index.ts +1 -0
  58. package/src/lib/vars/mappings.ts +17 -9
  59. package/src/lib/vars/secrets.ts +131 -0
  60. package/src/services/ref-resolver/container.ts +107 -0
  61. package/src/services/ref-resolver/dependency.ts +60 -0
  62. package/src/services/ref-resolver/format.ts +197 -0
  63. package/src/services/ref-resolver/glob.ts +115 -0
  64. package/src/services/ref-resolver/hash-names.ts +155 -0
  65. package/src/services/ref-resolver/index.ts +55 -0
  66. package/src/services/ref-resolver/injectable.ts +36 -0
  67. package/src/services/ref-resolver/location.ts +62 -0
  68. package/src/services/ref-resolver/lookups/cached-index-lookup.ts +53 -0
  69. package/src/services/ref-resolver/lookups/catalog-lookup.ts +23 -0
  70. package/src/services/ref-resolver/lookups/catalog-matcher.ts +261 -0
  71. package/src/services/ref-resolver/lookups/chained-lookup.ts +27 -0
  72. package/src/services/ref-resolver/lookups/env-var-lookup.ts +65 -0
  73. package/src/services/ref-resolver/lookups/fetched-index-lookup.ts +150 -0
  74. package/src/services/ref-resolver/lookups/recorded-dependency-lookup.ts +62 -0
  75. package/src/services/ref-resolver/lookups/ref-lookup.ts +54 -0
  76. package/src/services/ref-resolver/lookups/variable-lookup.ts +123 -0
  77. package/src/services/ref-resolver/named-refs.ts +144 -0
  78. package/src/services/ref-resolver/parser/env-var-splitter.ts +27 -0
  79. package/src/services/ref-resolver/parser/location-splitter.ts +239 -0
  80. package/src/services/ref-resolver/parser/name-path-splitter.ts +200 -0
  81. package/src/services/ref-resolver/parser/partial-ref.ts +84 -0
  82. package/src/services/ref-resolver/parser/query-splitter.ts +61 -0
  83. package/src/services/ref-resolver/parser/range-splitter.ts +52 -0
  84. package/src/services/ref-resolver/parser/ref-parser.ts +115 -0
  85. package/src/services/ref-resolver/parser/repo-qualifier-splitter.ts +51 -0
  86. package/src/services/ref-resolver/parts.ts +115 -0
  87. package/src/services/ref-resolver/pruners/command-line-pruner.ts +47 -0
  88. package/src/services/ref-resolver/pruners/include-pruner.ts +66 -0
  89. package/src/services/ref-resolver/pruners/manifest-pruner.ts +130 -0
  90. package/src/services/ref-resolver/pruners/ref-pruner.ts +135 -0
  91. package/src/services/ref-resolver/pruners/rule-helpers.ts +44 -0
  92. package/src/services/ref-resolver/pruners/stored-key-pruner.ts +136 -0
  93. package/src/services/ref-resolver/ref-helpers.ts +62 -0
  94. package/src/services/ref-resolver/ref-picker-service.ts +204 -0
  95. package/src/{lib/repos/reference-report.ts → services/ref-resolver/ref-report.ts} +2 -40
  96. package/src/services/ref-resolver/ref-resolve-arguments.ts +47 -0
  97. package/src/services/ref-resolver/ref-resolve-result.ts +36 -0
  98. package/src/services/ref-resolver/ref-resolver-service.ts +323 -0
  99. package/src/services/ref-resolver/source.ts +48 -0
  100. package/src/services/ref-resolver/tokens.ts +25 -0
  101. package/src/services/ref-resolver/types.ts +155 -0
  102. package/src/templating/init-liquid-engine.ts +33 -18
  103. package/src/templating/lib/secret-scope.ts +41 -0
  104. package/src/templating/tags/exportScalarVarsJs.ts +5 -2
  105. package/src/templating/tags/showVars.ts +8 -2
  106. package/src/lib/refs/find.ts +0 -770
  107. package/src/lib/refs/index.ts +0 -14
  108. package/src/lib/refs/parse.ts +0 -844
  109. package/src/lib/refs/pick.ts +0 -147
  110. package/src/lib/refs/scopes.ts +0 -95
  111. package/src/lib/refs/settle.ts +0 -219
@@ -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
- * Where the files a subscribed recipe contributes are written, one list of
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
- * Only `skills` has a default (`<project root>/.claude/skills`, the project root
157
- * being the parent of `.sous/`). A kind with no destination is skipped, with one
158
- * warning naming this config key, because sous cannot guess where a project
159
- * wants its memories or its prompts.
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 RecipeOutputs = {
162
- /** Where recipe skill bundles are written. */
163
- skills?: string[];
164
- /** Where recipe memory files are written. */
165
- memories?: string[];
166
- /** Where recipe prompt files are written. */
167
- prompts?: string[];
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
- * Where the files subscribed recipes contribute are written, per content kind.
210
+ * What the project does with each content kind its subscribed recipes publish.
208
211
  */
209
- recipeOutputs?: RecipeOutputs;
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, always available and reserved (their names begin
824
- * with `~` so user `_aliases` can never shadow them). Add new entries here as
825
- * needed; keep names kebab-case.
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
- * - `~project` → the consuming project's root (`projectRoot`).
831
+ * - `#project` is the consuming project's root (`projectRoot`).
828
832
  *
829
- * There is exactly one, on purpose. Files that used to be reached through a
830
- * built-in alias pointing inside the sous package are published as recipes now,
831
- * and a recipe's files are addressed by its namespace (`@~workflow/task-files/
832
- * _partials/resume-task.md`), resolved against what the project has pinned. A
833
- * `~namespace` reference is NOT an alias: it is resolved separately, after the
834
- * alias map has been tried; see `locked-namespace-resolver.ts`.
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
- const builtIns: AliasMap = {};
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 rejected (reserved).
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: warning,
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 (`~project/skills/**`) expands to one candidate pattern per 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`);
@@ -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);
@@ -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 { variableReferenceKey } from "../refs/find.js";
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/lib/refs/` passes the fully qualified form, which is
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 = variableReferenceKey(defined);
185
+ const qualified = refKey(variableRefOf(defined));
186
186
  return only.some(
187
187
  (name) => name === defined.definition.name || name === key || name === qualified
188
188
  );
@@ -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)";
@@ -17,3 +17,4 @@ export * from "./ask.js";
17
17
  export * from "./report.js";
18
18
  export * from "./preanswers.js";
19
19
  export * from "./question-plan.js";
20
+ export * from "./secrets.js";
@@ -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 { RefSource } from "../refs/scopes.js";
31
- import { formatRef, parseShortRef, type ParsedRef } from "../refs/parse.js";
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: ParsedRef = { namespace: "" };
94
+ let ref: NamedRef | undefined;
89
95
  try {
90
- ref = parseShortRef(trimmed.slice(0, Math.max(slash, 0)), RefSource.CommandLine);
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
- const { repo, namespace, recipe } = ref;
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
- if (formatRef(ref) !== formatRef(ref).toLowerCase()) {
99
- fail(`the repository, namespace and recipe in '${formatRef(ref)}' must be lowercase.`);
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: recipe!, variable };
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
+ }