@sous-io/sous 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -35
- package/bin/run.js +10 -1
- package/docs/markdown/README.md +27 -0
- package/docs/markdown/_sidebar.md +18 -0
- package/docs/markdown/commands.md +308 -0
- package/docs/markdown/config-discovery.md +74 -0
- package/docs/markdown/config-inspection.md +69 -0
- package/docs/markdown/config-layers.md +92 -0
- package/docs/markdown/config-variables.md +79 -0
- package/docs/markdown/configuration.md +71 -0
- package/docs/markdown/design-principles.md +59 -0
- package/docs/markdown/repositories-authoring.md +409 -0
- package/docs/markdown/repositories-consuming.md +580 -0
- package/docs/markdown/repositories-file-formats.md +1084 -0
- package/docs/markdown/repositories-variables.md +387 -0
- package/docs/markdown/repositories.md +303 -0
- package/docs/markdown/skill-categories.md +58 -0
- package/package.json +72 -8
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/SKILL.tpl.md +20 -20
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/about-something.md +2 -2
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/examples/do-something.md +1 -1
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/advanced-patterns.md +6 -6
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/commands.md +5 -5
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/frontmatter.md +3 -3
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/SKILL.tpl.md +40 -25
- package/recipes/core/sous-skills/skills/about-sous/SKILL.tpl.md +70 -0
- package/recipes/core/sous-skills/skills/about-sous-configuration/SKILL.tpl.md +75 -0
- package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/create-skill/SKILL.tpl.md +8 -9
- package/recipes/core/sous-skills/sous.recipe.yaml +45 -0
- package/sous.config.schema.json +337 -0
- package/src/base-command.ts +220 -67
- package/src/commands/build.ts +150 -73
- package/src/commands/clear.ts +23 -15
- package/src/commands/compile.ts +74 -16
- package/src/commands/config/get.ts +110 -0
- package/src/commands/config/show.ts +32 -0
- package/src/commands/config/validate.ts +53 -0
- package/src/commands/help.ts +46 -0
- package/src/commands/launch.ts +36 -14
- package/src/commands/lock/rebuild.ts +241 -0
- package/src/commands/lock/show.ts +115 -0
- package/src/commands/namespace/list.ts +117 -0
- package/src/commands/namespace/show.ts +110 -0
- package/src/commands/prune.ts +3 -11
- package/src/commands/recipe/list.ts +95 -0
- package/src/commands/recipe/show.ts +301 -0
- package/src/commands/repo/add.ts +145 -0
- package/src/commands/repo/gc.ts +172 -0
- package/src/commands/repo/init.ts +136 -0
- package/src/commands/repo/link.ts +500 -0
- package/src/commands/repo/list.ts +179 -0
- package/src/commands/repo/release.ts +625 -0
- package/src/commands/repo/remove.ts +193 -0
- package/src/commands/repo/search.ts +189 -0
- package/src/commands/repo/submit.ts +133 -0
- package/src/commands/repo/unlink.ts +147 -0
- package/src/commands/subscription/add.ts +285 -0
- package/src/commands/subscription/list.ts +129 -0
- package/src/commands/subscription/remove.ts +181 -0
- package/src/commands/vars/ask.ts +374 -0
- package/src/commands/vars/index.ts +79 -0
- package/src/commands/vars/list.ts +67 -0
- package/src/commands/vars/show.ts +77 -0
- package/src/config-command.ts +30 -0
- package/src/lib/build-service.ts +206 -54
- package/src/lib/config-discovery.ts +220 -27
- package/src/lib/config-inspect.ts +145 -0
- package/src/lib/config-kernel.mjs +377 -0
- package/src/lib/config-schema.ts +361 -0
- package/src/lib/env-file.ts +328 -0
- package/src/lib/env-local.ts +18 -1
- package/src/lib/errors.ts +32 -0
- package/src/lib/include-resolver.ts +108 -15
- package/src/lib/interactive.ts +165 -0
- package/src/lib/markdown-compiler.ts +118 -37
- package/src/lib/package-info.ts +25 -0
- package/src/lib/pid-service.ts +32 -21
- package/src/lib/refs/find.ts +589 -0
- package/src/lib/refs/index.ts +12 -0
- package/src/lib/refs/pick.ts +147 -0
- package/src/lib/refs/scopes.ts +61 -0
- package/src/lib/repos/catalog-display.ts +116 -0
- package/src/lib/repos/catalog-inputs.ts +160 -0
- package/src/lib/repos/catalog.ts +722 -0
- package/src/lib/repos/core-recipe.ts +105 -0
- package/src/lib/repos/defaults.ts +175 -0
- package/src/lib/repos/formats/common.ts +389 -0
- package/src/lib/repos/formats/index-file.ts +215 -0
- package/src/lib/repos/formats/links-map.ts +96 -0
- package/src/lib/repos/formats/lockfile.ts +167 -0
- package/src/lib/repos/formats/patterns.ts +57 -0
- package/src/lib/repos/formats/recipe-manifest.ts +395 -0
- package/src/lib/repos/formats/repo-manifest.ts +88 -0
- package/src/lib/repos/formats/store-entry.ts +84 -0
- package/src/lib/repos/freshness.ts +208 -0
- package/src/lib/repos/git-clone.ts +312 -0
- package/src/lib/repos/identity.ts +89 -0
- package/src/lib/repos/index.ts +58 -0
- package/src/lib/repos/links.ts +353 -0
- package/src/lib/repos/load-manifest.ts +236 -0
- package/src/lib/repos/lock-service.ts +453 -0
- package/src/lib/repos/locked-namespace-resolver.ts +90 -0
- package/src/lib/repos/locked-recipes.ts +254 -0
- package/src/lib/repos/managed-layer.ts +422 -0
- package/src/lib/repos/namespace-resolver.ts +370 -0
- package/src/lib/repos/providers/base.ts +206 -0
- package/src/lib/repos/providers/git.ts +233 -0
- package/src/lib/repos/providers/github.ts +294 -0
- package/src/lib/repos/providers/gitlab.ts +263 -0
- package/src/lib/repos/providers/http.ts +102 -0
- package/src/lib/repos/providers/index-cache.ts +382 -0
- package/src/lib/repos/providers/index.ts +106 -0
- package/src/lib/repos/providers/local.ts +391 -0
- package/src/lib/repos/providers/provider.ts +401 -0
- package/src/lib/repos/recipe-config-layers.ts +287 -0
- package/src/lib/repos/recipe-targets.ts +223 -0
- package/src/lib/repos/ref-search.ts +46 -0
- package/src/lib/repos/ref.ts +513 -0
- package/src/lib/repos/reference-report.ts +122 -0
- package/src/lib/repos/release/bump.ts +161 -0
- package/src/lib/repos/release/git-state.ts +305 -0
- package/src/lib/repos/release/index-builder.ts +635 -0
- package/src/lib/repos/release/index.ts +16 -0
- package/src/lib/repos/release/plan.ts +512 -0
- package/src/lib/repos/release/submit-service.ts +496 -0
- package/src/lib/repos/release/tags.ts +243 -0
- package/src/lib/repos/release/validate.ts +463 -0
- package/src/lib/repos/resolver.ts +789 -0
- package/src/lib/repos/scaffold/index.ts +238 -0
- package/src/lib/repos/scaffold/templates.ts +415 -0
- package/src/lib/repos/seed.ts +414 -0
- package/src/lib/repos/store/contract.ts +64 -0
- package/src/lib/repos/store/hash.ts +114 -0
- package/src/lib/repos/store/recipe-store.ts +599 -0
- package/src/lib/repos/store/settings.ts +58 -0
- package/src/lib/repos/subscription-service.ts +2678 -0
- package/src/lib/repos/trust.ts +447 -0
- package/src/lib/settings.ts +546 -189
- package/src/lib/sous-home.ts +104 -0
- package/src/lib/state.ts +52 -20
- package/src/lib/vars/ask.ts +1152 -0
- package/src/lib/vars/definition-source.ts +252 -0
- package/src/lib/vars/display.ts +233 -0
- package/src/lib/vars/index.ts +18 -0
- package/src/lib/vars/ladder.ts +282 -0
- package/src/lib/vars/mappings.ts +265 -0
- package/src/lib/vars/names.ts +94 -0
- package/src/lib/vars/preanswers.ts +395 -0
- package/src/lib/vars/question-plan.ts +218 -0
- package/src/lib/vars/report.ts +228 -0
- package/src/lib/vars/safe-regex.ts +235 -0
- package/src/lib/vars/validate.ts +312 -0
- package/src/lib/watch-loop.ts +148 -0
- package/src/templating/init-liquid-engine.ts +58 -16
- package/src/utils/choice-prompt.ts +143 -0
- package/src/utils/command-errors.ts +186 -0
- package/src/utils/command-help.ts +45 -0
- package/src/utils/confirm-prompt.ts +110 -0
- package/src/utils/flags.ts +153 -0
- package/src/utils/formatting.ts +540 -55
- package/src/utils/prompts.ts +35 -1
- package/src/utils/sous-directory.ts +245 -0
- package/src/utils/table.ts +603 -0
- package/src/utils/value-prompt.ts +119 -0
- package/shared-prompts/_partials/resume-task.md +0 -51
- package/shared-prompts/_partials/sub-agent-delegation.md +0 -32
- package/shared-prompts/_partials/update-task-file.md +0 -52
- package/shared-prompts/memories/automated-browser-tasks/INDEX.tpl.md +0 -52
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/SKILL.tpl.md +0 -102
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/auth-failure-handling.mjs +0 -81
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/chained-workflow.mjs +0 -126
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/examples/simple-fetch.mjs +0 -92
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/architecture.md +0 -61
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/auth-and-sessions.md +0 -65
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/ctx-api.md +0 -96
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/installation.md +0 -104
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/references/script-conventions.md +0 -243
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/chrome-state.mjs +0 -148
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.mjs +0 -383
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/debug.spec.mjs +0 -267
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/eslint.config.mjs +0 -56
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/harness.mjs +0 -169
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/keyring.mjs +0 -59
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/logger.mjs +0 -25
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/params.mjs +0 -140
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/run.mjs +0 -140
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/settings.tpl.mjs +0 -1
- package/shared-prompts/skills/automated-browser-tasks/about-automated-browser-tasks/scripts/utils.mjs +0 -185
- package/shared-prompts/skills/automated-browser-tasks/create-automated-browser-task/SKILL.tpl.md +0 -52
- package/shared-prompts/skills/automated-browser-tasks/running-automated-browser-tasks/SKILL.tpl.md +0 -59
- package/shared-prompts/skills/automated-browser-tasks/update-automated-browser-task/SKILL.tpl.md +0 -47
- package/shared-prompts/skills/control-flow/approve/SKILL.tpl.md +0 -26
- package/shared-prompts/skills/control-flow/opine/SKILL.tpl.md +0 -58
- package/shared-prompts/skills/control-flow/repeat/SKILL.tpl.md +0 -27
- package/shared-prompts/skills/control-flow/research/SKILL.tpl.md +0 -34
- package/shared-prompts/skills/sous-skills/about-sous/SKILL.tpl.md +0 -51
- package/shared-prompts/skills/task-files/about-task-files/SKILL.tpl.md +0 -122
- package/shared-prompts/skills/task-files/continue-task-in-new-branch/SKILL.tpl.md +0 -80
- package/shared-prompts/skills/task-files/go/SKILL.tpl.md +0 -14
- package/shared-prompts/skills/task-files/resume-task/SKILL.tpl.md +0 -13
- package/shared-prompts/skills/task-files/start-task/SKILL.tpl.md +0 -93
- package/shared-prompts/skills/task-files/update/SKILL.tpl.md +0 -14
- package/shared-prompts/skills/task-files/update-task-file/SKILL.tpl.md +0 -13
- /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-agent-skills/references/substitutions.md +0 -0
- /package/{shared-prompts/skills/sous-skills → recipes/core/sous-skills/skills}/about-liquid-templates/references/liquid-filters.md +0 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a locked recipe's files actually are.
|
|
3
|
+
*
|
|
4
|
+
* The lockfile pins a recipe to a repository, a version and a content hash; it
|
|
5
|
+
* does not say where the bytes live. Three consumers need that answer and must
|
|
6
|
+
* all get the same one: the namespace resolver (which recipe directory a
|
|
7
|
+
* `~namespace` include lands in), the variable definition source (which
|
|
8
|
+
* manifests `sous vars` reads), and the recipe compile targets (which files a
|
|
9
|
+
* build copies or renders).
|
|
10
|
+
*
|
|
11
|
+
* There are two possible homes, and the order matters. A LINKED repository is
|
|
12
|
+
* read from its working copy, because a link is a deliberate instruction to
|
|
13
|
+
* bypass versions and the lockfile; everything else is read from the immutable
|
|
14
|
+
* store entry at the locked version. Nothing here fetches anything: a recipe the
|
|
15
|
+
* store does not hold yet is reported as absent so the caller can restore it.
|
|
16
|
+
*
|
|
17
|
+
* Reading is done once per call and cached inside the returned list, because a
|
|
18
|
+
* build asks for the same answer several times in a row.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import type { Settings } from "../settings.js";
|
|
24
|
+
import { resolveStoreRoot } from "../sous-home.js";
|
|
25
|
+
import { parseLockfile, type LockKind, type Lockfile } from "./formats/lockfile.js";
|
|
26
|
+
import { parseRecipeManifest, type RecipeManifest } from "./formats/recipe-manifest.js";
|
|
27
|
+
import { parseRepoManifest, type RepoManifest } from "./formats/repo-manifest.js";
|
|
28
|
+
import { LOCKFILE_FILENAME } from "./formats/common.js";
|
|
29
|
+
import {
|
|
30
|
+
findRecipeManifest,
|
|
31
|
+
findRepoManifest,
|
|
32
|
+
loadJsonFile,
|
|
33
|
+
loadManifestFile,
|
|
34
|
+
} from "./load-manifest.js";
|
|
35
|
+
import { readEffectiveLinks } from "./links.js";
|
|
36
|
+
import { identitySegments } from "./identity.js";
|
|
37
|
+
import { enabledSubscriptions } from "./defaults.js";
|
|
38
|
+
import { PROJECT_HOLDER } from "./formats/lockfile.js";
|
|
39
|
+
|
|
40
|
+
/** One locked recipe, together with the directory its files are read from. */
|
|
41
|
+
export type LockedRecipeLocation = {
|
|
42
|
+
/** The recipe key, `namespace/recipe`. */
|
|
43
|
+
key: string;
|
|
44
|
+
/** The short name of the repository it came from. */
|
|
45
|
+
repo: string;
|
|
46
|
+
/** The recipe's namespace. */
|
|
47
|
+
namespace: string;
|
|
48
|
+
/** The recipe's name. */
|
|
49
|
+
name: string;
|
|
50
|
+
/** The exact version the lockfile pins. */
|
|
51
|
+
version: string;
|
|
52
|
+
/** The content hash the lockfile pins. */
|
|
53
|
+
hash: string;
|
|
54
|
+
/** Whether anything holds it as a co-subscription, or only as a build dependency. */
|
|
55
|
+
kind: LockKind;
|
|
56
|
+
/** Everyone holding it: "project", and the key of every recipe that asked. */
|
|
57
|
+
requestedBy: string[];
|
|
58
|
+
/** Absolute directory holding the recipe's files. */
|
|
59
|
+
dir: string;
|
|
60
|
+
/** True when `dir` is a linked working copy rather than a store entry. */
|
|
61
|
+
linked: boolean;
|
|
62
|
+
/** True when `dir` exists on disk right now. */
|
|
63
|
+
present: boolean;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** How locked recipes are located. */
|
|
67
|
+
export type LockedRecipeOptions = {
|
|
68
|
+
/** The project's `.sous/` directory, which holds the lockfile and the links map. */
|
|
69
|
+
sousDir: string;
|
|
70
|
+
/** The environment to read; decides where the store and the machine-wide links map are. */
|
|
71
|
+
env?: NodeJS.ProcessEnv;
|
|
72
|
+
/** The store root to use instead of the one the environment implies. */
|
|
73
|
+
storeRoot?: string;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Reads and validates a project's lockfile, returning an empty one when the
|
|
78
|
+
* project has locked nothing yet.
|
|
79
|
+
*
|
|
80
|
+
* @param sousDir - The project's `.sous/` directory.
|
|
81
|
+
*/
|
|
82
|
+
export function readProjectLockfile(sousDir: string): Lockfile {
|
|
83
|
+
const filePath = path.join(sousDir, LOCKFILE_FILENAME);
|
|
84
|
+
if (!fs.existsSync(filePath)) return { formatVersion: 1, repos: {}, recipes: {} };
|
|
85
|
+
return parseLockfile(loadJsonFile(filePath, "lockfile"), filePath);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Reads and validates the recipe manifest in a directory, returning undefined
|
|
90
|
+
* when the directory has none or does not exist. A recipe that is not on disk
|
|
91
|
+
* yet is an ordinary state (a fresh clone before restore), never an error here.
|
|
92
|
+
*
|
|
93
|
+
* @param recipeDir - The directory holding the recipe's files.
|
|
94
|
+
*/
|
|
95
|
+
export function readRecipeManifestIn(recipeDir: string): RecipeManifest | undefined {
|
|
96
|
+
let manifestPath: string | undefined;
|
|
97
|
+
try {
|
|
98
|
+
manifestPath = findRecipeManifest(recipeDir);
|
|
99
|
+
} catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
if (manifestPath === undefined) return undefined;
|
|
103
|
+
return parseRecipeManifest(loadManifestFile(manifestPath), manifestPath);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Reads and validates the repo manifest at the root of a checkout, returning
|
|
108
|
+
* undefined when there is none.
|
|
109
|
+
*
|
|
110
|
+
* @param repoRoot - The checkout's root directory.
|
|
111
|
+
*/
|
|
112
|
+
export function readRepoManifestIn(repoRoot: string): RepoManifest | undefined {
|
|
113
|
+
let manifestPath: string | undefined;
|
|
114
|
+
try {
|
|
115
|
+
manifestPath = findRepoManifest(repoRoot);
|
|
116
|
+
} catch {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
if (manifestPath === undefined) return undefined;
|
|
120
|
+
return parseRepoManifest(loadManifestFile(manifestPath), manifestPath);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Maps every recipe a linked checkout publishes to its directory, by reading the
|
|
125
|
+
* checkout's repo manifest and then each recipe folder's own manifest. A folder
|
|
126
|
+
* the repo manifest lists but that holds no readable recipe manifest is skipped
|
|
127
|
+
* rather than failing the build: a working copy is edited by hand and is allowed
|
|
128
|
+
* to be mid-change.
|
|
129
|
+
*
|
|
130
|
+
* @param checkoutDir - The linked working copy's root directory.
|
|
131
|
+
*/
|
|
132
|
+
export function mapLinkedRecipes(checkoutDir: string): Record<string, string> {
|
|
133
|
+
const manifest = readRepoManifestIn(checkoutDir);
|
|
134
|
+
if (manifest === undefined) return {};
|
|
135
|
+
|
|
136
|
+
const found: Record<string, string> = {};
|
|
137
|
+
for (const relative of manifest.recipes) {
|
|
138
|
+
const recipeDir = path.join(checkoutDir, relative);
|
|
139
|
+
const recipe = readRecipeManifestIn(recipeDir);
|
|
140
|
+
if (recipe === undefined) continue;
|
|
141
|
+
found[`${recipe.namespace}/${recipe.name}`] = recipeDir;
|
|
142
|
+
}
|
|
143
|
+
return found;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Every recipe the lockfile pins, with the directory each one's files are read
|
|
148
|
+
* from. Linked repositories win over the store, and a recipe whose directory is
|
|
149
|
+
* not there yet comes back with `present: false`.
|
|
150
|
+
*
|
|
151
|
+
* @param options - The project's `.sous/` directory and the environment.
|
|
152
|
+
*/
|
|
153
|
+
export function listLockedRecipes(
|
|
154
|
+
options: LockedRecipeOptions
|
|
155
|
+
): LockedRecipeLocation[] {
|
|
156
|
+
const env = options.env ?? process.env;
|
|
157
|
+
const lock = readProjectLockfile(options.sousDir);
|
|
158
|
+
const keys = Object.keys(lock.recipes);
|
|
159
|
+
if (keys.length === 0) return [];
|
|
160
|
+
|
|
161
|
+
const storeRoot = options.storeRoot ?? resolveStoreRoot(env);
|
|
162
|
+
const links = readEffectiveLinks(options.sousDir, env);
|
|
163
|
+
const linkedRecipes = new Map<string, Record<string, string>>();
|
|
164
|
+
|
|
165
|
+
const located: LockedRecipeLocation[] = [];
|
|
166
|
+
|
|
167
|
+
for (const key of keys.sort()) {
|
|
168
|
+
const entry = lock.recipes[key]!;
|
|
169
|
+
const namespace = key.slice(0, key.indexOf("/"));
|
|
170
|
+
const name = key.slice(namespace.length + 1);
|
|
171
|
+
|
|
172
|
+
let dir: string | undefined;
|
|
173
|
+
let linked = false;
|
|
174
|
+
|
|
175
|
+
const checkout = links[entry.repo]?.path;
|
|
176
|
+
if (checkout !== undefined) {
|
|
177
|
+
if (!linkedRecipes.has(entry.repo)) {
|
|
178
|
+
linkedRecipes.set(entry.repo, mapLinkedRecipes(checkout));
|
|
179
|
+
}
|
|
180
|
+
dir = linkedRecipes.get(entry.repo)![key];
|
|
181
|
+
linked = dir !== undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (dir === undefined) {
|
|
185
|
+
// The store is machine-wide, so it files an entry under the repository's
|
|
186
|
+
// canonical identity rather than under this project's short name for it.
|
|
187
|
+
const identity = lock.repos[entry.repo]?.identity;
|
|
188
|
+
dir =
|
|
189
|
+
identity === undefined
|
|
190
|
+
? undefined
|
|
191
|
+
: path.join(storeRoot, ...identitySegments(identity), namespace, name, entry.version);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
located.push({
|
|
195
|
+
key,
|
|
196
|
+
repo: entry.repo,
|
|
197
|
+
namespace,
|
|
198
|
+
name,
|
|
199
|
+
version: entry.version,
|
|
200
|
+
hash: entry.hash,
|
|
201
|
+
kind: entry.kind,
|
|
202
|
+
requestedBy: [...entry.requestedBy],
|
|
203
|
+
dir: dir ?? "",
|
|
204
|
+
linked,
|
|
205
|
+
present: dir !== undefined && fs.existsSync(dir),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return located;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The lockfile keys one subscription holds DIRECTLY. A recipe ref holds its own
|
|
214
|
+
* key; a namespace ref holds every recipe in that namespace.
|
|
215
|
+
*
|
|
216
|
+
* Only entries the project itself holds count. A recipe that arrived purely as
|
|
217
|
+
* another recipe's `depends` sits under the same namespace but was never the
|
|
218
|
+
* project's to hold, and counting it made `sous unsubscribe <namespace>` report
|
|
219
|
+
* it as having "stayed" when the project had never held it in the first place.
|
|
220
|
+
*
|
|
221
|
+
* @param lock - The lockfile as it stands.
|
|
222
|
+
* @param key - The subscription's ref key: a namespace, or `namespace/recipe`.
|
|
223
|
+
*/
|
|
224
|
+
export function keysHeldBySubscription(lock: Lockfile, key: string): string[] {
|
|
225
|
+
const heldByProject = (entry: string): boolean =>
|
|
226
|
+
lock.recipes[entry]?.requestedBy.includes(PROJECT_HOLDER) === true;
|
|
227
|
+
|
|
228
|
+
if (key.includes("/")) {
|
|
229
|
+
return heldByProject(key) ? [key] : [];
|
|
230
|
+
}
|
|
231
|
+
return Object.keys(lock.recipes)
|
|
232
|
+
.filter((entry) => entry.startsWith(`${key}/`) && heldByProject(entry))
|
|
233
|
+
.sort();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The refs a project's own templates may address: everything it subscribed to
|
|
238
|
+
* directly. Both sources are read, because a subscription may be written by
|
|
239
|
+
* `sous subscribe` into the managed layer, hand-written in the primary config,
|
|
240
|
+
* or (for an older project) recorded only in the lockfile.
|
|
241
|
+
*
|
|
242
|
+
* @param settings - The merged project config.
|
|
243
|
+
* @param locked - The locked recipes, as returned by listLockedRecipes.
|
|
244
|
+
*/
|
|
245
|
+
export function projectSubscriptionRefs(
|
|
246
|
+
settings: Settings,
|
|
247
|
+
locked: LockedRecipeLocation[]
|
|
248
|
+
): string[] {
|
|
249
|
+
const refs = new Set<string>(Object.keys(enabledSubscriptions(settings)));
|
|
250
|
+
for (const recipe of locked) {
|
|
251
|
+
if (recipe.requestedBy.includes(PROJECT_HOLDER)) refs.add(recipe.key);
|
|
252
|
+
}
|
|
253
|
+
return [...refs].sort();
|
|
254
|
+
}
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The managed config layers.
|
|
3
|
+
*
|
|
4
|
+
* Three files in a project's `conf.d/` directory are written by sous rather
|
|
5
|
+
* than by a person: `500-repos.jsonc`, which holds the repositories the project
|
|
6
|
+
* trusts, `510-subscriptions.jsonc`, which holds what it subscribes to, and
|
|
7
|
+
* `520-var-mappings.jsonc` (written from `vars/mappings.ts`), which holds
|
|
8
|
+
* variable mapping records. All three sit in the 5xx band reserved for
|
|
9
|
+
* machine-written layers, so a user's own primary config and non-5xx layers are
|
|
10
|
+
* never touched.
|
|
11
|
+
*
|
|
12
|
+
* They are `.jsonc`, not `.json`, so they can carry real comments: each one
|
|
13
|
+
* opens with a header saying what it holds and who writes it. Sous edits them
|
|
14
|
+
* BY KEY, through `jsonc-parser`, which rewrites only the bytes of the entry it
|
|
15
|
+
* is changing. A comment somebody adds beside an entry, the order they put the
|
|
16
|
+
* keys in, and the way they formatted the file all survive a sous edit.
|
|
17
|
+
*
|
|
18
|
+
* A layer that still exists under its old `.json` name is read as a fallback
|
|
19
|
+
* and migrates on the next write: the `.jsonc` file is written and the `.json`
|
|
20
|
+
* one is removed, so a project never ends up with both (two layers with the
|
|
21
|
+
* same baseName are a hard config error).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import {
|
|
27
|
+
applyEdits,
|
|
28
|
+
findNodeAtLocation,
|
|
29
|
+
modify,
|
|
30
|
+
parse as parseJsonc,
|
|
31
|
+
parseTree,
|
|
32
|
+
type ParseError,
|
|
33
|
+
} from "jsonc-parser";
|
|
34
|
+
import { CONFD_DIR_NAME } from "../config-discovery.js";
|
|
35
|
+
import { ConfigError } from "../errors.js";
|
|
36
|
+
import { stableJsonStringify } from "./formats/common.js";
|
|
37
|
+
import { ensureConfdDirectory } from "../../utils/sous-directory.js";
|
|
38
|
+
|
|
39
|
+
/** The machine-written layer holding the repositories a project trusts. */
|
|
40
|
+
export const REPOS_LAYER_FILENAME = "500-repos.jsonc";
|
|
41
|
+
|
|
42
|
+
/** The machine-written layer holding a project's subscriptions. */
|
|
43
|
+
export const SUBSCRIPTIONS_LAYER_FILENAME = "510-subscriptions.jsonc";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The policy every managed layer states in its own header: sous edits it by
|
|
47
|
+
* key, and a person may edit it too.
|
|
48
|
+
*/
|
|
49
|
+
export const MANAGED_LAYER_COMMENT =
|
|
50
|
+
"This file is managed by sous. Sous edits these files by key; you may edit them " +
|
|
51
|
+
"too, and your comments, key order and formatting are kept.";
|
|
52
|
+
|
|
53
|
+
/** The closing lines of every managed layer header, describing the format. */
|
|
54
|
+
const MANAGED_LAYER_FORMAT_NOTE = [
|
|
55
|
+
"It is JSON with comments (.jsonc): line comments, block comments and trailing",
|
|
56
|
+
"commas are all allowed here.",
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/** What each managed layer holds, and which commands write it. */
|
|
60
|
+
const MANAGED_LAYER_DESCRIPTIONS: Record<string, string[]> = {
|
|
61
|
+
[REPOS_LAYER_FILENAME]: [
|
|
62
|
+
"It records the repositories this project trusts. The 'sous repo add' and",
|
|
63
|
+
"'sous repo remove' commands write the entries under 'repos'.",
|
|
64
|
+
],
|
|
65
|
+
[SUBSCRIPTIONS_LAYER_FILENAME]: [
|
|
66
|
+
"It records what this project subscribes to. The 'sous subscription add' and",
|
|
67
|
+
"'sous subscription remove' commands write the entries under 'subscriptions'.",
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** Wraps a sentence into `//` comment lines of at most `width` characters. */
|
|
72
|
+
function commentLines(text: string, width = 78): string[] {
|
|
73
|
+
const lines: string[] = [];
|
|
74
|
+
let current = "";
|
|
75
|
+
for (const word of text.split(/\s+/)) {
|
|
76
|
+
if (current.length === 0) {
|
|
77
|
+
current = word;
|
|
78
|
+
} else if (`${current} ${word}`.length + 3 <= width) {
|
|
79
|
+
current = `${current} ${word}`;
|
|
80
|
+
} else {
|
|
81
|
+
lines.push(current);
|
|
82
|
+
current = word;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (current.length > 0) lines.push(current);
|
|
86
|
+
return lines;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The header comment block a managed layer opens with. It states the policy,
|
|
91
|
+
* says what the file holds, and names the format.
|
|
92
|
+
*
|
|
93
|
+
* @param fileName - The layer's file name, which selects the description.
|
|
94
|
+
* @param description - Lines describing the file, for a layer this module does
|
|
95
|
+
* not know about (the variable mapping layer passes its own).
|
|
96
|
+
*/
|
|
97
|
+
export function managedLayerHeader(fileName: string, description?: string[]): string {
|
|
98
|
+
const what = description ?? MANAGED_LAYER_DESCRIPTIONS[fileName] ?? [];
|
|
99
|
+
const blocks = [commentLines(MANAGED_LAYER_COMMENT), what, MANAGED_LAYER_FORMAT_NOTE].filter(
|
|
100
|
+
(block) => block.length > 0
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
blocks
|
|
105
|
+
.map((block) => block.map((line) => `// ${line}`.trimEnd()).join("\n"))
|
|
106
|
+
.join("\n//\n") + "\n"
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Where a managed layer lives, and how it is written. */
|
|
111
|
+
export type ManagedLayerOptions = {
|
|
112
|
+
/**
|
|
113
|
+
* The `conf.d/` directory to use instead of `<sousDir>/conf.d`. Set it from
|
|
114
|
+
* the discovered config context, so `--sous-confd` and `SOUS_CONFD` are
|
|
115
|
+
* respected.
|
|
116
|
+
*/
|
|
117
|
+
confDir?: string;
|
|
118
|
+
/**
|
|
119
|
+
* The header comment block a newly created layer opens with, for a layer this
|
|
120
|
+
* module has no description for. Defaults to the header for `fileName`.
|
|
121
|
+
*/
|
|
122
|
+
header?: string;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The directory a managed layer is written into.
|
|
127
|
+
*
|
|
128
|
+
* @param sousDir - The project's `.sous/` directory.
|
|
129
|
+
* @param options - An explicit `conf.d/` directory, when there is one.
|
|
130
|
+
*/
|
|
131
|
+
export function managedLayerDir(sousDir: string, options: ManagedLayerOptions = {}): string {
|
|
132
|
+
return options.confDir ?? path.join(sousDir, CONFD_DIR_NAME);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The full path of a managed layer.
|
|
137
|
+
*
|
|
138
|
+
* @param sousDir - The project's `.sous/` directory.
|
|
139
|
+
* @param fileName - The layer's file name, such as `500-repos.jsonc`.
|
|
140
|
+
* @param options - An explicit `conf.d/` directory, when there is one.
|
|
141
|
+
*/
|
|
142
|
+
export function managedLayerPath(
|
|
143
|
+
sousDir: string,
|
|
144
|
+
fileName: string,
|
|
145
|
+
options: ManagedLayerOptions = {}
|
|
146
|
+
): string {
|
|
147
|
+
return path.join(managedLayerDir(sousDir, options), fileName);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The path the same layer had before managed layers became `.jsonc`, or
|
|
152
|
+
* undefined when the name is not a `.jsonc` one. Read as a fallback, and
|
|
153
|
+
* removed by the first write that migrates the layer.
|
|
154
|
+
*
|
|
155
|
+
* @param sousDir - The project's `.sous/` directory.
|
|
156
|
+
* @param fileName - The layer's file name.
|
|
157
|
+
* @param options - An explicit `conf.d/` directory, when there is one.
|
|
158
|
+
*/
|
|
159
|
+
export function legacyManagedLayerPath(
|
|
160
|
+
sousDir: string,
|
|
161
|
+
fileName: string,
|
|
162
|
+
options: ManagedLayerOptions = {}
|
|
163
|
+
): string | undefined {
|
|
164
|
+
if (!fileName.endsWith(".jsonc")) return undefined;
|
|
165
|
+
return path.join(managedLayerDir(sousDir, options), `${fileName.slice(0, -1)}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** True when the path exists and is a regular file. */
|
|
169
|
+
function isFile(candidate: string): boolean {
|
|
170
|
+
try {
|
|
171
|
+
return fs.statSync(candidate).isFile();
|
|
172
|
+
} catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The managed layer file that actually exists: the `.jsonc` one, or the old
|
|
179
|
+
* `.json` one when only that is there. Undefined when the layer has never been
|
|
180
|
+
* written.
|
|
181
|
+
*/
|
|
182
|
+
function existingManagedLayerPath(
|
|
183
|
+
sousDir: string,
|
|
184
|
+
fileName: string,
|
|
185
|
+
options: ManagedLayerOptions
|
|
186
|
+
): string | undefined {
|
|
187
|
+
const current = managedLayerPath(sousDir, fileName, options);
|
|
188
|
+
if (isFile(current)) return current;
|
|
189
|
+
|
|
190
|
+
const legacy = legacyManagedLayerPath(sousDir, fileName, options);
|
|
191
|
+
if (legacy !== undefined && isFile(legacy)) return legacy;
|
|
192
|
+
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Parses a managed layer's text, allowing comments and trailing commas. */
|
|
197
|
+
function parseManagedLayerText(text: string, filePath: string): unknown {
|
|
198
|
+
const errors: ParseError[] = [];
|
|
199
|
+
const value = parseJsonc(text, errors, {
|
|
200
|
+
allowTrailingComma: true,
|
|
201
|
+
disallowComments: false,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (errors.length > 0) {
|
|
205
|
+
throw new ConfigError(
|
|
206
|
+
`Sous could not read its own config layer at ${filePath}.\n` +
|
|
207
|
+
` The file is not valid JSON with comments.\n` +
|
|
208
|
+
` Sous writes this file itself. Restoring it from version control, or deleting ` +
|
|
209
|
+
`it and adding the entries again, both fix this.`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Reads a managed layer, returning an empty object when the file does not exist
|
|
218
|
+
* yet. A file that does not parse is an error naming it, because sous wrote it
|
|
219
|
+
* and is about to edit it: silently discarding somebody's edits would be worse
|
|
220
|
+
* than stopping.
|
|
221
|
+
*
|
|
222
|
+
* @param sousDir - The project's `.sous/` directory.
|
|
223
|
+
* @param fileName - The layer's file name.
|
|
224
|
+
* @param options - An explicit `conf.d/` directory, when there is one.
|
|
225
|
+
*/
|
|
226
|
+
export function readManagedLayer(
|
|
227
|
+
sousDir: string,
|
|
228
|
+
fileName: string,
|
|
229
|
+
options: ManagedLayerOptions = {}
|
|
230
|
+
): Record<string, unknown> {
|
|
231
|
+
const filePath = existingManagedLayerPath(sousDir, fileName, options);
|
|
232
|
+
if (filePath === undefined) return {};
|
|
233
|
+
|
|
234
|
+
let text: string;
|
|
235
|
+
try {
|
|
236
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
237
|
+
} catch (error) {
|
|
238
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
239
|
+
throw new ConfigError(
|
|
240
|
+
`Sous could not read its own config layer at ${filePath}.\n` +
|
|
241
|
+
` ${(error as Error).message}`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const parsed = parseManagedLayerText(text, filePath);
|
|
246
|
+
|
|
247
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
248
|
+
throw new ConfigError(
|
|
249
|
+
`The config layer at ${filePath} is not a JSON object.\n` +
|
|
250
|
+
` Sous writes this file itself; it always holds one object.`
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return parsed as Record<string, unknown>;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Writes a layer's text to its `.jsonc` path and removes the old `.json` name
|
|
259
|
+
* when there was one, so a migrated layer never exists twice. The file is
|
|
260
|
+
* staged under a temporary name in the same directory and renamed into place,
|
|
261
|
+
* so a reader never sees a half-written layer.
|
|
262
|
+
*/
|
|
263
|
+
function writeLayerText(
|
|
264
|
+
sousDir: string,
|
|
265
|
+
fileName: string,
|
|
266
|
+
body: string,
|
|
267
|
+
options: ManagedLayerOptions
|
|
268
|
+
): string {
|
|
269
|
+
const directory = managedLayerDir(sousDir, options);
|
|
270
|
+
const filePath = path.join(directory, fileName);
|
|
271
|
+
|
|
272
|
+
// Created with its README, so somebody who finds a machine-written layer can
|
|
273
|
+
// read what the directory is for without leaving it.
|
|
274
|
+
ensureConfdDirectory(directory);
|
|
275
|
+
|
|
276
|
+
const temporary = path.join(directory, `.${fileName}.tmp-${process.pid}`);
|
|
277
|
+
try {
|
|
278
|
+
fs.writeFileSync(temporary, body, "utf8");
|
|
279
|
+
fs.renameSync(temporary, filePath);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
fs.rmSync(temporary, { force: true });
|
|
282
|
+
throw new ConfigError(
|
|
283
|
+
`Sous could not write its config layer at ${filePath}.\n ${(error as Error).message}`
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const legacy = legacyManagedLayerPath(sousDir, fileName, options);
|
|
288
|
+
if (legacy !== undefined && legacy !== filePath) fs.rmSync(legacy, { force: true });
|
|
289
|
+
|
|
290
|
+
return filePath;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Writes a managed layer, replacing whatever was there: the header comment,
|
|
295
|
+
* then the content as sorted JSON. Use `updateManagedLayer` for an ordinary
|
|
296
|
+
* change; this is for creating a layer from nothing, or for replacing one whose
|
|
297
|
+
* whole content sous is generating.
|
|
298
|
+
*
|
|
299
|
+
* The header comment is added for you; there is no need to pass one.
|
|
300
|
+
*
|
|
301
|
+
* @param sousDir - The project's `.sous/` directory.
|
|
302
|
+
* @param fileName - The layer's file name.
|
|
303
|
+
* @param content - The layer's whole content.
|
|
304
|
+
* @param options - An explicit `conf.d/` directory, when there is one.
|
|
305
|
+
*/
|
|
306
|
+
export function writeManagedLayer(
|
|
307
|
+
sousDir: string,
|
|
308
|
+
fileName: string,
|
|
309
|
+
content: Record<string, unknown>,
|
|
310
|
+
options: ManagedLayerOptions = {}
|
|
311
|
+
): string {
|
|
312
|
+
const header = options.header ?? managedLayerHeader(fileName);
|
|
313
|
+
return writeLayerText(sousDir, fileName, header + stableJsonStringify(content), options);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* True when the layer's text actually holds something at a key path. Used to
|
|
318
|
+
* tell a removal that has work to do from one that does not.
|
|
319
|
+
*
|
|
320
|
+
* @param text - The layer's text.
|
|
321
|
+
* @param keyPath - The key path to look for.
|
|
322
|
+
*/
|
|
323
|
+
function hasNodeAt(text: string, keyPath: (string | number)[]): boolean {
|
|
324
|
+
const root = parseTree(text);
|
|
325
|
+
if (root === undefined) return false;
|
|
326
|
+
return findNodeAtLocation(root, keyPath) !== undefined;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** One key-path edit to a managed layer. `undefined` removes the key. */
|
|
330
|
+
export type ManagedLayerEdit = {
|
|
331
|
+
/** The key path to change, such as `["repos", "team-recipes"]`. */
|
|
332
|
+
path: (string | number)[];
|
|
333
|
+
/** The value to write there, or undefined to remove the key. */
|
|
334
|
+
value: unknown | undefined;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Applies key-path edits to a managed layer, rewriting only the bytes of the
|
|
339
|
+
* entries that change. Comments, key order and formatting elsewhere in the file
|
|
340
|
+
* are left exactly as they were, which is what lets a person keep notes beside
|
|
341
|
+
* the entries sous manages.
|
|
342
|
+
*
|
|
343
|
+
* A layer that does not exist yet is created with its header comment and an
|
|
344
|
+
* empty object, and the edits are applied to that. A layer still under its old
|
|
345
|
+
* `.json` name is edited and written back as `.jsonc`, and the `.json` file is
|
|
346
|
+
* removed.
|
|
347
|
+
*
|
|
348
|
+
* New keys are inserted in sorted position, so a layer sous has written from
|
|
349
|
+
* the start stays in a stable order and its diffs stay small.
|
|
350
|
+
*
|
|
351
|
+
* @param sousDir - The project's `.sous/` directory.
|
|
352
|
+
* @param fileName - The layer's file name.
|
|
353
|
+
* @param edits - The key paths to set, or to remove by passing undefined.
|
|
354
|
+
* @param options - An explicit `conf.d/` directory, and a header for a layer
|
|
355
|
+
* this module has no description for.
|
|
356
|
+
* @returns The path of the layer file that was written.
|
|
357
|
+
*/
|
|
358
|
+
export function updateManagedLayer(
|
|
359
|
+
sousDir: string,
|
|
360
|
+
fileName: string,
|
|
361
|
+
edits: ManagedLayerEdit[],
|
|
362
|
+
options: ManagedLayerOptions = {}
|
|
363
|
+
): string {
|
|
364
|
+
const existing = existingManagedLayerPath(sousDir, fileName, options);
|
|
365
|
+
|
|
366
|
+
let text: string;
|
|
367
|
+
if (existing === undefined) {
|
|
368
|
+
text = (options.header ?? managedLayerHeader(fileName)) + "{}\n";
|
|
369
|
+
} else {
|
|
370
|
+
try {
|
|
371
|
+
text = fs.readFileSync(existing, "utf8");
|
|
372
|
+
} catch (error) {
|
|
373
|
+
throw new ConfigError(
|
|
374
|
+
`Sous could not read its own config layer at ${existing}.\n` +
|
|
375
|
+
` ${(error as Error).message}`
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
// Refuse to edit a file that does not parse, rather than writing over it.
|
|
379
|
+
parseManagedLayerText(text, existing);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
for (const edit of edits) {
|
|
383
|
+
// Removing a key that is not there is already done. Asking `modify` to do it
|
|
384
|
+
// anyway throws, because there is no parent object to remove it from, and a
|
|
385
|
+
// layer somebody has hand-edited is exactly where that happens.
|
|
386
|
+
if (edit.value === undefined && !hasNodeAt(text, edit.path)) continue;
|
|
387
|
+
|
|
388
|
+
const last = edit.path[edit.path.length - 1];
|
|
389
|
+
const changes = modify(text, edit.path, edit.value, {
|
|
390
|
+
formattingOptions: { tabSize: 2, insertSpaces: true, eol: "\n" },
|
|
391
|
+
getInsertionIndex:
|
|
392
|
+
typeof last === "string"
|
|
393
|
+
? (properties) => properties.filter((name) => name < last).length
|
|
394
|
+
: undefined,
|
|
395
|
+
});
|
|
396
|
+
text = applyEdits(text, changes);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (!text.endsWith("\n")) text += "\n";
|
|
400
|
+
|
|
401
|
+
return writeLayerText(sousDir, fileName, text, options);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Removes a managed layer entirely, which is what emptying one comes down to: a
|
|
406
|
+
* layer holding nothing but its own header comment is noise in a project. The
|
|
407
|
+
* old `.json` name is removed too, so a migration in progress leaves nothing
|
|
408
|
+
* behind.
|
|
409
|
+
*
|
|
410
|
+
* @param sousDir - The project's `.sous/` directory.
|
|
411
|
+
* @param fileName - The layer's file name.
|
|
412
|
+
* @param options - An explicit `conf.d/` directory, when there is one.
|
|
413
|
+
*/
|
|
414
|
+
export function removeManagedLayer(
|
|
415
|
+
sousDir: string,
|
|
416
|
+
fileName: string,
|
|
417
|
+
options: ManagedLayerOptions = {}
|
|
418
|
+
): void {
|
|
419
|
+
fs.rmSync(managedLayerPath(sousDir, fileName, options), { force: true });
|
|
420
|
+
const legacy = legacyManagedLayerPath(sousDir, fileName, options);
|
|
421
|
+
if (legacy !== undefined) fs.rmSync(legacy, { force: true });
|
|
422
|
+
}
|