@sous-io/sous 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +121 -35
- package/bin/run.js +10 -1
- package/docs/markdown/README.md +27 -0
- package/docs/markdown/_sidebar.md +18 -0
- package/docs/markdown/commands.md +308 -0
- package/docs/markdown/config-discovery.md +74 -0
- package/docs/markdown/config-inspection.md +69 -0
- package/docs/markdown/config-layers.md +92 -0
- package/docs/markdown/config-variables.md +79 -0
- package/docs/markdown/configuration.md +71 -0
- package/docs/markdown/design-principles.md +59 -0
- package/docs/markdown/repositories-authoring.md +408 -0
- package/docs/markdown/repositories-consuming.md +580 -0
- package/docs/markdown/repositories-file-formats.md +1084 -0
- package/docs/markdown/repositories-variables.md +387 -0
- package/docs/markdown/repositories.md +303 -0
- package/docs/markdown/skill-categories.md +58 -0
- package/package.json +73 -9
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/SKILL.tpl.md +20 -20
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/about-something.md +2 -2
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/do-something.md +1 -1
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/advanced-patterns.md +6 -6
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/commands.md +5 -5
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/frontmatter.md +3 -3
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/SKILL.tpl.md +40 -25
- package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +70 -0
- package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +75 -0
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/create-skill/SKILL.tpl.md +8 -9
- package/recipes/core/sous-skills/sous.recipe.yaml +45 -0
- package/sous.config.schema.json +337 -0
- package/src/base-command.ts +220 -67
- package/src/commands/build.ts +150 -73
- package/src/commands/clear.ts +23 -15
- package/src/commands/compile.ts +74 -16
- package/src/commands/config/get.ts +110 -0
- package/src/commands/config/show.ts +32 -0
- package/src/commands/config/validate.ts +53 -0
- package/src/commands/help.ts +46 -0
- package/src/commands/launch.ts +36 -14
- package/src/commands/lock/rebuild.ts +241 -0
- package/src/commands/lock/show.ts +115 -0
- package/src/commands/namespace/list.ts +117 -0
- package/src/commands/namespace/show.ts +110 -0
- package/src/commands/prune.ts +3 -11
- package/src/commands/recipe/list.ts +95 -0
- package/src/commands/recipe/show.ts +301 -0
- package/src/commands/repo/add.ts +145 -0
- package/src/commands/repo/gc.ts +172 -0
- package/src/commands/repo/init.ts +136 -0
- package/src/commands/repo/link.ts +500 -0
- package/src/commands/repo/list.ts +179 -0
- package/src/commands/repo/release.ts +619 -0
- package/src/commands/repo/remove.ts +193 -0
- package/src/commands/repo/search.ts +189 -0
- package/src/commands/repo/submit.ts +133 -0
- package/src/commands/repo/unlink.ts +147 -0
- package/src/commands/subscription/add.ts +285 -0
- package/src/commands/subscription/list.ts +129 -0
- package/src/commands/subscription/remove.ts +181 -0
- package/src/commands/vars/ask.ts +374 -0
- package/src/commands/vars/index.ts +79 -0
- package/src/commands/vars/list.ts +67 -0
- package/src/commands/vars/show.ts +77 -0
- package/src/config-command.ts +30 -0
- package/src/lib/build-service.ts +206 -54
- package/src/lib/config-discovery.ts +220 -27
- package/src/lib/config-inspect.ts +145 -0
- package/src/lib/config-kernel.mjs +377 -0
- package/src/lib/config-schema.ts +361 -0
- package/src/lib/env-file.ts +328 -0
- package/src/lib/env-local.ts +18 -1
- package/src/lib/errors.ts +32 -0
- package/src/lib/include-resolver.ts +108 -15
- package/src/lib/interactive.ts +165 -0
- package/src/lib/markdown-compiler.ts +118 -37
- package/src/lib/package-info.ts +25 -0
- package/src/lib/pid-service.ts +32 -21
- package/src/lib/refs/find.ts +589 -0
- package/src/lib/refs/index.ts +12 -0
- package/src/lib/refs/pick.ts +147 -0
- package/src/lib/refs/scopes.ts +61 -0
- package/src/lib/repos/catalog-display.ts +116 -0
- package/src/lib/repos/catalog-inputs.ts +160 -0
- package/src/lib/repos/catalog.ts +722 -0
- package/src/lib/repos/core-recipe.ts +105 -0
- package/src/lib/repos/defaults.ts +175 -0
- package/src/lib/repos/formats/common.ts +389 -0
- package/src/lib/repos/formats/index-file.ts +215 -0
- package/src/lib/repos/formats/links-map.ts +96 -0
- package/src/lib/repos/formats/lockfile.ts +167 -0
- package/src/lib/repos/formats/patterns.ts +57 -0
- package/src/lib/repos/formats/recipe-manifest.ts +395 -0
- package/src/lib/repos/formats/repo-manifest.ts +88 -0
- package/src/lib/repos/formats/store-entry.ts +84 -0
- package/src/lib/repos/freshness.ts +208 -0
- package/src/lib/repos/git-clone.ts +312 -0
- package/src/lib/repos/identity.ts +89 -0
- package/src/lib/repos/index.ts +58 -0
- package/src/lib/repos/links.ts +353 -0
- package/src/lib/repos/load-manifest.ts +236 -0
- package/src/lib/repos/lock-service.ts +453 -0
- package/src/lib/repos/locked-namespace-resolver.ts +90 -0
- package/src/lib/repos/locked-recipes.ts +254 -0
- package/src/lib/repos/managed-layer.ts +422 -0
- package/src/lib/repos/namespace-resolver.ts +370 -0
- package/src/lib/repos/providers/base.ts +206 -0
- package/src/lib/repos/providers/git.ts +233 -0
- package/src/lib/repos/providers/github.ts +294 -0
- package/src/lib/repos/providers/gitlab.ts +263 -0
- package/src/lib/repos/providers/http.ts +102 -0
- package/src/lib/repos/providers/index-cache.ts +382 -0
- package/src/lib/repos/providers/index.ts +106 -0
- package/src/lib/repos/providers/local.ts +391 -0
- package/src/lib/repos/providers/provider.ts +401 -0
- package/src/lib/repos/recipe-config-layers.ts +287 -0
- package/src/lib/repos/recipe-targets.ts +223 -0
- package/src/lib/repos/ref-search.ts +46 -0
- package/src/lib/repos/ref.ts +513 -0
- package/src/lib/repos/reference-report.ts +122 -0
- package/src/lib/repos/release/bump.ts +161 -0
- package/src/lib/repos/release/git-state.ts +305 -0
- package/src/lib/repos/release/index-builder.ts +635 -0
- package/src/lib/repos/release/index.ts +16 -0
- package/src/lib/repos/release/plan.ts +512 -0
- package/src/lib/repos/release/submit-service.ts +496 -0
- package/src/lib/repos/release/tags.ts +243 -0
- package/src/lib/repos/release/validate.ts +463 -0
- package/src/lib/repos/resolver.ts +789 -0
- package/src/lib/repos/scaffold/index.ts +238 -0
- package/src/lib/repos/scaffold/templates.ts +413 -0
- package/src/lib/repos/seed.ts +414 -0
- package/src/lib/repos/store/contract.ts +64 -0
- package/src/lib/repos/store/hash.ts +114 -0
- package/src/lib/repos/store/recipe-store.ts +599 -0
- package/src/lib/repos/store/settings.ts +58 -0
- package/src/lib/repos/subscription-service.ts +2678 -0
- package/src/lib/repos/trust.ts +447 -0
- package/src/lib/settings.ts +546 -189
- package/src/lib/sous-home.ts +104 -0
- package/src/lib/state.ts +52 -20
- package/src/lib/vars/ask.ts +1152 -0
- package/src/lib/vars/definition-source.ts +252 -0
- package/src/lib/vars/display.ts +233 -0
- package/src/lib/vars/index.ts +18 -0
- package/src/lib/vars/ladder.ts +282 -0
- package/src/lib/vars/mappings.ts +265 -0
- package/src/lib/vars/names.ts +94 -0
- package/src/lib/vars/preanswers.ts +395 -0
- package/src/lib/vars/question-plan.ts +218 -0
- package/src/lib/vars/report.ts +228 -0
- package/src/lib/vars/safe-regex.ts +235 -0
- package/src/lib/vars/validate.ts +312 -0
- package/src/lib/watch-loop.ts +148 -0
- package/src/templating/init-liquid-engine.ts +58 -16
- package/src/utils/choice-prompt.ts +143 -0
- package/src/utils/command-errors.ts +186 -0
- package/src/utils/command-help.ts +45 -0
- package/src/utils/confirm-prompt.ts +110 -0
- package/src/utils/flags.ts +153 -0
- package/src/utils/formatting.ts +540 -55
- package/src/utils/prompts.ts +35 -1
- package/src/utils/sous-directory.ts +245 -0
- package/src/utils/table.ts +603 -0
- package/src/utils/value-prompt.ts +119 -0
- package/bin/xcv +0 -5
- package/shared-prompts/_partials/resume-task.md +0 -51
- package/shared-prompts/_partials/sub-agent-delegation.md +0 -32
- package/shared-prompts/_partials/update-task-file.md +0 -52
- package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +0 -52
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +0 -102
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +0 -81
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +0 -126
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +0 -92
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +0 -61
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +0 -65
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +0 -96
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +0 -104
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +0 -243
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +0 -148
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +0 -383
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +0 -267
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +0 -56
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +0 -169
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +0 -59
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +0 -25
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +0 -140
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +0 -140
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +0 -1
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +0 -185
- package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +0 -52
- package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +0 -59
- package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +0 -47
- package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +0 -26
- package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +0 -58
- package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +0 -27
- package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +0 -34
- package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +0 -51
- package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +0 -122
- package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +0 -80
- package/shared-prompts/skills/task-files/go/SKILL.tpl.md +0 -14
- package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +0 -13
- package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +0 -93
- package/shared-prompts/skills/task-files/update/SKILL.tpl.md +0 -14
- package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +0 -13
- /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/substitutions.md +0 -0
- /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/references/liquid-filters.md +0 -0
|
@@ -0,0 +1,2678 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The subscription service: the workflow the consumer commands drive.
|
|
3
|
+
*
|
|
4
|
+
* Everything underneath it is a single-purpose part (a provider, the index
|
|
5
|
+
* cache, the resolver, the trust layer, the store, the lockfile). This is the
|
|
6
|
+
* one place that puts them in the right order, and the order is the design:
|
|
7
|
+
*
|
|
8
|
+
* - Nothing is fetched from a repository before it is trusted, not even its
|
|
9
|
+
* index. `addRepo` runs the trust ceremony first and fetches second.
|
|
10
|
+
* - Resolution is iterative. Each round may turn up repositories a dependency
|
|
11
|
+
* needs that the project has not added; those go through one consolidated
|
|
12
|
+
* trust question and the round runs again.
|
|
13
|
+
* - A subscription is not finished until its questions are answered, so
|
|
14
|
+
* `subscribe` ends by asking for the variables its recipes publish.
|
|
15
|
+
* - Restoring never decides anything: it fetches exactly what the lockfile
|
|
16
|
+
* pins, which is what makes a fresh clone reproducible and prompt-free.
|
|
17
|
+
*
|
|
18
|
+
* Every collaborator is injectable, so a test can drive the whole workflow
|
|
19
|
+
* against a local fixture repository without a network.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fsp from "node:fs/promises";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import semver from "semver";
|
|
25
|
+
import { ConfigError, isConfigError } from "../errors.js";
|
|
26
|
+
import { SOUS_VERSION, type ConfigContext, type Settings, type VarScope } from "../settings.js";
|
|
27
|
+
import { CONFD_DIR_NAME } from "../config-discovery.js";
|
|
28
|
+
import {
|
|
29
|
+
BULLET,
|
|
30
|
+
indent,
|
|
31
|
+
log,
|
|
32
|
+
palette,
|
|
33
|
+
warning,
|
|
34
|
+
wrapColumns,
|
|
35
|
+
wrapText,
|
|
36
|
+
} from "../../utils/formatting.js";
|
|
37
|
+
import { askChoice, askYesNo } from "../../utils/prompts.js";
|
|
38
|
+
import { ensureStoreRootDirectory } from "../../utils/sous-directory.js";
|
|
39
|
+
import { isInteractive, nonInteractiveError } from "../interactive.js";
|
|
40
|
+
import {
|
|
41
|
+
applyProvidedAnswers,
|
|
42
|
+
askForMissing,
|
|
43
|
+
loadLadderContext,
|
|
44
|
+
planQuestions,
|
|
45
|
+
validateProvidedAnswers,
|
|
46
|
+
type AskReport,
|
|
47
|
+
type DefinedVariable,
|
|
48
|
+
type DefiningRecipe,
|
|
49
|
+
type LadderContext,
|
|
50
|
+
type PlannedVariable,
|
|
51
|
+
type ProvidedAnswer,
|
|
52
|
+
} from "../vars/index.js";
|
|
53
|
+
import type { IndexFile } from "./formats/index-file.js";
|
|
54
|
+
import {
|
|
55
|
+
PROJECT_HOLDER,
|
|
56
|
+
type LockedRecipe,
|
|
57
|
+
type Lockfile,
|
|
58
|
+
} from "./formats/lockfile.js";
|
|
59
|
+
import type { RecipeManifest } from "./formats/recipe-manifest.js";
|
|
60
|
+
import { formatRef, parseRef, refKey, type ParsedRef } from "./ref.js";
|
|
61
|
+
import {
|
|
62
|
+
SousScope,
|
|
63
|
+
describeReference,
|
|
64
|
+
findReference,
|
|
65
|
+
pickReference,
|
|
66
|
+
referenceReposFromIndexes,
|
|
67
|
+
referenceToRef,
|
|
68
|
+
type ReferenceMatch,
|
|
69
|
+
} from "../refs/index.js";
|
|
70
|
+
import { describeIndexSearch } from "./ref-search.js";
|
|
71
|
+
import {
|
|
72
|
+
PROJECT_REQUESTER,
|
|
73
|
+
resolveRefs,
|
|
74
|
+
type RefRequest,
|
|
75
|
+
type ResolvedRecipe,
|
|
76
|
+
type ResolverRepo,
|
|
77
|
+
} from "./resolver.js";
|
|
78
|
+
import {
|
|
79
|
+
LockService,
|
|
80
|
+
type LockDiff,
|
|
81
|
+
type LockRepoInput,
|
|
82
|
+
type RestoreReport,
|
|
83
|
+
} from "./lock-service.js";
|
|
84
|
+
import { TrustService, USER_ADDED_BY, type TrustedRepo } from "./trust.js";
|
|
85
|
+
import {
|
|
86
|
+
SUBSCRIPTIONS_LAYER_FILENAME,
|
|
87
|
+
readManagedLayer,
|
|
88
|
+
removeManagedLayer,
|
|
89
|
+
updateManagedLayer,
|
|
90
|
+
} from "./managed-layer.js";
|
|
91
|
+
import {
|
|
92
|
+
builtInProviders,
|
|
93
|
+
createIndexCache,
|
|
94
|
+
requireProvider,
|
|
95
|
+
type IndexCache,
|
|
96
|
+
type ProviderOptions,
|
|
97
|
+
type ProviderId,
|
|
98
|
+
type RepoProvider,
|
|
99
|
+
} from "./providers/index.js";
|
|
100
|
+
import { normalizeRepoUrl } from "./providers/provider.js";
|
|
101
|
+
import {
|
|
102
|
+
assertLocalRepoDirectory,
|
|
103
|
+
looksLikeLocalPath,
|
|
104
|
+
resolveRepoArgument,
|
|
105
|
+
} from "./providers/local.js";
|
|
106
|
+
import { RecipeStore } from "./store/recipe-store.js";
|
|
107
|
+
import type { RecipeStoreLike, StoreKey } from "./store/contract.js";
|
|
108
|
+
import { resolveStoreSettings } from "./store/settings.js";
|
|
109
|
+
import {
|
|
110
|
+
effectiveRangeForHolders,
|
|
111
|
+
findNewerInRange,
|
|
112
|
+
recordUpstreamCheck,
|
|
113
|
+
shouldCheckUpstream,
|
|
114
|
+
} from "./freshness.js";
|
|
115
|
+
import { REPO_NAME_PATTERN } from "./formats/patterns.js";
|
|
116
|
+
import {
|
|
117
|
+
linkedPathFor,
|
|
118
|
+
readGlobalLinks,
|
|
119
|
+
readProjectLinks,
|
|
120
|
+
writeProjectLinks,
|
|
121
|
+
} from "./links.js";
|
|
122
|
+
import {
|
|
123
|
+
keysHeldBySubscription,
|
|
124
|
+
listLockedRecipes,
|
|
125
|
+
mapLinkedRecipes,
|
|
126
|
+
readRecipeManifestIn,
|
|
127
|
+
} from "./locked-recipes.js";
|
|
128
|
+
import { resolveStoreRoot } from "../sous-home.js";
|
|
129
|
+
import { seedCoreRecipe, type SeedCoreRecipeReport } from "./seed.js";
|
|
130
|
+
import { enabledRepos, enabledSubscriptions, isBuiltInEntry } from "./defaults.js";
|
|
131
|
+
import {
|
|
132
|
+
CORE_RECIPE_KEY,
|
|
133
|
+
OFFICIAL_REPO_IDENTITY,
|
|
134
|
+
packagedCoreRecipeDir,
|
|
135
|
+
} from "./core-recipe.js";
|
|
136
|
+
import { repoIdentity } from "./identity.js";
|
|
137
|
+
import { buildRecipeTargets } from "./recipe-targets.js";
|
|
138
|
+
import { resolveOutputPath } from "../markdown-compiler.js";
|
|
139
|
+
import { hashDirectory } from "./store/hash.js";
|
|
140
|
+
|
|
141
|
+
// --- Options and reports ------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/** How the subscription service is built. Every collaborator is injectable. */
|
|
144
|
+
export type SubscriptionServiceOptions = {
|
|
145
|
+
/** The project's `.sous/` directory: lockfile, links map and env files. */
|
|
146
|
+
sousDir: string;
|
|
147
|
+
/** The project's `conf.d/` directory. Defaults to `<sousDir>/conf.d`. */
|
|
148
|
+
confDir?: string;
|
|
149
|
+
/** The merged project config. */
|
|
150
|
+
settings: Settings;
|
|
151
|
+
/** The environment to read; decides where the store is. Defaults to `process.env`. */
|
|
152
|
+
env?: NodeJS.ProcessEnv;
|
|
153
|
+
/**
|
|
154
|
+
* The real shell environment, snapshotted before the `.sous/` env files were
|
|
155
|
+
* injected. Used only to tell a shell-supplied answer from a file-supplied one.
|
|
156
|
+
*/
|
|
157
|
+
shellEnv?: NodeJS.ProcessEnv;
|
|
158
|
+
/** Whether sous may ask questions. Defaults to whether both streams are a terminal. */
|
|
159
|
+
interactive?: boolean;
|
|
160
|
+
/** The recipe store. Defaults to the machine-wide one. */
|
|
161
|
+
store?: RecipeStoreLike;
|
|
162
|
+
/** The index cache. Defaults to one rooted at the store. */
|
|
163
|
+
indexCache?: IndexCache;
|
|
164
|
+
/** The trust layer. Defaults to one bound to this project. */
|
|
165
|
+
trust?: TrustService;
|
|
166
|
+
/** The lockfile service. Defaults to one bound to this project. */
|
|
167
|
+
lock?: LockService;
|
|
168
|
+
/** The providers to choose from. Defaults to the built-ins. */
|
|
169
|
+
providers?: RepoProvider[];
|
|
170
|
+
/** Options handed to every provider call. */
|
|
171
|
+
providerOptions?: ProviderOptions;
|
|
172
|
+
/** Where warnings go. Defaults to the console warning banner. */
|
|
173
|
+
warn?: (message: string) => void;
|
|
174
|
+
/** Where the plan and the resolution notices go. Defaults to the console. */
|
|
175
|
+
write?: (message: string) => void;
|
|
176
|
+
/** How a yes or no question is asked. Injected in tests. */
|
|
177
|
+
ask?: (message: string) => Promise<boolean>;
|
|
178
|
+
/** How a choice between candidate refs is asked. Injected in tests. */
|
|
179
|
+
choose?: (
|
|
180
|
+
message: string,
|
|
181
|
+
candidates: ReferenceMatch[]
|
|
182
|
+
) => Promise<ReferenceMatch>;
|
|
183
|
+
/** The clock, so a recorded timestamp is predictable in tests. */
|
|
184
|
+
now?: () => Date;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** What `addRepo` is asked to do. */
|
|
188
|
+
export type AddRepoOptions = {
|
|
189
|
+
/** The repository's URL, or an absolute path for one on this machine. */
|
|
190
|
+
url: string;
|
|
191
|
+
/** The short name refs will use. Defaults to the last segment of the URL. */
|
|
192
|
+
name?: string;
|
|
193
|
+
/** The provider that handles it, when the URL does not give it away. */
|
|
194
|
+
provider?: ProviderId;
|
|
195
|
+
/** Acknowledge trust without being asked, for a run with no terminal. */
|
|
196
|
+
trust?: boolean;
|
|
197
|
+
/** Work out what would happen and report it, writing and fetching nothing. */
|
|
198
|
+
dryRun?: boolean;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/** What `addRepo` did. */
|
|
202
|
+
export type AddRepoOutcome = {
|
|
203
|
+
/** The short name the repository was recorded under. */
|
|
204
|
+
name: string;
|
|
205
|
+
/** Where it lives, as recorded. */
|
|
206
|
+
url: string;
|
|
207
|
+
/** The provider that handles it. */
|
|
208
|
+
provider: ProviderId;
|
|
209
|
+
/** True when the project already trusted this exact repository. */
|
|
210
|
+
alreadyTrusted: boolean;
|
|
211
|
+
/** Every namespace its index declares, sorted. */
|
|
212
|
+
namespaces: string[];
|
|
213
|
+
/** How many recipes its index publishes. */
|
|
214
|
+
recipeCount: number;
|
|
215
|
+
/** True when nothing was written, because this was a dry run. */
|
|
216
|
+
dryRun: boolean;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/** What `subscribe` is asked to do. */
|
|
220
|
+
export type SubscribeOptions = {
|
|
221
|
+
/** The ref to subscribe to: a namespace, or `namespace/recipe`, with an optional range. */
|
|
222
|
+
ref: string;
|
|
223
|
+
/** Let prerelease versions take part in range matching. */
|
|
224
|
+
prerelease?: boolean;
|
|
225
|
+
/** Prefer a newer in-range version over the locked one on every build. */
|
|
226
|
+
alwaysPull?: boolean;
|
|
227
|
+
/** Acknowledge trust for every repository this command adds, without being asked. */
|
|
228
|
+
trust?: boolean;
|
|
229
|
+
/** Accept the subscribe confirmation without being asked. */
|
|
230
|
+
yes?: boolean;
|
|
231
|
+
/** Take the first candidate when a one-word ref matched several things. */
|
|
232
|
+
acceptFirst?: boolean;
|
|
233
|
+
/**
|
|
234
|
+
* Answers supplied ahead of the questions, which are validated and stored
|
|
235
|
+
* before anything is asked. See `lib/vars/preanswers.ts`.
|
|
236
|
+
*/
|
|
237
|
+
answers?: ProvidedAnswer[];
|
|
238
|
+
/** Work out what would happen and report it, writing and fetching nothing. */
|
|
239
|
+
dryRun?: boolean;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/** What `subscribe` did. */
|
|
243
|
+
export type SubscribeOutcome = {
|
|
244
|
+
/** The ref that was installed, fully qualified, in its canonical written form. */
|
|
245
|
+
ref: string;
|
|
246
|
+
/** The ref as it was written, when a one-word ref had to be resolved first. */
|
|
247
|
+
resolvedFrom?: string;
|
|
248
|
+
/** The key the subscription was recorded under. */
|
|
249
|
+
key: string;
|
|
250
|
+
/** Every recipe version the resolution settled on. */
|
|
251
|
+
resolved: ResolvedRecipe[];
|
|
252
|
+
/** Repositories that had to be trusted along the way. */
|
|
253
|
+
trusted: string[];
|
|
254
|
+
/** What changed in the lockfile. */
|
|
255
|
+
diff: LockDiff;
|
|
256
|
+
/** What the variable questions produced, when they were asked. */
|
|
257
|
+
answers?: AskReport;
|
|
258
|
+
/**
|
|
259
|
+
* Every question these recipes would ask, and where each answer would go.
|
|
260
|
+
* Reported by a dry run, which is how a caller with no terminal finds out
|
|
261
|
+
* what to supply with `--answer`.
|
|
262
|
+
*/
|
|
263
|
+
questions?: PlannedVariable[];
|
|
264
|
+
/**
|
|
265
|
+
* Recipes a dry run could not describe, because their files are not on this
|
|
266
|
+
* machine and a dry run downloads nothing. Their questions are unknown until
|
|
267
|
+
* they are installed.
|
|
268
|
+
*/
|
|
269
|
+
unreadable?: string[];
|
|
270
|
+
/** Dependency cycles the resolver noticed, reported rather than treated as fatal. */
|
|
271
|
+
cycles: string[][];
|
|
272
|
+
/** True when nothing was written, because this was a dry run. */
|
|
273
|
+
dryRun: boolean;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/** What `unsubscribe` is asked to do. */
|
|
277
|
+
export type UnsubscribeOptions = {
|
|
278
|
+
/** The ref to unsubscribe from. */
|
|
279
|
+
ref: string;
|
|
280
|
+
/** Work out what would happen and report it, writing nothing. */
|
|
281
|
+
dryRun?: boolean;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
/** What `unsubscribe` did. */
|
|
285
|
+
export type UnsubscribeOutcome = {
|
|
286
|
+
/** The key the subscription was recorded under. */
|
|
287
|
+
key: string;
|
|
288
|
+
/** What changed in the lockfile. */
|
|
289
|
+
diff: LockDiff;
|
|
290
|
+
/** Recipes that stayed because something else still holds them, with who holds them. */
|
|
291
|
+
stayed: Array<{ key: string; heldBy: string[] }>;
|
|
292
|
+
/**
|
|
293
|
+
* True when the subscription was one sous provides itself, so it was switched
|
|
294
|
+
* off with an `enabled: false` entry rather than deleted. The entry sous
|
|
295
|
+
* provides comes back on every run; only a recorded opt-out outlives it.
|
|
296
|
+
*/
|
|
297
|
+
optedOut: boolean;
|
|
298
|
+
/** True when nothing was written, because this was a dry run. */
|
|
299
|
+
dryRun: boolean;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/** What `removeRepo` is asked to do. */
|
|
303
|
+
export type RemoveRepoOptions = {
|
|
304
|
+
/** The repository's short name, as the project records it. */
|
|
305
|
+
name: string;
|
|
306
|
+
/** Accept the removal without being asked. */
|
|
307
|
+
yes?: boolean;
|
|
308
|
+
/** Work out what would happen and report it, writing nothing. */
|
|
309
|
+
dryRun?: boolean;
|
|
310
|
+
/**
|
|
311
|
+
* The resolved settings scope, used to work out which output files the
|
|
312
|
+
* removed recipes wrote. Without it a `recipeOutputs` destination holding a
|
|
313
|
+
* `${var}` cannot be resolved, and the file list is left out of the report.
|
|
314
|
+
*/
|
|
315
|
+
scope?: VarScope;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
/** What `removeRepo` did, or would do on a dry run. */
|
|
319
|
+
export type RemoveRepoOutcome = {
|
|
320
|
+
/** The repository's short name. */
|
|
321
|
+
name: string;
|
|
322
|
+
/** Where it lives, as the entry recorded it. */
|
|
323
|
+
url: string;
|
|
324
|
+
/**
|
|
325
|
+
* True when the repository is one sous provides itself, so it was switched
|
|
326
|
+
* off with an `enabled: false` entry rather than deleted. The entry sous
|
|
327
|
+
* provides comes back on every run; only a recorded opt-out outlives it.
|
|
328
|
+
*/
|
|
329
|
+
optedOut: boolean;
|
|
330
|
+
/** The subscriptions that were removed because they resolve into it. */
|
|
331
|
+
subscriptions: string[];
|
|
332
|
+
/**
|
|
333
|
+
* Subscriptions that resolve into it but are written in the project's own
|
|
334
|
+
* config, which sous never edits. They stay, and are named so the person
|
|
335
|
+
* removing the repository knows to deal with them.
|
|
336
|
+
*/
|
|
337
|
+
keptSubscriptions: string[];
|
|
338
|
+
/** The locked recipes that were released, because nothing else held them. */
|
|
339
|
+
removedRecipes: string[];
|
|
340
|
+
/** Recipes that stayed because something else still holds them, with who holds them. */
|
|
341
|
+
stayed: Array<{ key: string; heldBy: string[] }>;
|
|
342
|
+
/** The output files the removed recipes compiled, which the next build prunes. */
|
|
343
|
+
outputs: string[];
|
|
344
|
+
/** What changed in the lockfile. */
|
|
345
|
+
diff: LockDiff;
|
|
346
|
+
/** The linked checkout that pointed at the repository, when there was one. */
|
|
347
|
+
linkedPath?: string;
|
|
348
|
+
/**
|
|
349
|
+
* True when that link is the machine-wide one, which other projects on this
|
|
350
|
+
* machine share, so it was left exactly as it was.
|
|
351
|
+
*/
|
|
352
|
+
linkIsGlobal: boolean;
|
|
353
|
+
/** True when nothing was written, because this was a dry run. */
|
|
354
|
+
dryRun: boolean;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
/** One row of the subscription listing: what a project subscribes to, and why. */
|
|
358
|
+
export type SubscriptionListing = {
|
|
359
|
+
/** The ref key: a bare namespace, or `namespace/recipe`. */
|
|
360
|
+
key: string;
|
|
361
|
+
/** The version range the subscription resolves within, when one was written. */
|
|
362
|
+
range: string | undefined;
|
|
363
|
+
/** False when the entry is switched off with `enabled: false`. */
|
|
364
|
+
enabled: boolean;
|
|
365
|
+
/** What the entry recorded about who wanted it, when it recorded anything. */
|
|
366
|
+
addedBy: string | undefined;
|
|
367
|
+
/** Every recipe the lockfile pins because of this subscription, sorted by key. */
|
|
368
|
+
pinned: Array<{ key: string; version: string }>;
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
/** What bringing the lockfile in line with the declared subscriptions produced. */
|
|
372
|
+
export type SubscriptionSyncReport = {
|
|
373
|
+
/** The subscriptions that had to be resolved, by ref key. */
|
|
374
|
+
resolved: string[];
|
|
375
|
+
/** Recipes the lockfile did not pin before and pins now. */
|
|
376
|
+
added: Array<{ key: string; version: string }>;
|
|
377
|
+
/** Recipes whose pinned version moved to satisfy a subscription's range. */
|
|
378
|
+
moved: Array<{ key: string; from: string; to: string }>;
|
|
379
|
+
/** Subscriptions that could not be resolved, each with a plain-language reason. */
|
|
380
|
+
failed: Array<{ key: string; reason: string }>;
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
/** What an upstream check found. */
|
|
384
|
+
export type UpstreamCheckReport = {
|
|
385
|
+
/** Repositories that were actually asked. */
|
|
386
|
+
checked: string[];
|
|
387
|
+
/** Recipes moved to a newer in-range version. */
|
|
388
|
+
updated: Array<{ key: string; from: string; to: string }>;
|
|
389
|
+
/** Repositories whose check failed; the last good answer still stands. */
|
|
390
|
+
failed: Array<{ repo: string; reason: string }>;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
// --- The service --------------------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
/** Adds repositories, subscribes to recipes, and keeps the store and lockfile honest. */
|
|
396
|
+
export class SubscriptionService {
|
|
397
|
+
private readonly sousDir: string;
|
|
398
|
+
|
|
399
|
+
private readonly confDir: string;
|
|
400
|
+
|
|
401
|
+
private readonly settings: Settings;
|
|
402
|
+
|
|
403
|
+
private readonly env: NodeJS.ProcessEnv;
|
|
404
|
+
|
|
405
|
+
private readonly shellEnv: NodeJS.ProcessEnv;
|
|
406
|
+
|
|
407
|
+
private readonly interactive: boolean;
|
|
408
|
+
|
|
409
|
+
private readonly providers: RepoProvider[];
|
|
410
|
+
|
|
411
|
+
private readonly providerOptions: ProviderOptions;
|
|
412
|
+
|
|
413
|
+
private readonly warn: (message: string) => void;
|
|
414
|
+
|
|
415
|
+
private readonly write: (message: string) => void;
|
|
416
|
+
|
|
417
|
+
private readonly ask: (message: string) => Promise<boolean>;
|
|
418
|
+
|
|
419
|
+
private readonly choose: (
|
|
420
|
+
message: string,
|
|
421
|
+
candidates: ReferenceMatch[]
|
|
422
|
+
) => Promise<ReferenceMatch>;
|
|
423
|
+
|
|
424
|
+
private readonly now: () => Date;
|
|
425
|
+
|
|
426
|
+
private readonly storeInstance: RecipeStoreLike;
|
|
427
|
+
|
|
428
|
+
private readonly indexCache: IndexCache;
|
|
429
|
+
|
|
430
|
+
private readonly trust: TrustService;
|
|
431
|
+
|
|
432
|
+
private readonly lock: LockService;
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Canonical identities already worked out, keyed by the provider and URL they
|
|
436
|
+
* came from. Canonicalizing is pure, so it is worth doing once per run.
|
|
437
|
+
*/
|
|
438
|
+
private readonly identityCache = new Map<string, string>();
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* What seeding the packaged core recipe did, once it has been done. Seeding is
|
|
442
|
+
* idempotent but not free (it verifies the store entry against its content
|
|
443
|
+
* hash), so one service instance does it at most once.
|
|
444
|
+
*/
|
|
445
|
+
private seedReport: SeedCoreRecipeReport | undefined;
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Where each locked recipe's files are, keyed by recipe key, once it has been
|
|
449
|
+
* looked up. Deriving the range a `depends`-held recipe may move within asks
|
|
450
|
+
* for this once per lockfile entry, and the answer does not change during a
|
|
451
|
+
* command.
|
|
452
|
+
*/
|
|
453
|
+
private lockedDirectories: Record<string, string> | undefined;
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Every trusted repository's index, once it has been loaded. Working out what
|
|
457
|
+
* a one-word ref meant and describing what a subscription will do both read
|
|
458
|
+
* it, within one command, and an index does not change mid-command.
|
|
459
|
+
*/
|
|
460
|
+
private indexesSnapshot: Map<string, IndexFile> | undefined;
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* @param options - The project's directories, its config, and any collaborator to override.
|
|
464
|
+
*/
|
|
465
|
+
constructor(options: SubscriptionServiceOptions) {
|
|
466
|
+
this.sousDir = options.sousDir;
|
|
467
|
+
this.confDir = options.confDir ?? path.join(options.sousDir, CONFD_DIR_NAME);
|
|
468
|
+
this.settings = options.settings;
|
|
469
|
+
this.env = options.env ?? process.env;
|
|
470
|
+
this.shellEnv = options.shellEnv ?? this.env;
|
|
471
|
+
this.interactive = options.interactive ?? isInteractive();
|
|
472
|
+
this.providers = options.providers ?? builtInProviders();
|
|
473
|
+
this.providerOptions = options.providerOptions ?? {};
|
|
474
|
+
this.warn = options.warn ?? warning;
|
|
475
|
+
this.write = options.write ?? ((message: string) => log(message));
|
|
476
|
+
this.ask = options.ask ?? ((message: string) => askYesNo(message));
|
|
477
|
+
this.choose =
|
|
478
|
+
options.choose ??
|
|
479
|
+
((message, candidates) =>
|
|
480
|
+
askChoice(
|
|
481
|
+
message,
|
|
482
|
+
candidates.map((candidate) => ({
|
|
483
|
+
name: describeReference(candidate),
|
|
484
|
+
value: candidate,
|
|
485
|
+
}))
|
|
486
|
+
));
|
|
487
|
+
this.now = options.now ?? (() => new Date());
|
|
488
|
+
|
|
489
|
+
this.storeInstance =
|
|
490
|
+
options.store ??
|
|
491
|
+
new RecipeStore({ root: resolveStoreRoot(this.env), onWarning: this.warn });
|
|
492
|
+
this.indexCache =
|
|
493
|
+
options.indexCache ??
|
|
494
|
+
createIndexCache({
|
|
495
|
+
storeRoot: this.storeInstance.root,
|
|
496
|
+
resolveProvider: (url, providerId) =>
|
|
497
|
+
requireProvider(url, providerId, this.providers),
|
|
498
|
+
providerOptions: this.providerOptions,
|
|
499
|
+
warn: this.warn,
|
|
500
|
+
now: this.now,
|
|
501
|
+
});
|
|
502
|
+
this.trust =
|
|
503
|
+
options.trust ??
|
|
504
|
+
new TrustService({
|
|
505
|
+
sousDir: this.sousDir,
|
|
506
|
+
confDir: this.confDir,
|
|
507
|
+
settings: this.settings,
|
|
508
|
+
interactive: this.interactive,
|
|
509
|
+
// The trust question is one of this service's questions, so it is asked
|
|
510
|
+
// and printed through the same seams as the rest of them.
|
|
511
|
+
ask: this.ask,
|
|
512
|
+
write: this.write,
|
|
513
|
+
now: this.now,
|
|
514
|
+
});
|
|
515
|
+
this.lock = options.lock ?? new LockService(this.sousDir);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** The recipe store this service fills and reads. */
|
|
519
|
+
get store(): RecipeStoreLike {
|
|
520
|
+
return this.storeInstance;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** The lockfile service this project uses. */
|
|
524
|
+
get lockService(): LockService {
|
|
525
|
+
return this.lock;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** The index cache, for a command that wants to read a cached index without fetching. */
|
|
529
|
+
get indexes(): IndexCache {
|
|
530
|
+
return this.indexCache;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The cached index of one added repository, or undefined when nothing has
|
|
535
|
+
* been fetched from it yet. Nothing is downloaded. Callers name the
|
|
536
|
+
* repository the way the project does, by its short name; the cache itself is
|
|
537
|
+
* keyed by identity, and this is what translates between the two.
|
|
538
|
+
*
|
|
539
|
+
* @param name - The repository's short name.
|
|
540
|
+
*/
|
|
541
|
+
cachedIndex(name: string): IndexFile | undefined {
|
|
542
|
+
const identity = this.identityForRepo(name);
|
|
543
|
+
return identity === undefined ? undefined : this.indexCache.readCached(identity);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// --- Repository identity ----------------------------------------------------------------------
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* The canonical identity of a repository at a URL: what the machine-wide
|
|
550
|
+
* store and the index cache file it under. Canonicalizing runs a provider's
|
|
551
|
+
* URL parser, so the answers are remembered for the life of the service.
|
|
552
|
+
*
|
|
553
|
+
* @param url - Where the repository lives.
|
|
554
|
+
* @param providerId - The provider the repository entry names, when it names one.
|
|
555
|
+
*/
|
|
556
|
+
private identityOf(url: string, providerId?: string): string {
|
|
557
|
+
const cacheKey = `${providerId ?? ""}|${url}`;
|
|
558
|
+
const known = this.identityCache.get(cacheKey);
|
|
559
|
+
if (known !== undefined) return known;
|
|
560
|
+
|
|
561
|
+
const provider = requireProvider(url, providerId, this.providers);
|
|
562
|
+
const identity = repoIdentity(provider.canonicalize(url));
|
|
563
|
+
this.identityCache.set(cacheKey, identity);
|
|
564
|
+
return identity;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* The canonical identity of an added repository, by the short name this
|
|
569
|
+
* project calls it. The lockfile is consulted second, so a repository that
|
|
570
|
+
* has been removed from the config can still be located while its recipes are
|
|
571
|
+
* being cleaned up.
|
|
572
|
+
*
|
|
573
|
+
* @param name - The repository's short name.
|
|
574
|
+
* @param lock - The lockfile, when the caller has already read it.
|
|
575
|
+
*/
|
|
576
|
+
identityForRepo(name: string, lock?: Lockfile): string | undefined {
|
|
577
|
+
const entry = this.currentRepos()[name];
|
|
578
|
+
if (entry?.url !== undefined) return this.identityOf(entry.url, entry.provider);
|
|
579
|
+
|
|
580
|
+
const locked = (lock ?? this.lock.read()).repos[name];
|
|
581
|
+
if (locked === undefined) return undefined;
|
|
582
|
+
return locked.identity;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// --- Adding a repository ----------------------------------------------------------------------
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Adds a repository, which is the same thing as trusting it, and then fetches
|
|
589
|
+
* exactly one file from it: its index. Nothing is downloaded before the trust
|
|
590
|
+
* question is answered.
|
|
591
|
+
*
|
|
592
|
+
* A path is normalized before anything else happens: `~` is expanded and a
|
|
593
|
+
* relative path is resolved against the working directory, so what gets
|
|
594
|
+
* stored is always absolute (a repository on this machine is machine-specific
|
|
595
|
+
* whichever way it was typed). A path that is not a repository is reported as
|
|
596
|
+
* a path mistake, naming what was typed and where sous looked, rather than as
|
|
597
|
+
* a provider that could not be found.
|
|
598
|
+
*
|
|
599
|
+
* @param options - The URL, an optional short name and provider, and the trust flag.
|
|
600
|
+
*/
|
|
601
|
+
async addRepo(options: AddRepoOptions): Promise<AddRepoOutcome> {
|
|
602
|
+
const typed = options.url.trim();
|
|
603
|
+
const url = resolveRepoArgument(typed);
|
|
604
|
+
if (looksLikeLocalPath(typed)) assertLocalRepoDirectory(typed, url);
|
|
605
|
+
const provider = requireProvider(url, options.provider, this.providers);
|
|
606
|
+
const canonical = provider.canonicalize(url);
|
|
607
|
+
const name = options.name ?? canonical.name;
|
|
608
|
+
|
|
609
|
+
if (!REPO_NAME_PATTERN.test(name)) {
|
|
610
|
+
throw new ConfigError(
|
|
611
|
+
`'${name}' is not a usable short name for a repository.\n` +
|
|
612
|
+
` A short name is lowercase kebab-case: a letter, then letters, digits or ` +
|
|
613
|
+
`hyphens. It is what refs use as the 'repo:' qualifier.\n` +
|
|
614
|
+
` Choose one with '--name', for example ` +
|
|
615
|
+
`'sous repo add ${url} --name my-recipes'.`
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const existing = this.currentRepos()[name];
|
|
620
|
+
const alreadyTrusted =
|
|
621
|
+
existing !== undefined && normalizeRepoUrl(existing.url) === normalizeRepoUrl(url);
|
|
622
|
+
|
|
623
|
+
if (existing !== undefined && !alreadyTrusted) {
|
|
624
|
+
throw new ConfigError(
|
|
625
|
+
`This project already has a repository called '${name}', and it is a different one.\n` +
|
|
626
|
+
` Already added: ${existing.url}\n` +
|
|
627
|
+
` Being added: ${url}\n` +
|
|
628
|
+
` Give this one a name of its own with '--name', for example ` +
|
|
629
|
+
`'sous repo add ${url} --name ${name}-2'.`
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (options.dryRun === true) {
|
|
634
|
+
return {
|
|
635
|
+
name,
|
|
636
|
+
url,
|
|
637
|
+
provider: provider.id,
|
|
638
|
+
alreadyTrusted,
|
|
639
|
+
namespaces: [],
|
|
640
|
+
recipeCount: 0,
|
|
641
|
+
dryRun: true,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Trust first, fetch second. This is the last gate before a repository's
|
|
646
|
+
// recipes can put files (and scripts) on this machine.
|
|
647
|
+
if (!alreadyTrusted) {
|
|
648
|
+
await this.trust.confirmTrust(
|
|
649
|
+
[
|
|
650
|
+
{
|
|
651
|
+
name,
|
|
652
|
+
url,
|
|
653
|
+
requiredBy: [{ ref: name, requestedBy: PROJECT_REQUESTER }],
|
|
654
|
+
},
|
|
655
|
+
],
|
|
656
|
+
{
|
|
657
|
+
interactive: this.interactive,
|
|
658
|
+
...(options.trust === undefined ? {} : { trustFlag: options.trust }),
|
|
659
|
+
}
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Written again even when confirmTrust already wrote it, so the entry records
|
|
664
|
+
// that a person added this repository deliberately rather than a dependency
|
|
665
|
+
// having dragged it in.
|
|
666
|
+
this.trust.addRepo({
|
|
667
|
+
name,
|
|
668
|
+
url,
|
|
669
|
+
...(options.provider === undefined ? {} : { provider: options.provider }),
|
|
670
|
+
addedBy: USER_ADDED_BY,
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
const lookup = await this.indexCache.getIndex(this.identityOf(url, provider.id), {
|
|
674
|
+
url,
|
|
675
|
+
label: name,
|
|
676
|
+
...(options.provider === undefined ? {} : { provider: options.provider }),
|
|
677
|
+
force: true,
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
return {
|
|
681
|
+
name,
|
|
682
|
+
url,
|
|
683
|
+
provider: provider.id,
|
|
684
|
+
alreadyTrusted,
|
|
685
|
+
namespaces: Object.keys(lookup.index.namespaces).sort(),
|
|
686
|
+
recipeCount: Object.keys(lookup.index.recipes).length,
|
|
687
|
+
dryRun: false,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// --- Subscribing ------------------------------------------------------------------------------
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Subscribes the project to a namespace or a recipe: resolves the whole
|
|
695
|
+
* dependency closure, trusts whatever new repositories that turns up, fetches
|
|
696
|
+
* every resolved version into the store, writes the lockfile and the managed
|
|
697
|
+
* subscriptions layer, and finally asks for the variables the new recipes
|
|
698
|
+
* publish.
|
|
699
|
+
*
|
|
700
|
+
* @param options - The ref, the prerelease and always-pull flags, and the trust flag.
|
|
701
|
+
*/
|
|
702
|
+
async subscribe(options: SubscribeOptions): Promise<SubscribeOutcome> {
|
|
703
|
+
const written = parseRef(options.ref);
|
|
704
|
+
const dryRun = options.dryRun === true;
|
|
705
|
+
|
|
706
|
+
// A one-word ref is a guess at a name, and the guess is settled here, from
|
|
707
|
+
// the cached indexes alone. Everything after this point works with a fully
|
|
708
|
+
// qualified ref, so what is confirmed is exactly what is installed.
|
|
709
|
+
const parsed = await this.resolveBareRef(written, options);
|
|
710
|
+
const key = refKey(parsed);
|
|
711
|
+
|
|
712
|
+
// The last gate before anything is fetched or written: what this will do to
|
|
713
|
+
// the project, in plain sentences, and a question.
|
|
714
|
+
await this.confirmSubscription(parsed, options);
|
|
715
|
+
|
|
716
|
+
const { resolved, trusted, cycles } = await this.resolveClosure(parsed, options);
|
|
717
|
+
|
|
718
|
+
const before = this.lock.read();
|
|
719
|
+
const after = this.lock.applyResolution(before, resolved, this.lockRepoInputs());
|
|
720
|
+
const diff = this.lock.diff(before, after);
|
|
721
|
+
|
|
722
|
+
const resolvedFrom =
|
|
723
|
+
formatRef(written) === formatRef(parsed) ? {} : { resolvedFrom: formatRef(written) };
|
|
724
|
+
|
|
725
|
+
if (dryRun) {
|
|
726
|
+
// The questions are planned even here, so `--dry-run` is the command an
|
|
727
|
+
// agent runs to find out what a subscription will want to know. Answers
|
|
728
|
+
// supplied with it are validated and reported, and nothing is written.
|
|
729
|
+
//
|
|
730
|
+
// What can be described is settled from the files actually on this
|
|
731
|
+
// machine, not from what the resolver happened to reach: a recipe already
|
|
732
|
+
// in the store, or read from a linked checkout, has its questions listed
|
|
733
|
+
// even when something else in the closure is still missing.
|
|
734
|
+
const defined = this.definedVariables(resolved);
|
|
735
|
+
const unreadable = this.unreadableRecipes(resolved);
|
|
736
|
+
const context = this.ladderContext();
|
|
737
|
+
const supplied = applyProvidedAnswers(defined, options.answers ?? [], context, {
|
|
738
|
+
sousDir: this.sousDir,
|
|
739
|
+
confDir: this.confDir,
|
|
740
|
+
interactive: this.interactive,
|
|
741
|
+
dryRun: true,
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
return {
|
|
745
|
+
ref: formatRef(parsed),
|
|
746
|
+
...resolvedFrom,
|
|
747
|
+
key,
|
|
748
|
+
resolved,
|
|
749
|
+
trusted,
|
|
750
|
+
diff,
|
|
751
|
+
cycles,
|
|
752
|
+
questions: planQuestions(defined, context, { sousDir: this.sousDir }),
|
|
753
|
+
...(unreadable.length === 0 ? {} : { unreadable }),
|
|
754
|
+
...(supplied.stored.length === 0
|
|
755
|
+
? {}
|
|
756
|
+
: { answers: { answered: supplied.stored, inherited: [], skipped: [] } }),
|
|
757
|
+
dryRun: true,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Supplied answers are checked before anything is written, so an answer
|
|
762
|
+
// that does not fit, or a name nothing declares, fails the run rather than
|
|
763
|
+
// leaving a subscription behind with its questions unanswered.
|
|
764
|
+
validateProvidedAnswers(this.definedVariables(resolved), options.answers ?? []);
|
|
765
|
+
|
|
766
|
+
for (const recipe of resolved) await this.ensureStored(recipe);
|
|
767
|
+
|
|
768
|
+
this.lock.write(after);
|
|
769
|
+
this.writeSubscriptionEntry(key, parsed, options);
|
|
770
|
+
|
|
771
|
+
const answers = await this.askVariables(resolved, options.answers ?? []);
|
|
772
|
+
|
|
773
|
+
return {
|
|
774
|
+
ref: formatRef(parsed),
|
|
775
|
+
...resolvedFrom,
|
|
776
|
+
key,
|
|
777
|
+
resolved,
|
|
778
|
+
trusted,
|
|
779
|
+
diff,
|
|
780
|
+
answers,
|
|
781
|
+
cycles,
|
|
782
|
+
dryRun: false,
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// --- Working out what a one-word ref meant -----------------------------------------------------
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Settles what a ref names, reading nothing but the cached indexes.
|
|
790
|
+
*
|
|
791
|
+
* A ref with two segments already says what it names and is handed back
|
|
792
|
+
* untouched. A ref with one segment is resolved through the shared reference
|
|
793
|
+
* module (`src/lib/refs/`), over the namespace and recipe scopes only:
|
|
794
|
+
* nothing found is an error naming what was searched, one match is used and
|
|
795
|
+
* reported, and several are chosen between. `--accept-first` takes the first
|
|
796
|
+
* match in the documented order; a run that cannot ask fails and says so.
|
|
797
|
+
*
|
|
798
|
+
* @param written - The ref exactly as the user wrote it.
|
|
799
|
+
* @param options - The accept-first flag.
|
|
800
|
+
*/
|
|
801
|
+
private async resolveBareRef(
|
|
802
|
+
written: ParsedRef,
|
|
803
|
+
options: SubscribeOptions
|
|
804
|
+
): Promise<ParsedRef> {
|
|
805
|
+
if (written.recipe !== undefined) return written;
|
|
806
|
+
|
|
807
|
+
const repoOrder = this.repoSearchOrder(written.repo);
|
|
808
|
+
const indexes = await this.loadIndexes(repoOrder);
|
|
809
|
+
if (written.repo === undefined) this.indexesSnapshot = indexes;
|
|
810
|
+
|
|
811
|
+
const context = { repos: referenceReposFromIndexes(repoOrder, indexes) };
|
|
812
|
+
const matches = findReference(
|
|
813
|
+
written.namespace,
|
|
814
|
+
[SousScope.Namespace, SousScope.Recipe],
|
|
815
|
+
context
|
|
816
|
+
);
|
|
817
|
+
|
|
818
|
+
if (matches.length === 0) {
|
|
819
|
+
throw new ConfigError(
|
|
820
|
+
[
|
|
821
|
+
`Nothing called '${written.namespace}' was found: no namespace has that name, ` +
|
|
822
|
+
`and no recipe does either.`,
|
|
823
|
+
...describeIndexSearch({ name: written.namespace, repoOrder, indexes }),
|
|
824
|
+
` Run 'sous repo search ${written.namespace}' to look for something like it, or ` +
|
|
825
|
+
`'sous repo add <url>' to add the repository that publishes it.`,
|
|
826
|
+
].join("\n")
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const chosen = await pickReference(matches, {
|
|
831
|
+
search: written.namespace,
|
|
832
|
+
interactive: this.interactive,
|
|
833
|
+
...(options.acceptFirst === undefined ? {} : { acceptFirst: options.acceptFirst }),
|
|
834
|
+
write: (message: string) => this.write(message),
|
|
835
|
+
choose: (message, offered) => this.choose(message, offered),
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
return referenceToRef(chosen, written);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* The repositories a one-word ref is searched in, in the order their
|
|
843
|
+
* candidates are listed: the built-in repository first, then the ones the
|
|
844
|
+
* config names, in the order the config names them.
|
|
845
|
+
*
|
|
846
|
+
* @param only - A repository qualifier from the ref, which narrows the search to it.
|
|
847
|
+
*/
|
|
848
|
+
private repoSearchOrder(only?: string): string[] {
|
|
849
|
+
const repos = this.currentRepos();
|
|
850
|
+
const names = Object.keys(repos);
|
|
851
|
+
|
|
852
|
+
if (only !== undefined) {
|
|
853
|
+
if (!Object.hasOwn(repos, only)) {
|
|
854
|
+
throw new ConfigError(
|
|
855
|
+
`This project does not trust a repository called '${only}'.\n` +
|
|
856
|
+
(names.length > 0
|
|
857
|
+
? ` It trusts: ${names.join(", ")}.`
|
|
858
|
+
: ` It trusts none yet.`) +
|
|
859
|
+
`\n Add it with 'sous repo add <url> --name ${only}'.`
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
return [only];
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const builtIn = names.filter((name) => isBuiltInEntry(repos[name]));
|
|
866
|
+
return [...builtIn, ...names.filter((name) => !builtIn.includes(name))];
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Every trusted repository's index, loaded once per command. Whatever a
|
|
871
|
+
* one-word ref already loaded is reused, so confirming a subscription costs
|
|
872
|
+
* no extra lookups.
|
|
873
|
+
*/
|
|
874
|
+
private async indexSnapshot(): Promise<Map<string, IndexFile>> {
|
|
875
|
+
if (this.indexesSnapshot === undefined) {
|
|
876
|
+
this.indexesSnapshot = await this.loadIndexes(this.repoSearchOrder());
|
|
877
|
+
}
|
|
878
|
+
return this.indexesSnapshot;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// --- The subscribe confirmation ----------------------------------------------------------------
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Says what subscribing will do to this project, and asks whether to go on.
|
|
885
|
+
*
|
|
886
|
+
* This runs before anything is fetched or written, so a "no" costs nothing:
|
|
887
|
+
* the only thing read to get here is the cached index of each trusted
|
|
888
|
+
* repository. `--yes` skips the question, and a dry run states the plan and
|
|
889
|
+
* never asks, because a dry run has nothing to decline.
|
|
890
|
+
*
|
|
891
|
+
* @param parsed - The fully qualified ref being subscribed to.
|
|
892
|
+
* @param options - The yes and dry-run flags.
|
|
893
|
+
*/
|
|
894
|
+
private async confirmSubscription(
|
|
895
|
+
parsed: ParsedRef,
|
|
896
|
+
options: SubscribeOptions
|
|
897
|
+
): Promise<void> {
|
|
898
|
+
const indexes = await this.indexSnapshot();
|
|
899
|
+
const plan = await this.subscriptionPlan(parsed, options, indexes);
|
|
900
|
+
for (const line of plan) this.write(line === "" ? "" : indent(line));
|
|
901
|
+
|
|
902
|
+
if (options.dryRun === true || options.yes === true) return;
|
|
903
|
+
|
|
904
|
+
if (!this.interactive) {
|
|
905
|
+
throw nonInteractiveError({
|
|
906
|
+
prompt: `whether to go ahead with subscribing to '${formatRef(parsed)}'`,
|
|
907
|
+
remedy:
|
|
908
|
+
"pass '--yes' (spelled '-y', '--force' or '--trust' if you prefer) to accept " +
|
|
909
|
+
"the plan above without being asked.",
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const proceed = await this.ask("Proceed?");
|
|
914
|
+
if (!proceed) {
|
|
915
|
+
throw new ConfigError(
|
|
916
|
+
`Nothing was written: the subscription to '${formatRef(parsed)}' was declined.\n` +
|
|
917
|
+
` Nothing was downloaded, no lockfile entry was made, and this project's ` +
|
|
918
|
+
`config is exactly as it was.`
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* The plan itself: what will be compiled, what can run, what will be asked,
|
|
925
|
+
* and what will be fetched, in plain sentences.
|
|
926
|
+
*
|
|
927
|
+
* @param parsed - The fully qualified ref being subscribed to.
|
|
928
|
+
* @param options - The prerelease flag, for the dependency peek.
|
|
929
|
+
*/
|
|
930
|
+
private async subscriptionPlan(
|
|
931
|
+
parsed: ParsedRef,
|
|
932
|
+
options: SubscribeOptions,
|
|
933
|
+
indexes: Map<string, IndexFile>
|
|
934
|
+
): Promise<string[]> {
|
|
935
|
+
const target = formatRef(parsed);
|
|
936
|
+
const width = wrapColumns() - 4;
|
|
937
|
+
const lines: string[] = [""];
|
|
938
|
+
|
|
939
|
+
/** One sentence of the plan, wrapped and in the warning color. */
|
|
940
|
+
const sentence = (text: string): string[] =>
|
|
941
|
+
wrapText(text, width).map((line) => palette.warning(line));
|
|
942
|
+
|
|
943
|
+
/** One bullet of the plan, wrapped so its continuation hangs under the text. */
|
|
944
|
+
const bullet = (text: string): string[] =>
|
|
945
|
+
wrapText(`${BULLET} ${text}`, width, { hangingIndent: 2 }).map((line) =>
|
|
946
|
+
palette.warning(line)
|
|
947
|
+
);
|
|
948
|
+
|
|
949
|
+
if (parsed.recipe === undefined) {
|
|
950
|
+
const published = this.namespaceRecipes(parsed, indexes);
|
|
951
|
+
lines.push(
|
|
952
|
+
...sentence(
|
|
953
|
+
`Subscribing to '${target}' subscribes this project to the whole ` +
|
|
954
|
+
`namespace '${parsed.namespace}', which means ${palette.highlight(
|
|
955
|
+
"every recipe in it, including ones published later"
|
|
956
|
+
)}.`
|
|
957
|
+
)
|
|
958
|
+
);
|
|
959
|
+
if (published.length > 0) {
|
|
960
|
+
lines.push(
|
|
961
|
+
...sentence(`It publishes ${published.length} today: ${published.join(", ")}.`)
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
} else {
|
|
965
|
+
lines.push(
|
|
966
|
+
...sentence(
|
|
967
|
+
`Subscribing to '${target}' installs the recipe '${parsed.recipe}' from ` +
|
|
968
|
+
`the namespace '${parsed.namespace}'.`
|
|
969
|
+
)
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
lines.push("");
|
|
974
|
+
lines.push(...sentence("Here is what that does:"));
|
|
975
|
+
lines.push("");
|
|
976
|
+
lines.push(
|
|
977
|
+
...bullet(
|
|
978
|
+
`The files it ships are compiled into this project on the next build, ` +
|
|
979
|
+
`which writes them into this project's agent directories.`
|
|
980
|
+
)
|
|
981
|
+
);
|
|
982
|
+
lines.push(
|
|
983
|
+
...bullet(
|
|
984
|
+
`Any scripts it ships ${palette.highlight(
|
|
985
|
+
"can be run on this machine"
|
|
986
|
+
)} when an agent uses them. Sous does not run them itself, and it cannot ` +
|
|
987
|
+
`vouch for what they do.`
|
|
988
|
+
)
|
|
989
|
+
);
|
|
990
|
+
lines.push(
|
|
991
|
+
...bullet(
|
|
992
|
+
`The variables it publishes are asked about at the end of this command, ` +
|
|
993
|
+
`and the answers are written into this project's env files.`
|
|
994
|
+
)
|
|
995
|
+
);
|
|
996
|
+
lines.push(
|
|
997
|
+
...bullet(
|
|
998
|
+
`Its dependencies are fetched and pinned in this project's lockfile, at ` +
|
|
999
|
+
`the exact versions resolved now.`
|
|
1000
|
+
)
|
|
1001
|
+
);
|
|
1002
|
+
|
|
1003
|
+
const untrusted = await this.knownUntrustedDependencyRepos(parsed, options, indexes);
|
|
1004
|
+
if (untrusted.length > 0) {
|
|
1005
|
+
lines.push(
|
|
1006
|
+
...bullet(
|
|
1007
|
+
`Some of what it needs lives in repositories this project ${palette.highlight(
|
|
1008
|
+
"does not trust yet"
|
|
1009
|
+
)}: ${untrusted.join(", ")}. You are asked about each one by name before ` +
|
|
1010
|
+
`anything is fetched from it.`
|
|
1011
|
+
)
|
|
1012
|
+
);
|
|
1013
|
+
} else {
|
|
1014
|
+
lines.push(
|
|
1015
|
+
...bullet(
|
|
1016
|
+
`If a dependency turns out to live in a repository this project does ` +
|
|
1017
|
+
`not trust, sous stops and asks about that repository by name before ` +
|
|
1018
|
+
`fetching anything from it.`
|
|
1019
|
+
)
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
lines.push("");
|
|
1024
|
+
return lines;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* The recipes a namespace publishes today, as `namespace/recipe` keys.
|
|
1029
|
+
*
|
|
1030
|
+
* @param parsed - The fully qualified namespace ref.
|
|
1031
|
+
*/
|
|
1032
|
+
private namespaceRecipes(parsed: ParsedRef, indexes: Map<string, IndexFile>): string[] {
|
|
1033
|
+
const index = parsed.repo === undefined ? undefined : indexes.get(parsed.repo);
|
|
1034
|
+
if (index === undefined) return [];
|
|
1035
|
+
return Object.keys(index.recipes)
|
|
1036
|
+
.filter((key) => key.startsWith(`${parsed.namespace}/`))
|
|
1037
|
+
.sort();
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Repositories a dependency needs that this project does not trust, as far as
|
|
1042
|
+
* anything already on disk knows.
|
|
1043
|
+
*
|
|
1044
|
+
* A manifest is the only thing that names a dependency, and a manifest that
|
|
1045
|
+
* has never been fetched cannot be read without fetching, which is exactly
|
|
1046
|
+
* what the confirmation exists to gate. So this reads what the store already
|
|
1047
|
+
* holds, and says nothing when it holds nothing; the trust ceremony during
|
|
1048
|
+
* resolution is still where the real answer comes from.
|
|
1049
|
+
*
|
|
1050
|
+
* @param parsed - The fully qualified ref being subscribed to.
|
|
1051
|
+
* @param options - The prerelease flag.
|
|
1052
|
+
*/
|
|
1053
|
+
private async knownUntrustedDependencyRepos(
|
|
1054
|
+
parsed: ParsedRef,
|
|
1055
|
+
options: SubscribeOptions,
|
|
1056
|
+
indexes: Map<string, IndexFile>
|
|
1057
|
+
): Promise<string[]> {
|
|
1058
|
+
try {
|
|
1059
|
+
const result = await resolveRefs(
|
|
1060
|
+
[
|
|
1061
|
+
{
|
|
1062
|
+
ref: parsed,
|
|
1063
|
+
requestedBy: PROJECT_REQUESTER,
|
|
1064
|
+
kind: "subscribes",
|
|
1065
|
+
...(options.prerelease === true ? { prerelease: true } : {}),
|
|
1066
|
+
},
|
|
1067
|
+
],
|
|
1068
|
+
{
|
|
1069
|
+
indexes,
|
|
1070
|
+
repos: this.resolverRepos(),
|
|
1071
|
+
// Reads only what is already on disk: the confirmation must not fetch.
|
|
1072
|
+
loadManifest: (recipe) => this.loadRecipeManifest(recipe, true),
|
|
1073
|
+
...(options.prerelease === true ? { prerelease: true } : {}),
|
|
1074
|
+
}
|
|
1075
|
+
);
|
|
1076
|
+
return result.missingRepos.map((missing) => `'${missing.name}'`).sort();
|
|
1077
|
+
} catch {
|
|
1078
|
+
// The plan is a courtesy; a peek that fails must never stop a subscription
|
|
1079
|
+
// that resolution itself would have completed.
|
|
1080
|
+
return [];
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Resolves a ref and everything beneath it, running the trust round as often
|
|
1086
|
+
* as resolution keeps turning up repositories the project has not added.
|
|
1087
|
+
*
|
|
1088
|
+
* IN PRACTICE THIS RUNS AT MOST ONE TRUST ROUND TODAY, and the loop is the
|
|
1089
|
+
* shape rather than the behavior. A recipe names the repository it depends on
|
|
1090
|
+
* by short name only, so nothing in a manifest carries a URL and every missing
|
|
1091
|
+
* repository comes back under `needUrl`, which throws below. The loop earns
|
|
1092
|
+
* its keep the moment any source of URLs exists (a repository hint block, or
|
|
1093
|
+
* the lockfile of a project restoring someone else's commit); until then, read
|
|
1094
|
+
* it as "one round, then either resolution succeeds or the person is told what
|
|
1095
|
+
* to add".
|
|
1096
|
+
*
|
|
1097
|
+
* @param parsed - The ref being subscribed to.
|
|
1098
|
+
* @param options - The prerelease and trust flags.
|
|
1099
|
+
*/
|
|
1100
|
+
private async resolveClosure(
|
|
1101
|
+
parsed: ParsedRef,
|
|
1102
|
+
options: SubscribeOptions
|
|
1103
|
+
): Promise<{
|
|
1104
|
+
resolved: ResolvedRecipe[];
|
|
1105
|
+
trusted: string[];
|
|
1106
|
+
cycles: string[][];
|
|
1107
|
+
unreadable: string[];
|
|
1108
|
+
}> {
|
|
1109
|
+
const trusted: string[] = [];
|
|
1110
|
+
|
|
1111
|
+
for (;;) {
|
|
1112
|
+
const repos = this.resolverRepos();
|
|
1113
|
+
const indexes = await this.loadIndexes(Object.keys(repos));
|
|
1114
|
+
|
|
1115
|
+
const result = await resolveRefs(
|
|
1116
|
+
[
|
|
1117
|
+
{
|
|
1118
|
+
ref: parsed,
|
|
1119
|
+
requestedBy: PROJECT_REQUESTER,
|
|
1120
|
+
kind: "subscribes",
|
|
1121
|
+
...(options.prerelease === true ? { prerelease: true } : {}),
|
|
1122
|
+
},
|
|
1123
|
+
],
|
|
1124
|
+
{
|
|
1125
|
+
indexes,
|
|
1126
|
+
repos,
|
|
1127
|
+
loadManifest: (recipe) => this.loadRecipeManifest(recipe, options.dryRun === true),
|
|
1128
|
+
...(options.prerelease === true ? { prerelease: true } : {}),
|
|
1129
|
+
}
|
|
1130
|
+
);
|
|
1131
|
+
|
|
1132
|
+
if (result.missingRepos.length === 0) {
|
|
1133
|
+
// A dry run refuses to download, so a recipe this machine does not hold
|
|
1134
|
+
// yet has no manifest to read. That is not a failure here: the plan
|
|
1135
|
+
// describes everything it could read and names what it could not.
|
|
1136
|
+
if (result.missingManifests.length > 0 && options.dryRun !== true) {
|
|
1137
|
+
throw new ConfigError(
|
|
1138
|
+
`Sous could not read the manifest of ` +
|
|
1139
|
+
`${result.missingManifests.map((entry) => `'${entry}'`).join(", ")}.\n` +
|
|
1140
|
+
` Every recipe carries a manifest, so this one is either damaged upstream ` +
|
|
1141
|
+
`or could not be downloaded. Nothing was written.`
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
return {
|
|
1145
|
+
resolved: result.resolved,
|
|
1146
|
+
trusted,
|
|
1147
|
+
cycles: result.cycles,
|
|
1148
|
+
unreadable: result.missingManifests,
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
const outcome = await this.trust.confirmTrust(result.missingRepos, {
|
|
1153
|
+
interactive: this.interactive,
|
|
1154
|
+
...(options.trust === undefined ? {} : { trustFlag: options.trust }),
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
if (outcome.needUrl.length > 0) {
|
|
1158
|
+
const lines = [
|
|
1159
|
+
outcome.needUrl.length === 1
|
|
1160
|
+
? `Sous does not know where the repository '${outcome.needUrl[0]}' lives, so it ` +
|
|
1161
|
+
`cannot add it for you.`
|
|
1162
|
+
: `Sous does not know where these repositories live, so it cannot add them for ` +
|
|
1163
|
+
`you: ${outcome.needUrl.map((entry) => `'${entry}'`).join(", ")}.`,
|
|
1164
|
+
" A recipe names the repository it depends on by its short name only; the URL " +
|
|
1165
|
+
"has to come from you.",
|
|
1166
|
+
" Add each one with its URL, then run this command again:",
|
|
1167
|
+
"",
|
|
1168
|
+
];
|
|
1169
|
+
for (const name of outcome.needUrl) {
|
|
1170
|
+
lines.push(` sous repo add <url> --name ${name}`);
|
|
1171
|
+
}
|
|
1172
|
+
throw new ConfigError(lines.join("\n"));
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
trusted.push(...outcome.added);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* Removes one subscription and everything that was only there because of it.
|
|
1181
|
+
* Removal is refcounted: a recipe another subscription (or another recipe)
|
|
1182
|
+
* still holds stays exactly where it is, and is reported as having stayed.
|
|
1183
|
+
*
|
|
1184
|
+
* @param options - The ref to unsubscribe from.
|
|
1185
|
+
*/
|
|
1186
|
+
async unsubscribe(options: UnsubscribeOptions): Promise<UnsubscribeOutcome> {
|
|
1187
|
+
const parsed = parseRef(options.ref);
|
|
1188
|
+
const key = refKey(parsed);
|
|
1189
|
+
const dryRun = options.dryRun === true;
|
|
1190
|
+
|
|
1191
|
+
const managed = this.readSubscriptionEntries();
|
|
1192
|
+
const configured = enabledSubscriptions(this.settings);
|
|
1193
|
+
const before = this.lock.read();
|
|
1194
|
+
const held = this.keysHeldBySubscription(before, key);
|
|
1195
|
+
|
|
1196
|
+
// An entry the managed layer already switched off is not a subscription any
|
|
1197
|
+
// more, so removing it again is the "you do not subscribe to this" case
|
|
1198
|
+
// rather than a deletion that would quietly switch it back on.
|
|
1199
|
+
const alreadyOff = managed[key]?.enabled === false;
|
|
1200
|
+
const removable = Object.hasOwn(managed, key) && !alreadyOff;
|
|
1201
|
+
// Sous provides the `core` subscription itself, so there is no entry to
|
|
1202
|
+
// delete. Removing it means recording an opt-out that outlives the default.
|
|
1203
|
+
const builtIn = !removable && isBuiltInEntry(configured[key]);
|
|
1204
|
+
|
|
1205
|
+
if (!removable && !builtIn && !Object.hasOwn(configured, key) && held.length === 0) {
|
|
1206
|
+
const known = [
|
|
1207
|
+
...new Set([
|
|
1208
|
+
...Object.entries(managed)
|
|
1209
|
+
.filter(([, entry]) => entry?.enabled !== false)
|
|
1210
|
+
.map(([entryKey]) => entryKey),
|
|
1211
|
+
...Object.keys(configured),
|
|
1212
|
+
]),
|
|
1213
|
+
].sort();
|
|
1214
|
+
throw new ConfigError(
|
|
1215
|
+
`This project does not subscribe to '${key}'.\n` +
|
|
1216
|
+
(known.length > 0
|
|
1217
|
+
? ` It subscribes to: ${known.join(", ")}.`
|
|
1218
|
+
: ` It has no subscriptions yet.`)
|
|
1219
|
+
);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
if (!removable && !builtIn && Object.hasOwn(configured, key)) {
|
|
1223
|
+
throw new ConfigError(
|
|
1224
|
+
`The subscription to '${key}' is written in this project's own config, not in the ` +
|
|
1225
|
+
`layer sous manages.\n` +
|
|
1226
|
+
` Remove its entry from the 'subscriptions' block of your config file; sous ` +
|
|
1227
|
+
`never edits a config file you wrote.`
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
let after = before;
|
|
1232
|
+
for (const heldKey of held) after = this.dropProjectHold(after, heldKey);
|
|
1233
|
+
const diff = this.lock.diff(before, after);
|
|
1234
|
+
|
|
1235
|
+
const stayed = held
|
|
1236
|
+
.filter((heldKey) => Object.hasOwn(after.recipes, heldKey))
|
|
1237
|
+
.map((heldKey) => ({
|
|
1238
|
+
key: heldKey,
|
|
1239
|
+
heldBy: [...after.recipes[heldKey]!.requestedBy],
|
|
1240
|
+
}));
|
|
1241
|
+
|
|
1242
|
+
if (!dryRun) {
|
|
1243
|
+
this.lock.write(after);
|
|
1244
|
+
|
|
1245
|
+
const remaining = { ...managed };
|
|
1246
|
+
if (builtIn) remaining[key] = { enabled: false };
|
|
1247
|
+
else delete remaining[key];
|
|
1248
|
+
|
|
1249
|
+
if (Object.keys(remaining).length === 0) {
|
|
1250
|
+
removeManagedLayer(this.sousDir, SUBSCRIPTIONS_LAYER_FILENAME, {
|
|
1251
|
+
confDir: this.confDir,
|
|
1252
|
+
});
|
|
1253
|
+
} else {
|
|
1254
|
+
// A subscription sous provides itself has no entry to delete, so the
|
|
1255
|
+
// opt-out is written as the entry instead; anything else is removed.
|
|
1256
|
+
updateManagedLayer(
|
|
1257
|
+
this.sousDir,
|
|
1258
|
+
SUBSCRIPTIONS_LAYER_FILENAME,
|
|
1259
|
+
[
|
|
1260
|
+
{
|
|
1261
|
+
path: ["subscriptions", key],
|
|
1262
|
+
value: builtIn ? { enabled: false } : undefined,
|
|
1263
|
+
},
|
|
1264
|
+
],
|
|
1265
|
+
{ confDir: this.confDir }
|
|
1266
|
+
);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
return { key, diff, stayed, optedOut: builtIn, dryRun };
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// --- Withdrawing trust from a repository -------------------------------------------------------
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* Stops trusting a repository: every subscription that resolves into it is
|
|
1277
|
+
* removed through the same refcounted path `unsubscribe` uses, the link that
|
|
1278
|
+
* pointed at it is dropped, and its entry leaves the managed repositories
|
|
1279
|
+
* layer.
|
|
1280
|
+
*
|
|
1281
|
+
* Informed consent, never prevention: everything that will go is printed
|
|
1282
|
+
* first, in plain sentences, and then one question is asked. `--yes` skips the
|
|
1283
|
+
* question and a dry run never asks, because a dry run has nothing to decline.
|
|
1284
|
+
*
|
|
1285
|
+
* The repository sous provides itself has no entry to delete, so removing it
|
|
1286
|
+
* records `enabled: false` instead; the default comes back on every run and
|
|
1287
|
+
* only a recorded opt-out outlives it.
|
|
1288
|
+
*
|
|
1289
|
+
* @param options - The repository's short name, and the yes and dry-run flags.
|
|
1290
|
+
*/
|
|
1291
|
+
async removeRepo(options: RemoveRepoOptions): Promise<RemoveRepoOutcome> {
|
|
1292
|
+
const name = options.name;
|
|
1293
|
+
const dryRun = options.dryRun === true;
|
|
1294
|
+
|
|
1295
|
+
const trusted = this.trust.listTrusted();
|
|
1296
|
+
const entry = trusted[name];
|
|
1297
|
+
if (entry === undefined) {
|
|
1298
|
+
const known = Object.keys(trusted).sort();
|
|
1299
|
+
throw new ConfigError(
|
|
1300
|
+
`This project does not trust a repository called '${name}'.\n` +
|
|
1301
|
+
(known.length > 0
|
|
1302
|
+
? ` It trusts: ${known.join(", ")}.`
|
|
1303
|
+
: ` It trusts no repositories yet.`)
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
const managed = this.trust.listManaged();
|
|
1308
|
+
const builtIn = !Object.hasOwn(managed, name) && isBuiltInEntry(entry);
|
|
1309
|
+
if (!Object.hasOwn(managed, name) && !builtIn) {
|
|
1310
|
+
throw new ConfigError(
|
|
1311
|
+
`The repository '${name}' is written in this project's own config, not in the ` +
|
|
1312
|
+
`layer sous manages.\n` +
|
|
1313
|
+
` Remove its entry from the 'repos' block of your config file; sous never ` +
|
|
1314
|
+
`edits a config file you wrote.`
|
|
1315
|
+
);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
const before = this.lock.read();
|
|
1319
|
+
const { removable, kept } = this.subscriptionsInto(name, before);
|
|
1320
|
+
|
|
1321
|
+
// What the removal would leave behind, worked out before anything is
|
|
1322
|
+
// written, so the plan below describes exactly what is about to happen.
|
|
1323
|
+
let after = before;
|
|
1324
|
+
const held: string[] = [];
|
|
1325
|
+
for (const key of removable) {
|
|
1326
|
+
for (const heldKey of keysHeldBySubscription(before, key)) {
|
|
1327
|
+
held.push(heldKey);
|
|
1328
|
+
after = this.dropProjectHold(after, heldKey);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
const diff = this.lock.diff(before, after);
|
|
1332
|
+
const removedRecipes = diff.removed.map((change) => change.key);
|
|
1333
|
+
const stayed = [...new Set(held)]
|
|
1334
|
+
.filter((heldKey) => Object.hasOwn(after.recipes, heldKey))
|
|
1335
|
+
.sort()
|
|
1336
|
+
.map((heldKey) => ({ key: heldKey, heldBy: [...after.recipes[heldKey]!.requestedBy] }));
|
|
1337
|
+
|
|
1338
|
+
const outputs = this.outputsOf(removedRecipes, options.scope);
|
|
1339
|
+
const projectLink = readProjectLinks(this.sousDir).links[name];
|
|
1340
|
+
const globalLink = readGlobalLinks(this.env).links[name];
|
|
1341
|
+
const link = projectLink ?? globalLink;
|
|
1342
|
+
|
|
1343
|
+
const outcome: RemoveRepoOutcome = {
|
|
1344
|
+
name,
|
|
1345
|
+
url: entry.url,
|
|
1346
|
+
optedOut: builtIn,
|
|
1347
|
+
subscriptions: removable,
|
|
1348
|
+
keptSubscriptions: kept,
|
|
1349
|
+
removedRecipes,
|
|
1350
|
+
stayed,
|
|
1351
|
+
outputs,
|
|
1352
|
+
diff,
|
|
1353
|
+
...(link === undefined ? {} : { linkedPath: link.path }),
|
|
1354
|
+
linkIsGlobal: projectLink === undefined && globalLink !== undefined,
|
|
1355
|
+
dryRun,
|
|
1356
|
+
};
|
|
1357
|
+
|
|
1358
|
+
for (const line of this.removalPlan(outcome)) {
|
|
1359
|
+
this.write(line === "" ? "" : indent(line));
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
if (!dryRun && options.yes !== true) {
|
|
1363
|
+
if (!this.interactive) {
|
|
1364
|
+
throw nonInteractiveError({
|
|
1365
|
+
prompt: `whether to stop trusting the repository '${name}'`,
|
|
1366
|
+
remedy:
|
|
1367
|
+
"pass '--yes' (spelled '-y', '--force' or '-f' if you prefer) to accept the " +
|
|
1368
|
+
"plan above without being asked.",
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const proceed = await this.ask("Stop trusting it?");
|
|
1373
|
+
if (!proceed) {
|
|
1374
|
+
throw new ConfigError(
|
|
1375
|
+
`Nothing was written: the repository '${name}' is still trusted.\n` +
|
|
1376
|
+
` No subscription was removed, no lockfile entry was changed, and this ` +
|
|
1377
|
+
`project's config is exactly as it was.`
|
|
1378
|
+
);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
if (dryRun) return outcome;
|
|
1383
|
+
|
|
1384
|
+
// Each subscription goes through the ordinary refcounted removal, so a
|
|
1385
|
+
// recipe another subscription still holds is kept exactly as it would be
|
|
1386
|
+
// had the subscription been removed on its own.
|
|
1387
|
+
for (const key of removable) await this.unsubscribe({ ref: key });
|
|
1388
|
+
|
|
1389
|
+
if (projectLink !== undefined) {
|
|
1390
|
+
const map = readProjectLinks(this.sousDir);
|
|
1391
|
+
delete map.links[name];
|
|
1392
|
+
writeProjectLinks(this.sousDir, map);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
if (builtIn) this.trust.disableRepo(name);
|
|
1396
|
+
else this.trust.removeRepo(name);
|
|
1397
|
+
|
|
1398
|
+
return outcome;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* The subscriptions that resolve into one repository, split by whether sous
|
|
1403
|
+
* may remove them. A subscription counts when the lockfile pins one of its
|
|
1404
|
+
* recipes to that repository, or when its ref names the repository outright
|
|
1405
|
+
* with a `repo:` qualifier.
|
|
1406
|
+
*
|
|
1407
|
+
* @param name - The repository's short name.
|
|
1408
|
+
* @param lock - The lockfile as it stands.
|
|
1409
|
+
*/
|
|
1410
|
+
private subscriptionsInto(
|
|
1411
|
+
name: string,
|
|
1412
|
+
lock: Lockfile
|
|
1413
|
+
): { removable: string[]; kept: string[] } {
|
|
1414
|
+
const fromRepo = new Set(
|
|
1415
|
+
Object.entries(lock.recipes)
|
|
1416
|
+
.filter(([, recipe]) => recipe.repo === name)
|
|
1417
|
+
.map(([key]) => key)
|
|
1418
|
+
);
|
|
1419
|
+
|
|
1420
|
+
const subscriptions = this.allSubscriptions();
|
|
1421
|
+
const managed = this.readSubscriptionEntries();
|
|
1422
|
+
const removable: string[] = [];
|
|
1423
|
+
const kept: string[] = [];
|
|
1424
|
+
|
|
1425
|
+
for (const key of Object.keys(subscriptions).sort()) {
|
|
1426
|
+
let qualifier: string | undefined;
|
|
1427
|
+
try {
|
|
1428
|
+
qualifier = parseRef(key).repo;
|
|
1429
|
+
} catch {
|
|
1430
|
+
// A ref that does not parse cannot name this repository, and reporting
|
|
1431
|
+
// it here would bury the removal under an unrelated complaint.
|
|
1432
|
+
continue;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
const touches =
|
|
1436
|
+
qualifier === name ||
|
|
1437
|
+
keysHeldBySubscription(lock, key).some((heldKey) => fromRepo.has(heldKey));
|
|
1438
|
+
if (!touches) continue;
|
|
1439
|
+
|
|
1440
|
+
const sousMayRemove =
|
|
1441
|
+
Object.hasOwn(managed, key) || isBuiltInEntry(subscriptions[key]);
|
|
1442
|
+
if (sousMayRemove) removable.push(key);
|
|
1443
|
+
else kept.push(key);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
return { removable, kept };
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
/**
|
|
1450
|
+
* The output files a set of locked recipes compiled, which the next build
|
|
1451
|
+
* prunes once they are gone.
|
|
1452
|
+
*
|
|
1453
|
+
* Worked out from the recipes' own compile targets rather than from the build
|
|
1454
|
+
* state file, because the state file records what sous wrote without
|
|
1455
|
+
* recording which recipe wrote it. A destination that cannot be resolved
|
|
1456
|
+
* (a `${var}` with no value in the scope given) leaves the list empty rather
|
|
1457
|
+
* than stopping a removal; the build itself reports that properly.
|
|
1458
|
+
*
|
|
1459
|
+
* @param keys - The recipe keys that are going away.
|
|
1460
|
+
* @param scope - The resolved settings scope, for `${var}` in destinations.
|
|
1461
|
+
*/
|
|
1462
|
+
private outputsOf(keys: string[], scope?: VarScope): string[] {
|
|
1463
|
+
if (keys.length === 0) return [];
|
|
1464
|
+
|
|
1465
|
+
const going = new Set(keys);
|
|
1466
|
+
const locked = listLockedRecipes({ sousDir: this.sousDir, env: this.env }).filter(
|
|
1467
|
+
(recipe) => going.has(recipe.key)
|
|
1468
|
+
);
|
|
1469
|
+
if (locked.length === 0) return [];
|
|
1470
|
+
|
|
1471
|
+
try {
|
|
1472
|
+
const { targets } = buildRecipeTargets({
|
|
1473
|
+
sousDir: this.sousDir,
|
|
1474
|
+
settings: this.settings,
|
|
1475
|
+
...(scope === undefined ? {} : { scope }),
|
|
1476
|
+
env: this.env,
|
|
1477
|
+
locked,
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
const files = new Set<string>();
|
|
1481
|
+
for (const target of targets) {
|
|
1482
|
+
for (const output of target.outputs) {
|
|
1483
|
+
const resolved = resolveOutputPath(target, output);
|
|
1484
|
+
if (resolved !== undefined) files.add(resolved);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
return [...files].sort();
|
|
1488
|
+
} catch {
|
|
1489
|
+
return [];
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
/**
|
|
1494
|
+
* What stopping trusting a repository will do to this project, in plain
|
|
1495
|
+
* sentences: the entry itself, the subscriptions that go with it, the recipes
|
|
1496
|
+
* they hold, the files the next build prunes, and the link that points at it.
|
|
1497
|
+
*
|
|
1498
|
+
* @param outcome - Everything the removal worked out.
|
|
1499
|
+
*/
|
|
1500
|
+
private removalPlan(outcome: RemoveRepoOutcome): string[] {
|
|
1501
|
+
const lines: string[] = [""];
|
|
1502
|
+
|
|
1503
|
+
lines.push(
|
|
1504
|
+
outcome.optedOut
|
|
1505
|
+
? `The repository '${outcome.name}' at ${outcome.url} is one sous provides ` +
|
|
1506
|
+
`itself, so it cannot be deleted: it is switched off instead, by recording ` +
|
|
1507
|
+
`'${outcome.name}: { enabled: false }' in this project's repositories layer.`
|
|
1508
|
+
: `The entry for '${outcome.name}' at ${outcome.url} is removed from this ` +
|
|
1509
|
+
`project's repositories layer, so sous stops reading anything from it.`
|
|
1510
|
+
);
|
|
1511
|
+
|
|
1512
|
+
lines.push("");
|
|
1513
|
+
lines.push("Here is what goes with it:");
|
|
1514
|
+
lines.push("");
|
|
1515
|
+
|
|
1516
|
+
if (outcome.subscriptions.length > 0) {
|
|
1517
|
+
lines.push(
|
|
1518
|
+
outcome.subscriptions.length === 1
|
|
1519
|
+
? ` One subscription resolves into it and is removed: ` +
|
|
1520
|
+
`${outcome.subscriptions[0]}.`
|
|
1521
|
+
: ` ${outcome.subscriptions.length} subscriptions resolve into it and are ` +
|
|
1522
|
+
`removed: ${outcome.subscriptions.join(", ")}.`
|
|
1523
|
+
);
|
|
1524
|
+
} else {
|
|
1525
|
+
lines.push(" Nothing this project subscribes to resolves into it.");
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
if (outcome.removedRecipes.length > 0) {
|
|
1529
|
+
lines.push(
|
|
1530
|
+
` ${outcome.removedRecipes.length === 1 ? "One locked recipe is" : `${outcome.removedRecipes.length} locked recipes are`} ` +
|
|
1531
|
+
`held only through those subscriptions, and ${
|
|
1532
|
+
outcome.removedRecipes.length === 1 ? "it leaves" : "they leave"
|
|
1533
|
+
} the lockfile: ${outcome.removedRecipes.join(", ")}.`
|
|
1534
|
+
);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
for (const stayed of outcome.stayed) {
|
|
1538
|
+
lines.push(
|
|
1539
|
+
` The recipe '${stayed.key}' stays, because ${stayed.heldBy.join(", ")} still ` +
|
|
1540
|
+
`holds it.`
|
|
1541
|
+
);
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
if (outcome.outputs.length > 0) {
|
|
1545
|
+
lines.push(
|
|
1546
|
+
` ${outcome.outputs.length === 1 ? "One file those recipes compiled is" : `${outcome.outputs.length} files those recipes compiled are`} ` +
|
|
1547
|
+
`pruned by the build that follows:`
|
|
1548
|
+
);
|
|
1549
|
+
for (const file of outcome.outputs) lines.push(` ${file}`);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
if (outcome.linkedPath !== undefined) {
|
|
1553
|
+
lines.push(
|
|
1554
|
+
outcome.linkIsGlobal
|
|
1555
|
+
? ` A machine-wide link points this repository at the checkout at ` +
|
|
1556
|
+
`${outcome.linkedPath}. That map is shared by every project on this ` +
|
|
1557
|
+
`machine, so it is left exactly as it is, and the checkout stays on disk.`
|
|
1558
|
+
: ` This project links the repository to the checkout at ` +
|
|
1559
|
+
`${outcome.linkedPath}. The link is removed, and the checkout stays on ` +
|
|
1560
|
+
`disk exactly as it is.`
|
|
1561
|
+
);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
for (const key of outcome.keptSubscriptions) {
|
|
1565
|
+
lines.push(
|
|
1566
|
+
` The subscription to '${key}' resolves into it and is written in this ` +
|
|
1567
|
+
`project's own config, which sous never edits, so it stays. It resolves ` +
|
|
1568
|
+
`against nothing once the repository is gone.`
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
lines.push("");
|
|
1573
|
+
return lines;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// --- Restoring and upstream checks -------------------------------------------------------------
|
|
1577
|
+
|
|
1578
|
+
/**
|
|
1579
|
+
* True when the lockfile pins something the store does not hold, which is the
|
|
1580
|
+
* state of a fresh clone. A linked repository is read from its checkout and is
|
|
1581
|
+
* never restored.
|
|
1582
|
+
*/
|
|
1583
|
+
needsRestore(): boolean {
|
|
1584
|
+
return listLockedRecipes({ sousDir: this.sousDir, env: this.env }).some(
|
|
1585
|
+
(recipe) => !recipe.linked && !recipe.present
|
|
1586
|
+
);
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/**
|
|
1590
|
+
* Puts the core recipe that ships inside the sous package into the store,
|
|
1591
|
+
* writes a stand-in index for the official repository when nothing real has
|
|
1592
|
+
* ever been fetched, and tells the index cache about the packaged version so
|
|
1593
|
+
* it resolves even against a real index that has not published it yet. This
|
|
1594
|
+
* runs before anything else a build does, because it is what lets a project
|
|
1595
|
+
* resolve the `core` namespace at all, with or without a network.
|
|
1596
|
+
*
|
|
1597
|
+
* Idempotent, offline, and never fatal: a failure comes back in the report as
|
|
1598
|
+
* a sentence to warn about.
|
|
1599
|
+
*/
|
|
1600
|
+
async seedCore(): Promise<SeedCoreRecipeReport> {
|
|
1601
|
+
if (this.seedReport !== undefined) return this.seedReport;
|
|
1602
|
+
this.seedReport = await seedCoreRecipe({
|
|
1603
|
+
store: this.storeInstance,
|
|
1604
|
+
sousVersion: SOUS_VERSION,
|
|
1605
|
+
now: this.now,
|
|
1606
|
+
// Teaching the index cache what the package holds is what lets the seeded
|
|
1607
|
+
// version resolve on a machine whose cached index is the real one and does
|
|
1608
|
+
// not publish that version yet.
|
|
1609
|
+
indexCache: this.indexCache,
|
|
1610
|
+
warn: this.warn,
|
|
1611
|
+
});
|
|
1612
|
+
return this.seedReport;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
/**
|
|
1616
|
+
* Brings the lockfile in line with the subscriptions the config declares.
|
|
1617
|
+
*
|
|
1618
|
+
* A subscription is normally written by `sous subscribe`, which locks it on
|
|
1619
|
+
* the spot. Two cases leave one declared but unlocked, and both have to work
|
|
1620
|
+
* without anyone typing a command: a subscription hand-written into the config
|
|
1621
|
+
* (or arriving with a colleague's commit), and the built-in `core`
|
|
1622
|
+
* subscription, whose range is the running sous version and therefore changes
|
|
1623
|
+
* every time sous is upgraded.
|
|
1624
|
+
*
|
|
1625
|
+
* So a subscription is resolved here when the lockfile pins nothing for it, or
|
|
1626
|
+
* pins something its range no longer allows. Everything else is left exactly
|
|
1627
|
+
* as the lockfile has it; a build never re-decides a version it already has.
|
|
1628
|
+
*
|
|
1629
|
+
* Nothing here is fatal and nothing here prompts. A build must not stop
|
|
1630
|
+
* because a repository is unreachable, and it must never block on a question,
|
|
1631
|
+
* so a subscription that cannot be resolved comes back in the report as a
|
|
1632
|
+
* sentence to warn about.
|
|
1633
|
+
*/
|
|
1634
|
+
async ensureSubscriptionsLocked(): Promise<SubscriptionSyncReport> {
|
|
1635
|
+
const report: SubscriptionSyncReport = {
|
|
1636
|
+
resolved: [],
|
|
1637
|
+
added: [],
|
|
1638
|
+
moved: [],
|
|
1639
|
+
failed: [],
|
|
1640
|
+
};
|
|
1641
|
+
|
|
1642
|
+
const subscriptions = this.allSubscriptions();
|
|
1643
|
+
const before = this.lock.read();
|
|
1644
|
+
|
|
1645
|
+
const pending: Array<{ key: string; request: RefRequest }> = [];
|
|
1646
|
+
for (const key of Object.keys(subscriptions).sort()) {
|
|
1647
|
+
const entry = subscriptions[key]!;
|
|
1648
|
+
if (this.subscriptionIsLocked(key, entry, before)) continue;
|
|
1649
|
+
|
|
1650
|
+
let parsed: ParsedRef;
|
|
1651
|
+
try {
|
|
1652
|
+
parsed = parseRef(key);
|
|
1653
|
+
} catch (error) {
|
|
1654
|
+
report.failed.push({ key, reason: describeError(error) });
|
|
1655
|
+
continue;
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
pending.push({
|
|
1659
|
+
key,
|
|
1660
|
+
request: {
|
|
1661
|
+
ref: {
|
|
1662
|
+
...parsed,
|
|
1663
|
+
...(entry.range === undefined ? {} : { range: entry.range }),
|
|
1664
|
+
},
|
|
1665
|
+
requestedBy: PROJECT_REQUESTER,
|
|
1666
|
+
kind: "subscribes",
|
|
1667
|
+
...(entry.prerelease === true ? { prerelease: true } : {}),
|
|
1668
|
+
},
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
if (pending.length === 0) return report;
|
|
1673
|
+
|
|
1674
|
+
const repos = this.resolverRepos();
|
|
1675
|
+
const indexes = await this.loadIndexes(Object.keys(repos), { lock: before });
|
|
1676
|
+
|
|
1677
|
+
// Each subscription is resolved on its own. Resolving them together would be
|
|
1678
|
+
// one call, but the resolver raises on the first ref it cannot settle, and a
|
|
1679
|
+
// project should not lose four subscriptions because one of them names a
|
|
1680
|
+
// recipe that no longer exists. What the separate resolutions produce is
|
|
1681
|
+
// merged back together below, so a recipe two subscriptions both depend on
|
|
1682
|
+
// still records both of them as holders.
|
|
1683
|
+
const stored = new Map<string, ResolvedRecipe>();
|
|
1684
|
+
for (const { key, request } of pending) {
|
|
1685
|
+
let result;
|
|
1686
|
+
try {
|
|
1687
|
+
result = await resolveRefs([request], {
|
|
1688
|
+
indexes,
|
|
1689
|
+
repos,
|
|
1690
|
+
loadManifest: (recipe) => this.loadRecipeManifest(recipe, false),
|
|
1691
|
+
});
|
|
1692
|
+
} catch (error) {
|
|
1693
|
+
report.failed.push({ key, reason: describeError(error) });
|
|
1694
|
+
continue;
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
// A repository something needs but the project has not added is a trust
|
|
1698
|
+
// decision, and a build is the wrong moment to ask for one. Say which
|
|
1699
|
+
// command grants it, and leave this subscription alone.
|
|
1700
|
+
if (result.missingRepos.length > 0) {
|
|
1701
|
+
const names = result.missingRepos.map((missing) => missing.name);
|
|
1702
|
+
report.failed.push({
|
|
1703
|
+
key,
|
|
1704
|
+
reason:
|
|
1705
|
+
`Sous has not been told where ${
|
|
1706
|
+
names.length === 1
|
|
1707
|
+
? `the repository '${names[0]}' lives`
|
|
1708
|
+
: `these repositories live: ${names.map((n) => `'${n}'`).join(", ")}`
|
|
1709
|
+
}, so it could not be resolved.\n` +
|
|
1710
|
+
names.map((name) => ` sous repo add <url> --name ${name}`).join("\n"),
|
|
1711
|
+
});
|
|
1712
|
+
continue;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
let failed = false;
|
|
1716
|
+
const settled: ResolvedRecipe[] = [];
|
|
1717
|
+
for (const recipe of result.resolved) {
|
|
1718
|
+
try {
|
|
1719
|
+
await this.ensureStored(recipe);
|
|
1720
|
+
settled.push(recipe);
|
|
1721
|
+
} catch (error) {
|
|
1722
|
+
report.failed.push({ key, reason: describeError(error) });
|
|
1723
|
+
failed = true;
|
|
1724
|
+
break;
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
if (failed) continue;
|
|
1729
|
+
report.resolved.push(key);
|
|
1730
|
+
|
|
1731
|
+
for (const recipe of settled) {
|
|
1732
|
+
const already = stored.get(recipe.key);
|
|
1733
|
+
if (already === undefined) {
|
|
1734
|
+
stored.set(recipe.key, recipe);
|
|
1735
|
+
continue;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
if (already.version !== recipe.version) {
|
|
1739
|
+
report.failed.push({
|
|
1740
|
+
key: recipe.key,
|
|
1741
|
+
reason:
|
|
1742
|
+
`Two of this project's subscriptions want different versions of ` +
|
|
1743
|
+
`'${recipe.key}': ${already.version} and ${recipe.version}. Sous kept ` +
|
|
1744
|
+
`${already.version}.\n` +
|
|
1745
|
+
` Subscribe to '${recipe.key}' directly, with the range you want, so ` +
|
|
1746
|
+
`there is one answer.`,
|
|
1747
|
+
});
|
|
1748
|
+
continue;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
stored.set(recipe.key, mergeHolders(already, recipe));
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
if (stored.size === 0) return report;
|
|
1756
|
+
|
|
1757
|
+
const settledRecipes = [...stored.values()];
|
|
1758
|
+
const after = this.lock.applyResolution(before, settledRecipes, this.lockRepoInputs());
|
|
1759
|
+
for (const recipe of settledRecipes) {
|
|
1760
|
+
const previous = before.recipes[recipe.key];
|
|
1761
|
+
if (previous === undefined) {
|
|
1762
|
+
report.added.push({ key: recipe.key, version: recipe.version });
|
|
1763
|
+
} else if (previous.version !== recipe.version) {
|
|
1764
|
+
report.moved.push({
|
|
1765
|
+
key: recipe.key,
|
|
1766
|
+
from: previous.version,
|
|
1767
|
+
to: recipe.version,
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
this.lock.write(after);
|
|
1773
|
+
return report;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
/**
|
|
1777
|
+
* True when the lockfile already pins everything one subscription asks for, at
|
|
1778
|
+
* a version its range still allows.
|
|
1779
|
+
*
|
|
1780
|
+
* @param key - The subscription's ref key: a namespace, or `namespace/recipe`.
|
|
1781
|
+
* @param entry - The subscription entry, which carries the range.
|
|
1782
|
+
* @param lock - The lockfile as it stands.
|
|
1783
|
+
*/
|
|
1784
|
+
private subscriptionIsLocked(
|
|
1785
|
+
key: string,
|
|
1786
|
+
entry: SubscriptionEntry,
|
|
1787
|
+
lock: Lockfile
|
|
1788
|
+
): boolean {
|
|
1789
|
+
const matches = key.includes("/")
|
|
1790
|
+
? lock.recipes[key] === undefined
|
|
1791
|
+
? []
|
|
1792
|
+
: [lock.recipes[key]!]
|
|
1793
|
+
: Object.entries(lock.recipes)
|
|
1794
|
+
.filter(([lockedKey]) => lockedKey.startsWith(`${key}/`))
|
|
1795
|
+
.map(([, locked]) => locked);
|
|
1796
|
+
|
|
1797
|
+
if (matches.length === 0) return false;
|
|
1798
|
+
|
|
1799
|
+
const range = entry.range;
|
|
1800
|
+
const includePrerelease = entry.prerelease === true;
|
|
1801
|
+
|
|
1802
|
+
return matches.every((locked) => {
|
|
1803
|
+
if (!locked.requestedBy.includes(PROJECT_HOLDER)) return false;
|
|
1804
|
+
if (range === undefined || range === "*") return true;
|
|
1805
|
+
return semver.satisfies(locked.version, range, { includePrerelease });
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
/**
|
|
1810
|
+
* Makes the store hold exactly what the lockfile pins. Nothing here decides a
|
|
1811
|
+
* version and nothing here asks a question; that is what makes a fresh clone
|
|
1812
|
+
* reproducible.
|
|
1813
|
+
*/
|
|
1814
|
+
async restore(): Promise<RestoreReport> {
|
|
1815
|
+
await this.seedCore();
|
|
1816
|
+
const lock = this.lock.read();
|
|
1817
|
+
if (Object.keys(lock.recipes).length === 0) {
|
|
1818
|
+
return { restored: [], alreadyPresent: [] };
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
const indexes = await this.loadIndexes(Object.keys(lock.repos), { lock });
|
|
1822
|
+
return this.lock.restore(lock, {
|
|
1823
|
+
store: this.storeInstance,
|
|
1824
|
+
indexes,
|
|
1825
|
+
providers: this.providers,
|
|
1826
|
+
providerOptions: this.providerOptions,
|
|
1827
|
+
});
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
/**
|
|
1831
|
+
* Looks upstream for the repositories that prefer a newer in-range version,
|
|
1832
|
+
* and moves the lockfile to it when there is one. A failed check never breaks
|
|
1833
|
+
* a build: it is warned about and the last good answer stands.
|
|
1834
|
+
*
|
|
1835
|
+
* @param options - Whether to check regardless of the freshness window, and which window to use.
|
|
1836
|
+
*/
|
|
1837
|
+
async checkUpstream(
|
|
1838
|
+
options: { force?: boolean; freshnessSeconds?: number } = {}
|
|
1839
|
+
): Promise<UpstreamCheckReport> {
|
|
1840
|
+
const report: UpstreamCheckReport = { checked: [], updated: [], failed: [] };
|
|
1841
|
+
const lock = this.lock.read();
|
|
1842
|
+
if (Object.keys(lock.recipes).length === 0) return report;
|
|
1843
|
+
|
|
1844
|
+
const storeSettings = resolveStoreSettings(this.settings);
|
|
1845
|
+
const freshnessSeconds = options.freshnessSeconds ?? storeSettings.freshnessSeconds;
|
|
1846
|
+
const repos = this.currentRepos();
|
|
1847
|
+
const subscriptions = this.allSubscriptions();
|
|
1848
|
+
|
|
1849
|
+
let changed = false;
|
|
1850
|
+
const recipes: Record<string, LockedRecipe> = { ...lock.recipes };
|
|
1851
|
+
|
|
1852
|
+
for (const repoName of Object.keys(lock.repos).sort()) {
|
|
1853
|
+
if (!this.prefersNewer(repoName, repos[repoName], lock, subscriptions)) continue;
|
|
1854
|
+
|
|
1855
|
+
const identity = this.identityForRepo(repoName, lock);
|
|
1856
|
+
if (identity === undefined) continue;
|
|
1857
|
+
|
|
1858
|
+
const meta = this.indexCache.readMeta(identity);
|
|
1859
|
+
const due = shouldCheckUpstream({
|
|
1860
|
+
...(meta?.lastCheckedAt === undefined ? {} : { lastCheckedAt: meta.lastCheckedAt }),
|
|
1861
|
+
freshnessSeconds,
|
|
1862
|
+
alwaysPull: true,
|
|
1863
|
+
...(options.force === undefined ? {} : { force: options.force }),
|
|
1864
|
+
now: this.now(),
|
|
1865
|
+
});
|
|
1866
|
+
if (!due) continue;
|
|
1867
|
+
|
|
1868
|
+
report.checked.push(repoName);
|
|
1869
|
+
|
|
1870
|
+
let index: IndexFile;
|
|
1871
|
+
try {
|
|
1872
|
+
const url = repos[repoName]?.url ?? lock.repos[repoName]!.url;
|
|
1873
|
+
const providerId = repos[repoName]?.provider;
|
|
1874
|
+
index = (
|
|
1875
|
+
await this.indexCache.getIndex(identity, {
|
|
1876
|
+
url,
|
|
1877
|
+
label: repoName,
|
|
1878
|
+
...(providerId === undefined ? {} : { provider: providerId }),
|
|
1879
|
+
force: true,
|
|
1880
|
+
})
|
|
1881
|
+
).index;
|
|
1882
|
+
} catch (error) {
|
|
1883
|
+
report.failed.push({ repo: repoName, reason: describeError(error) });
|
|
1884
|
+
// Recorded even though it failed, so an unreachable host is not retried
|
|
1885
|
+
// on every single build.
|
|
1886
|
+
recordUpstreamCheck(this.indexCache, identity, this.now());
|
|
1887
|
+
continue;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
recordUpstreamCheck(this.indexCache, identity, this.now());
|
|
1891
|
+
|
|
1892
|
+
for (const [key, entry] of Object.entries(recipes)) {
|
|
1893
|
+
if (entry.repo !== repoName) continue;
|
|
1894
|
+
const subscription = subscriptions[key] ?? subscriptions[key.split("/")[0]!];
|
|
1895
|
+
|
|
1896
|
+
// Always-pull re-resolves WITHIN what was declared; it never widens it.
|
|
1897
|
+
// A recipe held only through another recipe's `depends` has no
|
|
1898
|
+
// subscription to read a range from, and treating that as "any version"
|
|
1899
|
+
// would move it straight past the constraint the dependency declared.
|
|
1900
|
+
const range = this.effectiveRangeFor(key, entry, subscriptions);
|
|
1901
|
+
if (range === undefined) continue;
|
|
1902
|
+
|
|
1903
|
+
const newer = findNewerInRange({
|
|
1904
|
+
index,
|
|
1905
|
+
key,
|
|
1906
|
+
lockedVersion: entry.version,
|
|
1907
|
+
...(range === "*" ? {} : { range }),
|
|
1908
|
+
...(subscription?.prerelease === undefined
|
|
1909
|
+
? {}
|
|
1910
|
+
: { prerelease: subscription.prerelease }),
|
|
1911
|
+
});
|
|
1912
|
+
if (newer === undefined) continue;
|
|
1913
|
+
|
|
1914
|
+
const published = index.recipes[key]!.versions[newer.to]!;
|
|
1915
|
+
try {
|
|
1916
|
+
await this.fetchIntoStore({
|
|
1917
|
+
identity,
|
|
1918
|
+
key,
|
|
1919
|
+
version: newer.to,
|
|
1920
|
+
hash: published.hash,
|
|
1921
|
+
tag: published.tag,
|
|
1922
|
+
recipePath: index.recipes[key]!.path,
|
|
1923
|
+
url: repos[repoName]?.url ?? lock.repos[repoName]!.url,
|
|
1924
|
+
providerId: repos[repoName]?.provider,
|
|
1925
|
+
});
|
|
1926
|
+
} catch (error) {
|
|
1927
|
+
report.failed.push({ repo: repoName, reason: describeError(error) });
|
|
1928
|
+
continue;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
recipes[key] = { ...entry, version: newer.to, hash: published.hash };
|
|
1932
|
+
report.updated.push(newer);
|
|
1933
|
+
changed = true;
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
if (changed) this.lock.write({ ...lock, recipes });
|
|
1938
|
+
return report;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
/**
|
|
1942
|
+
* Everything a build needs done before it compiles: seed the packaged core
|
|
1943
|
+
* recipe, restore whatever else the store is missing, then look upstream for
|
|
1944
|
+
* the repositories that want a newer version. All three are quiet when there
|
|
1945
|
+
* is nothing to do.
|
|
1946
|
+
*
|
|
1947
|
+
* @param options - Whether to force the upstream check, and which freshness window to use.
|
|
1948
|
+
*/
|
|
1949
|
+
async prepareForBuild(
|
|
1950
|
+
options: { force?: boolean; freshnessSeconds?: number } = {}
|
|
1951
|
+
): Promise<{
|
|
1952
|
+
seed: SeedCoreRecipeReport;
|
|
1953
|
+
subscriptions: SubscriptionSyncReport;
|
|
1954
|
+
restored: RestoreReport | undefined;
|
|
1955
|
+
upstream: UpstreamCheckReport;
|
|
1956
|
+
}> {
|
|
1957
|
+
const seed = await this.seedCore();
|
|
1958
|
+
const subscriptions = await this.ensureSubscriptionsLocked();
|
|
1959
|
+
let restored: RestoreReport | undefined;
|
|
1960
|
+
if (this.needsRestore()) restored = await this.restore();
|
|
1961
|
+
const upstream = await this.checkUpstream(options);
|
|
1962
|
+
return { seed, subscriptions, restored, upstream };
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
// --- Reading the project's state ---------------------------------------------------------------
|
|
1966
|
+
|
|
1967
|
+
/**
|
|
1968
|
+
* Every repository this project trusts, merging the config it was loaded with
|
|
1969
|
+
* and the managed layer as it stands on disk right now. The layer is re-read
|
|
1970
|
+
* because a trust round in this same process may have just written to it.
|
|
1971
|
+
*/
|
|
1972
|
+
currentRepos(): Record<string, TrustedRepo> {
|
|
1973
|
+
return {
|
|
1974
|
+
...(enabledRepos(this.settings) as Record<string, TrustedRepo>),
|
|
1975
|
+
...this.trust.listManaged(),
|
|
1976
|
+
};
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
/**
|
|
1980
|
+
* Every subscription in force, from the config and from the managed layer.
|
|
1981
|
+
*
|
|
1982
|
+
* The managed layer is re-read rather than taken from the loaded config,
|
|
1983
|
+
* because a command in this same process may have just written to it; an entry
|
|
1984
|
+
* it switches off is dropped here, so removing a subscription sous provides
|
|
1985
|
+
* itself really does stop it being locked and built.
|
|
1986
|
+
*/
|
|
1987
|
+
allSubscriptions(): Record<string, SubscriptionEntry> {
|
|
1988
|
+
const merged: Record<string, SubscriptionEntry> = {
|
|
1989
|
+
...(enabledSubscriptions(this.settings) as Record<string, SubscriptionEntry>),
|
|
1990
|
+
...this.readSubscriptionEntries(),
|
|
1991
|
+
};
|
|
1992
|
+
|
|
1993
|
+
for (const [key, entry] of Object.entries(merged)) {
|
|
1994
|
+
if (entry?.enabled === false) delete merged[key];
|
|
1995
|
+
}
|
|
1996
|
+
return merged;
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
/**
|
|
2000
|
+
* Every subscription this project declares, switched-off ones included, with
|
|
2001
|
+
* the versions the lockfile pins because of each. Switched-off entries are
|
|
2002
|
+
* kept because an opt-out is part of what a project subscribes to, and hiding
|
|
2003
|
+
* it would make `sous subscription list` disagree with the config.
|
|
2004
|
+
*
|
|
2005
|
+
* Reads only what is already on disk, so the listing is safe offline.
|
|
2006
|
+
*/
|
|
2007
|
+
listSubscriptions(): SubscriptionListing[] {
|
|
2008
|
+
const entries: Record<string, SubscriptionEntry> = {
|
|
2009
|
+
...((this.settings.subscriptions ?? {}) as Record<string, SubscriptionEntry>),
|
|
2010
|
+
...this.readSubscriptionEntries(),
|
|
2011
|
+
};
|
|
2012
|
+
|
|
2013
|
+
const lock = this.lock.read();
|
|
2014
|
+
|
|
2015
|
+
return Object.keys(entries)
|
|
2016
|
+
.sort()
|
|
2017
|
+
.map((key) => {
|
|
2018
|
+
const entry = entries[key]!;
|
|
2019
|
+
return {
|
|
2020
|
+
key,
|
|
2021
|
+
range: entry.range,
|
|
2022
|
+
enabled: entry.enabled !== false,
|
|
2023
|
+
addedBy: entry.addedBy,
|
|
2024
|
+
pinned: keysHeldBySubscription(lock, key).map((heldKey) => ({
|
|
2025
|
+
key: heldKey,
|
|
2026
|
+
version: lock.recipes[heldKey]!.version,
|
|
2027
|
+
})),
|
|
2028
|
+
};
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
/** The trusted repositories in the shape the resolver reads. */
|
|
2033
|
+
private resolverRepos(): Record<string, ResolverRepo> {
|
|
2034
|
+
const repos: Record<string, ResolverRepo> = {};
|
|
2035
|
+
for (const [name, entry] of Object.entries(this.currentRepos())) {
|
|
2036
|
+
repos[name] = {
|
|
2037
|
+
url: entry.url,
|
|
2038
|
+
identity: this.identityOf(entry.url, entry.provider),
|
|
2039
|
+
...(entry.provider === undefined ? {} : { provider: entry.provider }),
|
|
2040
|
+
...(entry.alwaysPull === undefined ? {} : { alwaysPull: entry.alwaysPull }),
|
|
2041
|
+
};
|
|
2042
|
+
}
|
|
2043
|
+
return repos;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
/** The repository records the lockfile writes, keyed by short name. */
|
|
2047
|
+
private lockRepoInputs(): Record<string, LockRepoInput> {
|
|
2048
|
+
const inputs: Record<string, LockRepoInput> = {};
|
|
2049
|
+
for (const [name, entry] of Object.entries(this.currentRepos())) {
|
|
2050
|
+
inputs[name] = {
|
|
2051
|
+
url: entry.url,
|
|
2052
|
+
identity: this.identityOf(entry.url, entry.provider),
|
|
2053
|
+
...(entry.provider === undefined ? {} : { provider: entry.provider }),
|
|
2054
|
+
};
|
|
2055
|
+
}
|
|
2056
|
+
return inputs;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
/**
|
|
2060
|
+
* The cached (or freshly fetched) index of each named repository. A repository
|
|
2061
|
+
* whose index cannot be obtained at all is warned about and left out, so one
|
|
2062
|
+
* unreachable host never blocks work on the others.
|
|
2063
|
+
*
|
|
2064
|
+
* @param names - The repositories to load indexes for.
|
|
2065
|
+
* @param options - A lockfile to fall back to for a repository's URL.
|
|
2066
|
+
*/
|
|
2067
|
+
async loadIndexes(
|
|
2068
|
+
names: string[],
|
|
2069
|
+
options: { lock?: Lockfile; force?: boolean } = {}
|
|
2070
|
+
): Promise<Map<string, IndexFile>> {
|
|
2071
|
+
const repos = this.currentRepos();
|
|
2072
|
+
const indexes = new Map<string, IndexFile>();
|
|
2073
|
+
const freshnessSeconds = resolveStoreSettings(this.settings).freshnessSeconds;
|
|
2074
|
+
|
|
2075
|
+
for (const name of names) {
|
|
2076
|
+
const url = repos[name]?.url ?? options.lock?.repos[name]?.url;
|
|
2077
|
+
if (url === undefined) continue;
|
|
2078
|
+
|
|
2079
|
+
try {
|
|
2080
|
+
const identity =
|
|
2081
|
+
this.identityForRepo(name, options.lock) ??
|
|
2082
|
+
this.identityOf(url, repos[name]?.provider);
|
|
2083
|
+
const lookup = await this.indexCache.getIndex(identity, {
|
|
2084
|
+
url,
|
|
2085
|
+
label: name,
|
|
2086
|
+
...(repos[name]?.provider === undefined
|
|
2087
|
+
? {}
|
|
2088
|
+
: { provider: repos[name]!.provider! }),
|
|
2089
|
+
maxAgeSeconds: freshnessSeconds,
|
|
2090
|
+
...(options.force === undefined ? {} : { force: options.force }),
|
|
2091
|
+
});
|
|
2092
|
+
indexes.set(name, lookup.index);
|
|
2093
|
+
} catch (error) {
|
|
2094
|
+
this.warn(
|
|
2095
|
+
`Sous could not read the index of the repository '${name}', so nothing in it ` +
|
|
2096
|
+
`can be resolved right now.\n${describeError(error)}`
|
|
2097
|
+
);
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
return indexes;
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
// --- The store ---------------------------------------------------------------------------------
|
|
2105
|
+
|
|
2106
|
+
/**
|
|
2107
|
+
* The directory a resolved recipe's files are read from: a linked working copy
|
|
2108
|
+
* when the repository is linked, otherwise its store entry.
|
|
2109
|
+
*
|
|
2110
|
+
* @param recipe - The resolved recipe.
|
|
2111
|
+
*/
|
|
2112
|
+
private recipeDirectory(recipe: ResolvedRecipe): string {
|
|
2113
|
+
const checkout = linkedPathFor(recipe.repo, this.sousDir, this.env);
|
|
2114
|
+
if (checkout !== undefined) {
|
|
2115
|
+
const linked = mapLinkedRecipes(checkout)[recipe.key];
|
|
2116
|
+
if (linked !== undefined) return linked;
|
|
2117
|
+
}
|
|
2118
|
+
return this.storeInstance.entryDir(storeKeyFor(recipe));
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
/**
|
|
2122
|
+
* Makes sure a resolved recipe's files are in the store, fetching them when
|
|
2123
|
+
* they are not. A linked repository is read from its checkout and is never
|
|
2124
|
+
* fetched.
|
|
2125
|
+
*
|
|
2126
|
+
* @param recipe - The resolved recipe.
|
|
2127
|
+
*/
|
|
2128
|
+
private async ensureStored(recipe: ResolvedRecipe): Promise<void> {
|
|
2129
|
+
if (linkedPathFor(recipe.repo, this.sousDir, this.env) !== undefined) return;
|
|
2130
|
+
|
|
2131
|
+
const key = storeKeyFor(recipe);
|
|
2132
|
+
const hit = await this.storeInstance.get(key);
|
|
2133
|
+
if (hit !== undefined && hit.entry.hash === recipe.hash) return;
|
|
2134
|
+
|
|
2135
|
+
// The store refuses to overwrite an entry whose content differs, because a
|
|
2136
|
+
// published version is immutable and one that changed underneath a project
|
|
2137
|
+
// is worth refusing loudly. There is exactly one entry that is not a
|
|
2138
|
+
// published version: the packaged core recipe sous seeds so a project can
|
|
2139
|
+
// build before it has ever reached the network. That copy is a stand-in for
|
|
2140
|
+
// the published one, not a rival to it, so when the two disagree at the same
|
|
2141
|
+
// version it steps aside and the published copy is fetched over it.
|
|
2142
|
+
if (hit !== undefined && (await this.isSeededCoreEntry(key, hit.entry.hash))) {
|
|
2143
|
+
await this.storeInstance.remove(key);
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
const repos = this.currentRepos();
|
|
2147
|
+
const url = repos[recipe.repo]?.url;
|
|
2148
|
+
if (url === undefined) {
|
|
2149
|
+
throw new ConfigError(
|
|
2150
|
+
`Sous cannot fetch '${recipe.key}' because it no longer knows where the ` +
|
|
2151
|
+
`repository '${recipe.repo}' lives.\n` +
|
|
2152
|
+
` Add it with 'sous repo add <url> --name ${recipe.repo}'.`
|
|
2153
|
+
);
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
await this.fetchIntoStore({
|
|
2157
|
+
identity: recipe.identity,
|
|
2158
|
+
key: recipe.key,
|
|
2159
|
+
version: recipe.version,
|
|
2160
|
+
hash: recipe.hash,
|
|
2161
|
+
tag: recipe.tag,
|
|
2162
|
+
recipePath: recipe.path,
|
|
2163
|
+
url,
|
|
2164
|
+
providerId: repos[recipe.repo]?.provider,
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
/**
|
|
2169
|
+
* True when a store entry is the packaged core recipe that seeding put there,
|
|
2170
|
+
* rather than anything fetched from a repository.
|
|
2171
|
+
*
|
|
2172
|
+
* The test is deliberately exact: the entry has to be the core recipe in the
|
|
2173
|
+
* official repository, AND its content has to hash to what this installation's
|
|
2174
|
+
* package holds. An entry that was genuinely fetched, or a seeded entry from a
|
|
2175
|
+
* different installation, is left alone.
|
|
2176
|
+
*
|
|
2177
|
+
* @param key - The store key being written to.
|
|
2178
|
+
* @param storedHash - The hash the entry currently holds.
|
|
2179
|
+
*/
|
|
2180
|
+
private async isSeededCoreEntry(key: StoreKey, storedHash: string): Promise<boolean> {
|
|
2181
|
+
if (key.identity !== OFFICIAL_REPO_IDENTITY) return false;
|
|
2182
|
+
if (`${key.namespace}/${key.name}` !== CORE_RECIPE_KEY) return false;
|
|
2183
|
+
|
|
2184
|
+
try {
|
|
2185
|
+
return (await hashDirectory(packagedCoreRecipeDir())) === storedHash;
|
|
2186
|
+
} catch {
|
|
2187
|
+
// No packaged recipe to compare against means nothing here was seeded.
|
|
2188
|
+
return false;
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
/**
|
|
2193
|
+
* Fetches one recipe version into the store, verifying it against the hash the
|
|
2194
|
+
* index publishes. The download lands in a temporary directory beside the
|
|
2195
|
+
* store, so a failed fetch never leaves a half-written entry behind.
|
|
2196
|
+
*
|
|
2197
|
+
* @param request - Which version to fetch, from where, and at which tag.
|
|
2198
|
+
*/
|
|
2199
|
+
private async fetchIntoStore(request: {
|
|
2200
|
+
identity: string;
|
|
2201
|
+
key: string;
|
|
2202
|
+
version: string;
|
|
2203
|
+
hash: string;
|
|
2204
|
+
tag: string;
|
|
2205
|
+
recipePath: string;
|
|
2206
|
+
url: string;
|
|
2207
|
+
providerId?: string;
|
|
2208
|
+
}): Promise<void> {
|
|
2209
|
+
const provider = requireProvider(request.url, request.providerId, this.providers);
|
|
2210
|
+
const canonical = provider.canonicalize(request.url);
|
|
2211
|
+
|
|
2212
|
+
const namespace = request.key.slice(0, request.key.indexOf("/"));
|
|
2213
|
+
const key: StoreKey = {
|
|
2214
|
+
identity: request.identity,
|
|
2215
|
+
namespace,
|
|
2216
|
+
name: request.key.slice(namespace.length + 1),
|
|
2217
|
+
version: request.version,
|
|
2218
|
+
};
|
|
2219
|
+
|
|
2220
|
+
ensureStoreRootDirectory(this.storeInstance.root);
|
|
2221
|
+
const workDir = await fsp.mkdtemp(path.join(this.storeInstance.root, ".sous-fetch-"));
|
|
2222
|
+
const fetchDir = path.join(workDir, key.name);
|
|
2223
|
+
try {
|
|
2224
|
+
await provider.fetchRecipeTree(
|
|
2225
|
+
canonical,
|
|
2226
|
+
request.recipePath,
|
|
2227
|
+
request.tag,
|
|
2228
|
+
fetchDir,
|
|
2229
|
+
this.providerOptions
|
|
2230
|
+
);
|
|
2231
|
+
await this.storeInstance.put(key, fetchDir, request.hash);
|
|
2232
|
+
} finally {
|
|
2233
|
+
await fsp.rm(workDir, { recursive: true, force: true });
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
/**
|
|
2238
|
+
* The manifest loader the resolver walks the dependency closure with. It
|
|
2239
|
+
* fetches the recipe when the store does not hold it, which is the seam where
|
|
2240
|
+
* "resolve" turns into "download"; a dry run refuses to fetch and reads only
|
|
2241
|
+
* what is already there.
|
|
2242
|
+
*
|
|
2243
|
+
* @param recipe - The resolved recipe whose manifest is wanted.
|
|
2244
|
+
* @param dryRun - When true, read what is on disk and download nothing.
|
|
2245
|
+
*/
|
|
2246
|
+
private async loadRecipeManifest(
|
|
2247
|
+
recipe: ResolvedRecipe,
|
|
2248
|
+
dryRun: boolean
|
|
2249
|
+
): Promise<RecipeManifest | undefined> {
|
|
2250
|
+
const directory = this.recipeDirectory(recipe);
|
|
2251
|
+
const existing = readRecipeManifestIn(directory);
|
|
2252
|
+
if (existing !== undefined) return existing;
|
|
2253
|
+
if (dryRun) return undefined;
|
|
2254
|
+
|
|
2255
|
+
await this.ensureStored(recipe);
|
|
2256
|
+
return readRecipeManifestIn(this.recipeDirectory(recipe));
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
// --- The managed subscriptions layer -----------------------------------------------------------
|
|
2260
|
+
|
|
2261
|
+
/** Every subscription written in the managed layer, keyed by ref key. */
|
|
2262
|
+
private readSubscriptionEntries(): Record<string, SubscriptionEntry> {
|
|
2263
|
+
const layer = readManagedLayer(this.sousDir, SUBSCRIPTIONS_LAYER_FILENAME, {
|
|
2264
|
+
confDir: this.confDir,
|
|
2265
|
+
});
|
|
2266
|
+
const entries = layer["subscriptions"];
|
|
2267
|
+
if (typeof entries !== "object" || entries === null || Array.isArray(entries)) return {};
|
|
2268
|
+
return entries as Record<string, SubscriptionEntry>;
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
/**
|
|
2272
|
+
* Records one subscription in the managed layer, editing only that entry.
|
|
2273
|
+
*
|
|
2274
|
+
* @param key - The ref key the subscription is recorded under.
|
|
2275
|
+
* @param parsed - The ref as parsed, for its version range.
|
|
2276
|
+
* @param options - The prerelease and always-pull flags.
|
|
2277
|
+
*/
|
|
2278
|
+
private writeSubscriptionEntry(
|
|
2279
|
+
key: string,
|
|
2280
|
+
parsed: ParsedRef,
|
|
2281
|
+
options: SubscribeOptions
|
|
2282
|
+
): void {
|
|
2283
|
+
const entry: SubscriptionEntry = {
|
|
2284
|
+
...(parsed.range === undefined ? {} : { range: parsed.range }),
|
|
2285
|
+
...(options.prerelease === true ? { prerelease: true } : {}),
|
|
2286
|
+
...(options.alwaysPull === true ? { alwaysPull: true } : {}),
|
|
2287
|
+
addedAt: this.now().toISOString(),
|
|
2288
|
+
addedBy: USER_ADDED_BY,
|
|
2289
|
+
};
|
|
2290
|
+
|
|
2291
|
+
updateManagedLayer(
|
|
2292
|
+
this.sousDir,
|
|
2293
|
+
SUBSCRIPTIONS_LAYER_FILENAME,
|
|
2294
|
+
[{ path: ["subscriptions", key], value: entry }],
|
|
2295
|
+
{ confDir: this.confDir }
|
|
2296
|
+
);
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
// --- Lockfile bookkeeping ----------------------------------------------------------------------
|
|
2300
|
+
|
|
2301
|
+
/**
|
|
2302
|
+
* The lockfile keys one subscription holds directly. The rule itself lives in
|
|
2303
|
+
* `keysHeldBySubscription` (`locked-recipes.ts`), beside the other readers of
|
|
2304
|
+
* the lockfile's holder lists.
|
|
2305
|
+
*
|
|
2306
|
+
* @param lock - The lockfile as it stands.
|
|
2307
|
+
* @param key - The subscription's ref key.
|
|
2308
|
+
*/
|
|
2309
|
+
private keysHeldBySubscription(lock: Lockfile, key: string): string[] {
|
|
2310
|
+
return keysHeldBySubscription(lock, key);
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
/**
|
|
2314
|
+
* Drops the project's own hold on one recipe. When something else still holds
|
|
2315
|
+
* it the entry stays; when nothing does, the entry goes and everything it
|
|
2316
|
+
* pulled in is reconsidered, which is what makes removal refcounted.
|
|
2317
|
+
*
|
|
2318
|
+
* @param lock - The lockfile as it stands.
|
|
2319
|
+
* @param key - The recipe key to release.
|
|
2320
|
+
*/
|
|
2321
|
+
private dropProjectHold(lock: Lockfile, key: string): Lockfile {
|
|
2322
|
+
const entry = lock.recipes[key];
|
|
2323
|
+
if (entry === undefined) return lock;
|
|
2324
|
+
|
|
2325
|
+
const kept = entry.requestedBy.filter((holder) => holder !== PROJECT_HOLDER);
|
|
2326
|
+
const recipes = { ...lock.recipes };
|
|
2327
|
+
|
|
2328
|
+
if (kept.length > 0) {
|
|
2329
|
+
recipes[key] = { ...entry, requestedBy: kept };
|
|
2330
|
+
return { ...lock, recipes };
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
delete recipes[key];
|
|
2334
|
+
return this.lock.removeHolder({ ...lock, recipes }, key);
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
/**
|
|
2338
|
+
* True when a repository prefers a newer in-range version over the locked one,
|
|
2339
|
+
* whether the flag is on the repository itself or on any subscription that
|
|
2340
|
+
* resolves inside it.
|
|
2341
|
+
*
|
|
2342
|
+
* @param repoName - The repository's short name.
|
|
2343
|
+
* @param repo - Its config entry, when it still has one.
|
|
2344
|
+
* @param lock - The lockfile, for which recipes came from it.
|
|
2345
|
+
* @param subscriptions - Every subscription, from the config and the managed layer.
|
|
2346
|
+
*/
|
|
2347
|
+
private prefersNewer(
|
|
2348
|
+
repoName: string,
|
|
2349
|
+
repo: TrustedRepo | undefined,
|
|
2350
|
+
lock: Lockfile,
|
|
2351
|
+
subscriptions: Record<string, SubscriptionEntry>
|
|
2352
|
+
): boolean {
|
|
2353
|
+
if (repo?.alwaysPull === true) return true;
|
|
2354
|
+
|
|
2355
|
+
for (const [key, entry] of Object.entries(subscriptions)) {
|
|
2356
|
+
if (entry.alwaysPull !== true) continue;
|
|
2357
|
+
const keys = this.keysHeldBySubscription(lock, key);
|
|
2358
|
+
if (keys.some((held) => lock.recipes[held]!.repo === repoName)) return true;
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
return false;
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
/**
|
|
2365
|
+
* The version range an always-pull check may move one locked recipe within,
|
|
2366
|
+
* or undefined when sous cannot tell and therefore must not move it.
|
|
2367
|
+
*
|
|
2368
|
+
* The rule itself lives in `effectiveRangeForHolders`; this supplies it with
|
|
2369
|
+
* the two lookups it needs, one reading the project's subscriptions and one
|
|
2370
|
+
* reading the manifest of each holding recipe.
|
|
2371
|
+
*
|
|
2372
|
+
* @param key - The locked recipe key, `namespace/recipe`.
|
|
2373
|
+
* @param entry - Its lockfile entry, for its holders.
|
|
2374
|
+
* @param subscriptions - Every subscription, from the config and the managed layer.
|
|
2375
|
+
*/
|
|
2376
|
+
private effectiveRangeFor(
|
|
2377
|
+
key: string,
|
|
2378
|
+
entry: LockedRecipe,
|
|
2379
|
+
subscriptions: Record<string, SubscriptionEntry>
|
|
2380
|
+
): string | undefined {
|
|
2381
|
+
const namespace = key.split("/")[0]!;
|
|
2382
|
+
return effectiveRangeForHolders(key, entry.requestedBy, {
|
|
2383
|
+
subscriptionRange: (held) => {
|
|
2384
|
+
// A subscription the config no longer declares is not a hold sous can
|
|
2385
|
+
// read a range from; leave the entry alone rather than guessing.
|
|
2386
|
+
const subscription = subscriptions[held] ?? subscriptions[namespace];
|
|
2387
|
+
return subscription === undefined ? undefined : (subscription.range ?? "*");
|
|
2388
|
+
},
|
|
2389
|
+
dependencyRange: (holder, held) =>
|
|
2390
|
+
this.declaredDependencyRange(holder, held, namespace),
|
|
2391
|
+
});
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
/**
|
|
2395
|
+
* The range one recipe's manifest declares for a dependency, or undefined when
|
|
2396
|
+
* its manifest cannot be read or no longer names that dependency.
|
|
2397
|
+
*
|
|
2398
|
+
* @param holder - The holding recipe's key, `namespace/recipe`.
|
|
2399
|
+
* @param key - The held recipe's key.
|
|
2400
|
+
* @param namespace - The held recipe's namespace, since a `depends` entry may
|
|
2401
|
+
* name a whole namespace rather than one recipe.
|
|
2402
|
+
*/
|
|
2403
|
+
private declaredDependencyRange(
|
|
2404
|
+
holder: string,
|
|
2405
|
+
key: string,
|
|
2406
|
+
namespace: string
|
|
2407
|
+
): string | undefined {
|
|
2408
|
+
const directory = this.lockedRecipeDirectories()[holder];
|
|
2409
|
+
if (directory === undefined) return undefined;
|
|
2410
|
+
|
|
2411
|
+
let manifest;
|
|
2412
|
+
try {
|
|
2413
|
+
manifest = readRecipeManifestIn(directory);
|
|
2414
|
+
} catch {
|
|
2415
|
+
return undefined;
|
|
2416
|
+
}
|
|
2417
|
+
if (manifest === undefined) return undefined;
|
|
2418
|
+
|
|
2419
|
+
for (const dependency of manifest.depends ?? []) {
|
|
2420
|
+
let parsed: ParsedRef;
|
|
2421
|
+
try {
|
|
2422
|
+
parsed = parseRef(dependency);
|
|
2423
|
+
} catch {
|
|
2424
|
+
continue;
|
|
2425
|
+
}
|
|
2426
|
+
const dependencyKey = refKey(parsed);
|
|
2427
|
+
if (dependencyKey !== key && dependencyKey !== namespace) continue;
|
|
2428
|
+
return parsed.range ?? "*";
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
return undefined;
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
/**
|
|
2435
|
+
* Where each locked recipe's files are, keyed by recipe key. Read once per
|
|
2436
|
+
* command, because an always-pull check asks for the same answer for every
|
|
2437
|
+
* entry in the lockfile.
|
|
2438
|
+
*/
|
|
2439
|
+
private lockedRecipeDirectories(): Record<string, string> {
|
|
2440
|
+
if (this.lockedDirectories === undefined) {
|
|
2441
|
+
this.lockedDirectories = {};
|
|
2442
|
+
for (const located of listLockedRecipes({ sousDir: this.sousDir, env: this.env })) {
|
|
2443
|
+
if (located.present) this.lockedDirectories[located.key] = located.dir;
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
return this.lockedDirectories;
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
// --- Variables ----------------------------------------------------------------------------------
|
|
2450
|
+
|
|
2451
|
+
/**
|
|
2452
|
+
* Every variable definition the resolved closure publishes, attributed to the
|
|
2453
|
+
* recipe that declared it and to the chain that pulled it in.
|
|
2454
|
+
*
|
|
2455
|
+
* A recipe whose files are not on this machine contributes nothing rather
|
|
2456
|
+
* than failing, which is what lets a dry run describe as much of the closure
|
|
2457
|
+
* as it can without downloading any of it.
|
|
2458
|
+
*
|
|
2459
|
+
* @param resolved - The recipe versions the resolution settled on.
|
|
2460
|
+
*/
|
|
2461
|
+
private definedVariables(resolved: ResolvedRecipe[]): DefinedVariable[] {
|
|
2462
|
+
const defined: DefinedVariable[] = [];
|
|
2463
|
+
const byKey = new Map(resolved.map((recipe) => [recipe.key, recipe]));
|
|
2464
|
+
const repos = this.currentRepos();
|
|
2465
|
+
|
|
2466
|
+
/** One resolved recipe, described the way the variables layer shows it. */
|
|
2467
|
+
const describe = (recipe: ResolvedRecipe): DefiningRecipe => {
|
|
2468
|
+
const url = repos[recipe.repo]?.url;
|
|
2469
|
+
return {
|
|
2470
|
+
repo: recipe.repo,
|
|
2471
|
+
namespace: recipe.namespace,
|
|
2472
|
+
name: recipe.name,
|
|
2473
|
+
version: recipe.version,
|
|
2474
|
+
path: recipe.path,
|
|
2475
|
+
dir: this.recipeDirectory(recipe),
|
|
2476
|
+
...(url === undefined ? {} : { url }),
|
|
2477
|
+
};
|
|
2478
|
+
};
|
|
2479
|
+
|
|
2480
|
+
/** How a recipe came to be here: the subscribed recipe first, then each holder. */
|
|
2481
|
+
const chainFor = (recipe: ResolvedRecipe): ResolvedRecipe[] => {
|
|
2482
|
+
const chain = [recipe];
|
|
2483
|
+
const seen = new Set([recipe.key]);
|
|
2484
|
+
let current = recipe;
|
|
2485
|
+
|
|
2486
|
+
while (!current.requestedBy.includes(PROJECT_HOLDER)) {
|
|
2487
|
+
const holderKey = current.requestedBy.find(
|
|
2488
|
+
(holder) => holder !== PROJECT_HOLDER && byKey.has(holder) && !seen.has(holder)
|
|
2489
|
+
);
|
|
2490
|
+
if (holderKey === undefined) break;
|
|
2491
|
+
current = byKey.get(holderKey)!;
|
|
2492
|
+
seen.add(holderKey);
|
|
2493
|
+
chain.unshift(current);
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
return chain;
|
|
2497
|
+
};
|
|
2498
|
+
|
|
2499
|
+
// The subscribed recipe's own questions come first, then each dependency in
|
|
2500
|
+
// the order the closure reached it, so the run reads the way it happened.
|
|
2501
|
+
const ordered = [...resolved].sort((left, right) => {
|
|
2502
|
+
const depth = chainFor(left).length - chainFor(right).length;
|
|
2503
|
+
return depth !== 0 ? depth : left.key.localeCompare(right.key);
|
|
2504
|
+
});
|
|
2505
|
+
|
|
2506
|
+
for (const recipe of ordered) {
|
|
2507
|
+
const manifest = readRecipeManifestIn(this.recipeDirectory(recipe));
|
|
2508
|
+
if (manifest === undefined) continue;
|
|
2509
|
+
|
|
2510
|
+
const publisher = describe(recipe);
|
|
2511
|
+
const requiredBy = chainFor(recipe).map(describe);
|
|
2512
|
+
for (const definition of manifest.variables ?? []) {
|
|
2513
|
+
defined.push({ definition, recipe: publisher, requiredBy });
|
|
2514
|
+
}
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
return defined;
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
/**
|
|
2521
|
+
* The recipes in a resolution whose manifests cannot be read from this
|
|
2522
|
+
* machine, because neither the store nor a linked checkout holds their files
|
|
2523
|
+
* yet. A dry run downloads nothing, so this is exactly the set whose
|
|
2524
|
+
* questions it cannot describe; everything else is described in full.
|
|
2525
|
+
*
|
|
2526
|
+
* @param resolved - The recipe versions the resolution settled on.
|
|
2527
|
+
*/
|
|
2528
|
+
private unreadableRecipes(resolved: ResolvedRecipe[]): string[] {
|
|
2529
|
+
return resolved
|
|
2530
|
+
.filter((recipe) => readRecipeManifestIn(this.recipeDirectory(recipe)) === undefined)
|
|
2531
|
+
.map((recipe) => recipe.key)
|
|
2532
|
+
.sort();
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
/** The environment layers and mapping records this project resolves against. */
|
|
2536
|
+
private ladderContext(): LadderContext {
|
|
2537
|
+
return loadLadderContext({
|
|
2538
|
+
sousDir: this.sousDir,
|
|
2539
|
+
settings: this.settings,
|
|
2540
|
+
shellEnv: this.shellEnv,
|
|
2541
|
+
});
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
/**
|
|
2545
|
+
* Asks for the variables the newly resolved recipes publish, keeping and
|
|
2546
|
+
* reporting whatever answers were already in scope. Answers supplied ahead of
|
|
2547
|
+
* the questions are validated and stored first, so only what is left over is
|
|
2548
|
+
* asked for. A run with no terminal fails naming the exact environment
|
|
2549
|
+
* variables that would answer each remaining question, which is what the
|
|
2550
|
+
* variables layer does everywhere.
|
|
2551
|
+
*
|
|
2552
|
+
* @param resolved - The recipe versions the resolution settled on.
|
|
2553
|
+
* @param provided - Answers supplied ahead of the questions.
|
|
2554
|
+
*/
|
|
2555
|
+
private async askVariables(
|
|
2556
|
+
resolved: ResolvedRecipe[],
|
|
2557
|
+
provided: ProvidedAnswer[]
|
|
2558
|
+
): Promise<AskReport | undefined> {
|
|
2559
|
+
const defined = this.definedVariables(resolved);
|
|
2560
|
+
if (defined.length === 0 && provided.length === 0) return undefined;
|
|
2561
|
+
|
|
2562
|
+
const context = this.ladderContext();
|
|
2563
|
+
const options = {
|
|
2564
|
+
sousDir: this.sousDir,
|
|
2565
|
+
confDir: this.confDir,
|
|
2566
|
+
interactive: this.interactive,
|
|
2567
|
+
};
|
|
2568
|
+
|
|
2569
|
+
// A supplied answer naming a variable nothing declares fails here, before
|
|
2570
|
+
// any question is asked and before anything is stored.
|
|
2571
|
+
const supplied = applyProvidedAnswers(defined, provided, context, options);
|
|
2572
|
+
|
|
2573
|
+
const report = await askForMissing(defined, context, {
|
|
2574
|
+
...options,
|
|
2575
|
+
skip: supplied.keys,
|
|
2576
|
+
});
|
|
2577
|
+
report.answered.unshift(...supplied.stored);
|
|
2578
|
+
return report;
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
// --- Helpers ------------------------------------------------------------------------------------
|
|
2583
|
+
|
|
2584
|
+
/**
|
|
2585
|
+
* Builds the subscription service for a running command, from what every
|
|
2586
|
+
* command already has: where its config was discovered, the merged settings,
|
|
2587
|
+
* and the shell environment as it was before the `.sous/` env files were loaded.
|
|
2588
|
+
*
|
|
2589
|
+
* @param options - The discovered config context, the settings, and the shell environment.
|
|
2590
|
+
*/
|
|
2591
|
+
export function subscriptionServiceFor(options: {
|
|
2592
|
+
/** Where the active config was found. */
|
|
2593
|
+
configContext: ConfigContext;
|
|
2594
|
+
/** The merged project config. */
|
|
2595
|
+
settings: Settings;
|
|
2596
|
+
/** The shell environment as it was before the env files were injected. */
|
|
2597
|
+
shellEnv?: NodeJS.ProcessEnv;
|
|
2598
|
+
/** Whether sous may ask questions. Defaults to whether both streams are a terminal. */
|
|
2599
|
+
interactive?: boolean;
|
|
2600
|
+
}): SubscriptionService {
|
|
2601
|
+
return new SubscriptionService({
|
|
2602
|
+
sousDir: options.configContext.sousDir,
|
|
2603
|
+
...(options.configContext.confDir === undefined
|
|
2604
|
+
? {}
|
|
2605
|
+
: { confDir: options.configContext.confDir }),
|
|
2606
|
+
settings: options.settings,
|
|
2607
|
+
...(options.shellEnv === undefined ? {} : { shellEnv: options.shellEnv }),
|
|
2608
|
+
...(options.interactive === undefined ? {} : { interactive: options.interactive }),
|
|
2609
|
+
});
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
/** One subscription entry, as it is written into the managed layer. */
|
|
2613
|
+
export type SubscriptionEntry = {
|
|
2614
|
+
/**
|
|
2615
|
+
* Whether the subscription takes part in anything. Defaults to true. Sous
|
|
2616
|
+
* writes `false` when a subscription it provides itself is removed, because
|
|
2617
|
+
* the default comes back on every run and only a recorded opt-out outlives it.
|
|
2618
|
+
*/
|
|
2619
|
+
enabled?: boolean;
|
|
2620
|
+
/** The semantic version range to resolve within. */
|
|
2621
|
+
range?: string;
|
|
2622
|
+
/** Whether prerelease versions take part in range matching. */
|
|
2623
|
+
prerelease?: boolean;
|
|
2624
|
+
/** Whether a newer in-range version is preferred over the locked one. */
|
|
2625
|
+
alwaysPull?: boolean;
|
|
2626
|
+
/** When the subscription was added. */
|
|
2627
|
+
addedAt?: string;
|
|
2628
|
+
/** Who required it: "user", or the ref of the recipe that co-subscribed it. */
|
|
2629
|
+
addedBy?: string;
|
|
2630
|
+
};
|
|
2631
|
+
|
|
2632
|
+
/**
|
|
2633
|
+
* Combines two resolutions of the SAME recipe version into one, so the lockfile
|
|
2634
|
+
* records every holder rather than only the last resolution's.
|
|
2635
|
+
*
|
|
2636
|
+
* This matters for removal: a recipe several subscriptions depend on has to
|
|
2637
|
+
* survive unsubscribing from one of them, and the lockfile's refcounting is what
|
|
2638
|
+
* decides that. A recipe anyone holds as a co-subscription is a co-subscription;
|
|
2639
|
+
* it is only a build dependency while nothing subscribes to it.
|
|
2640
|
+
*
|
|
2641
|
+
* @param left - The resolution already recorded.
|
|
2642
|
+
* @param right - The resolution to fold into it.
|
|
2643
|
+
*/
|
|
2644
|
+
function mergeHolders(left: ResolvedRecipe, right: ResolvedRecipe): ResolvedRecipe {
|
|
2645
|
+
const requestedBy = [...new Set([...left.requestedBy, ...right.requestedBy])].sort();
|
|
2646
|
+
|
|
2647
|
+
const ranges = [...left.ranges];
|
|
2648
|
+
for (const entry of right.ranges) {
|
|
2649
|
+
const known = ranges.some(
|
|
2650
|
+
(seen) => seen.range === entry.range && seen.requestedBy === entry.requestedBy
|
|
2651
|
+
);
|
|
2652
|
+
if (!known) ranges.push(entry);
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
return {
|
|
2656
|
+
...left,
|
|
2657
|
+
requestedBy,
|
|
2658
|
+
ranges,
|
|
2659
|
+
kind:
|
|
2660
|
+
left.kind === "subscribes" || right.kind === "subscribes" ? "subscribes" : "depends",
|
|
2661
|
+
};
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
/** The store key a resolved recipe is filed under. */
|
|
2665
|
+
function storeKeyFor(recipe: ResolvedRecipe): StoreKey {
|
|
2666
|
+
return {
|
|
2667
|
+
identity: recipe.identity,
|
|
2668
|
+
namespace: recipe.namespace,
|
|
2669
|
+
name: recipe.name,
|
|
2670
|
+
version: recipe.version,
|
|
2671
|
+
};
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
/** The message of an error, whichever kind it turned out to be. */
|
|
2675
|
+
function describeError(error: unknown): string {
|
|
2676
|
+
if (isConfigError(error)) return (error as ConfigError).message;
|
|
2677
|
+
return error instanceof Error ? error.message : String(error);
|
|
2678
|
+
}
|