@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,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading and writing the links map, and the ignore hygiene that goes with it.
|
|
3
|
+
*
|
|
4
|
+
* A link redirects one repository's resolution away from the store and at a
|
|
5
|
+
* real working copy on disk, which is how a maintainer edits recipes: edits
|
|
6
|
+
* happen in a checkout, never in the store. `sous repo link` writes an entry
|
|
7
|
+
* here; `sous repo unlink` removes it and leaves the checkout alone.
|
|
8
|
+
*
|
|
9
|
+
* Two maps exist. The project's `.sous/sous.links.json` covers one project; the
|
|
10
|
+
* machine-wide `$SOUS_HOME/sous.links.json` covers every project on the machine,
|
|
11
|
+
* which is how two projects share one checkout. Both are read, and the project's
|
|
12
|
+
* entries win, because the narrower decision is the more deliberate one.
|
|
13
|
+
*
|
|
14
|
+
* Neither map is committed. A link bypasses versions, the lockfile and freshness
|
|
15
|
+
* checks, and those bypasses belong to one person's machine rather than to the
|
|
16
|
+
* team; the ignore hygiene in this module is what keeps them out of the
|
|
17
|
+
* repository, and `describeLinkedRepos` is what keeps them visible in build
|
|
18
|
+
* output instead of silently changing what a build produces.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { ConfigError } from "../errors.js";
|
|
24
|
+
import { resolveSousHome, resolveStoreRoot } from "../sous-home.js";
|
|
25
|
+
import { LINKS_FILENAME } from "./formats/common.js";
|
|
26
|
+
import {
|
|
27
|
+
ensureProjectReposDirectory,
|
|
28
|
+
ensureSousHomeDirectory,
|
|
29
|
+
} from "../../utils/sous-directory.js";
|
|
30
|
+
import {
|
|
31
|
+
createEmptyLinksMap,
|
|
32
|
+
mergeLinksMaps,
|
|
33
|
+
parseLinksMap,
|
|
34
|
+
stringifyLinksMap,
|
|
35
|
+
type LinksMap,
|
|
36
|
+
type RepoLink,
|
|
37
|
+
} from "./formats/links-map.js";
|
|
38
|
+
import { loadJsonFile } from "./load-manifest.js";
|
|
39
|
+
|
|
40
|
+
/** Directory name, inside `.sous/` or `$SOUS_HOME`, holding linked checkouts. */
|
|
41
|
+
export const REPOS_DIRNAME = "repos";
|
|
42
|
+
|
|
43
|
+
/** Opening marker of the block sous maintains in `.sous/.gitignore`. */
|
|
44
|
+
export const IGNORE_BLOCK_START = "# >>> sous managed (do not edit between these markers)";
|
|
45
|
+
|
|
46
|
+
/** Closing marker of the block sous maintains in `.sous/.gitignore`. */
|
|
47
|
+
export const IGNORE_BLOCK_END = "# <<< sous managed";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The entries sous keeps inside its managed block in `.sous/.gitignore`. All of
|
|
51
|
+
* them are machine-local: the links map, the build state file, the watcher's
|
|
52
|
+
* PID file, and the directory linked checkouts are cloned into.
|
|
53
|
+
*/
|
|
54
|
+
export const IGNORE_BLOCK_ENTRIES = [
|
|
55
|
+
LINKS_FILENAME,
|
|
56
|
+
"sous.state.json",
|
|
57
|
+
"sous.pid",
|
|
58
|
+
`${REPOS_DIRNAME}/`,
|
|
59
|
+
] as const;
|
|
60
|
+
|
|
61
|
+
// --- Locations ----------------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The user-level sous directory, re-exported under the name this module has
|
|
65
|
+
* always used. The one definition lives in `src/lib/sous-home.ts`, so the store,
|
|
66
|
+
* the links maps and the auto-injected `${sousHome}` variable can never disagree
|
|
67
|
+
* about where it is.
|
|
68
|
+
*
|
|
69
|
+
* @param env - The environment to read, so tests need not mutate the real one.
|
|
70
|
+
*/
|
|
71
|
+
export function resolveSousHomeDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
72
|
+
return resolveSousHome(env);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Path to the project's links map.
|
|
77
|
+
*
|
|
78
|
+
* @param sousDir - The project's discovered `.sous/` directory.
|
|
79
|
+
*/
|
|
80
|
+
export function projectLinksPath(sousDir: string): string {
|
|
81
|
+
return path.join(sousDir, LINKS_FILENAME);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Path to the machine-wide links map.
|
|
86
|
+
*
|
|
87
|
+
* @param env - The environment to read.
|
|
88
|
+
*/
|
|
89
|
+
export function globalLinksPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
90
|
+
return path.join(resolveSousHomeDir(env), LINKS_FILENAME);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The directory a project's own linked checkouts are cloned into.
|
|
95
|
+
*
|
|
96
|
+
* @param sousDir - The project's discovered `.sous/` directory.
|
|
97
|
+
*/
|
|
98
|
+
export function projectReposDir(sousDir: string): string {
|
|
99
|
+
return path.join(sousDir, REPOS_DIRNAME);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The directory machine-wide linked checkouts are cloned into, shared by every
|
|
104
|
+
* project on the machine.
|
|
105
|
+
*
|
|
106
|
+
* @param env - The environment to read.
|
|
107
|
+
*/
|
|
108
|
+
export function globalReposDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
109
|
+
return path.join(resolveSousHomeDir(env), REPOS_DIRNAME);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The directories nothing sous deletes may ever reach into: the project's linked
|
|
114
|
+
* checkouts, the machine-wide linked checkouts, and the machine-wide recipe
|
|
115
|
+
* store.
|
|
116
|
+
*
|
|
117
|
+
* A linked checkout is somebody's working copy with unpushed edits in it, and
|
|
118
|
+
* the store is shared by every project on the machine, so a stale state entry
|
|
119
|
+
* pointing into either one must never turn `sous prune` or `sous clear` into a
|
|
120
|
+
* data loss. Prune and clear only ever touch paths recorded in the state file,
|
|
121
|
+
* which nothing here writes; this is the belt that survives a future bug.
|
|
122
|
+
*
|
|
123
|
+
* @param sousDir - The project's discovered `.sous/` directory.
|
|
124
|
+
* @param env - The environment to read.
|
|
125
|
+
*/
|
|
126
|
+
export function protectedRepoPaths(
|
|
127
|
+
sousDir: string,
|
|
128
|
+
env: NodeJS.ProcessEnv = process.env
|
|
129
|
+
): string[] {
|
|
130
|
+
return [projectReposDir(sousDir), globalReposDir(env), resolveStoreRoot(env)];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- Reading ------------------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Reads a links map from a path, returning an empty map when the file does not
|
|
137
|
+
* exist. A file that exists but cannot be read or does not validate raises a
|
|
138
|
+
* ConfigError naming it, rather than being quietly treated as empty: a link
|
|
139
|
+
* changes what a build produces, so losing one must never pass unnoticed.
|
|
140
|
+
*
|
|
141
|
+
* @param filePath - Absolute path to the links file.
|
|
142
|
+
*/
|
|
143
|
+
export function readLinksFile(filePath: string): LinksMap {
|
|
144
|
+
if (!fs.existsSync(filePath)) return createEmptyLinksMap();
|
|
145
|
+
return parseLinksMap(loadJsonFile(filePath, "links map"), filePath);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Reads the project's links map.
|
|
150
|
+
*
|
|
151
|
+
* @param sousDir - The project's discovered `.sous/` directory.
|
|
152
|
+
*/
|
|
153
|
+
export function readProjectLinks(sousDir: string): LinksMap {
|
|
154
|
+
return readLinksFile(projectLinksPath(sousDir));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Reads the machine-wide links map.
|
|
159
|
+
*
|
|
160
|
+
* @param env - The environment to read.
|
|
161
|
+
*/
|
|
162
|
+
export function readGlobalLinks(env: NodeJS.ProcessEnv = process.env): LinksMap {
|
|
163
|
+
return readLinksFile(globalLinksPath(env));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Reads both maps and merges them into the one map the rest of sous consults,
|
|
168
|
+
* with the project's entries winning over the machine-wide ones.
|
|
169
|
+
*
|
|
170
|
+
* @param sousDir - The project's discovered `.sous/` directory.
|
|
171
|
+
* @param env - The environment to read.
|
|
172
|
+
*/
|
|
173
|
+
export function readEffectiveLinks(
|
|
174
|
+
sousDir: string,
|
|
175
|
+
env: NodeJS.ProcessEnv = process.env
|
|
176
|
+
): Record<string, RepoLink> {
|
|
177
|
+
return mergeLinksMaps(readGlobalLinks(env), readProjectLinks(sousDir));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The working copy sous should read for a repository, or undefined when the
|
|
182
|
+
* repository is not linked and resolution should fall through to the store.
|
|
183
|
+
*
|
|
184
|
+
* @param repoName - The repository's configured short name.
|
|
185
|
+
* @param sousDir - The project's discovered `.sous/` directory.
|
|
186
|
+
* @param env - The environment to read.
|
|
187
|
+
*/
|
|
188
|
+
export function linkedPathFor(
|
|
189
|
+
repoName: string,
|
|
190
|
+
sousDir: string,
|
|
191
|
+
env: NodeJS.ProcessEnv = process.env
|
|
192
|
+
): string | undefined {
|
|
193
|
+
return readEffectiveLinks(sousDir, env)[repoName]?.path;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --- Writing ------------------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Writes a links map, creating its directory if needed. Returns the path it was
|
|
200
|
+
* written to.
|
|
201
|
+
*
|
|
202
|
+
* @param filePath - Absolute path to the links file.
|
|
203
|
+
* @param map - The map to write.
|
|
204
|
+
*/
|
|
205
|
+
export function writeLinksFile(filePath: string, map: LinksMap): string {
|
|
206
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
207
|
+
fs.writeFileSync(filePath, stringifyLinksMap(map), "utf8");
|
|
208
|
+
return filePath;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Writes the project's links map.
|
|
213
|
+
*
|
|
214
|
+
* @param sousDir - The project's discovered `.sous/` directory.
|
|
215
|
+
* @param map - The map to write.
|
|
216
|
+
*/
|
|
217
|
+
export function writeProjectLinks(sousDir: string, map: LinksMap): string {
|
|
218
|
+
return writeLinksFile(projectLinksPath(sousDir), map);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Writes the machine-wide links map.
|
|
223
|
+
*
|
|
224
|
+
* @param map - The map to write.
|
|
225
|
+
* @param env - The environment to read.
|
|
226
|
+
*/
|
|
227
|
+
export function writeGlobalLinks(
|
|
228
|
+
map: LinksMap,
|
|
229
|
+
env: NodeJS.ProcessEnv = process.env
|
|
230
|
+
): string {
|
|
231
|
+
// The machine-wide map is the first thing many users ever put in `$SOUS_HOME`,
|
|
232
|
+
// so this is where that directory usually gets its own explanation.
|
|
233
|
+
ensureSousHomeDirectory(resolveSousHomeDir(env));
|
|
234
|
+
return writeLinksFile(globalLinksPath(env), map);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- Build output notice ------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The lines a build prints when anything is linked, so a checkout standing in
|
|
241
|
+
* for a published repository is never a silent change. Returns an empty array
|
|
242
|
+
* when nothing is linked, which is the signal to print nothing at all.
|
|
243
|
+
*
|
|
244
|
+
* @param sousDir - The project's discovered `.sous/` directory.
|
|
245
|
+
* @param env - The environment to read.
|
|
246
|
+
*/
|
|
247
|
+
export function describeLinkedRepos(
|
|
248
|
+
sousDir: string,
|
|
249
|
+
env: NodeJS.ProcessEnv = process.env
|
|
250
|
+
): string[] {
|
|
251
|
+
const links = readEffectiveLinks(sousDir, env);
|
|
252
|
+
const names = Object.keys(links).sort();
|
|
253
|
+
if (names.length === 0) return [];
|
|
254
|
+
|
|
255
|
+
const lines = [
|
|
256
|
+
names.length === 1
|
|
257
|
+
? "One repository is LINKED to a working copy on this machine."
|
|
258
|
+
: `${names.length} repositories are LINKED to working copies on this machine.`,
|
|
259
|
+
"Their recipes are read from those checkouts, so versions, the lockfile and",
|
|
260
|
+
"freshness checks do not apply to them.",
|
|
261
|
+
"",
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
for (const name of names) {
|
|
265
|
+
lines.push(`${name} -> ${links[name]!.path}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
lines.push("");
|
|
269
|
+
lines.push("Run 'sous repo unlink <name>' to go back to the published versions.");
|
|
270
|
+
return lines;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// --- Ignore hygiene -----------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Makes sure git ignores everything sous keeps inside `.sous/` that belongs to
|
|
277
|
+
* one machine rather than to the team. Two files are maintained, and both are
|
|
278
|
+
* safe to write again on every link:
|
|
279
|
+
*
|
|
280
|
+
* - `.sous/repos/.gitignore`, holding a single `*`, so a linked checkout cloned
|
|
281
|
+
* underneath it is invisible to the project's own repository (the `*` covers
|
|
282
|
+
* the ignore file itself, so the directory contributes nothing at all).
|
|
283
|
+
* - a delimited managed block inside `.sous/.gitignore`. Only the lines between
|
|
284
|
+
* the markers are ever rewritten; anything the user put above or below them is
|
|
285
|
+
* left exactly as it was.
|
|
286
|
+
*
|
|
287
|
+
* @param sousDir - The project's discovered `.sous/` directory.
|
|
288
|
+
*/
|
|
289
|
+
export function ensureReposIgnoreFiles(sousDir: string): void {
|
|
290
|
+
const reposDir = ensureProjectReposDirectory(projectReposDir(sousDir));
|
|
291
|
+
writeIfChanged(path.join(reposDir, ".gitignore"), "*\n");
|
|
292
|
+
|
|
293
|
+
const gitignorePath = path.join(sousDir, ".gitignore");
|
|
294
|
+
const existing = fs.existsSync(gitignorePath)
|
|
295
|
+
? fs.readFileSync(gitignorePath, "utf8")
|
|
296
|
+
: undefined;
|
|
297
|
+
writeIfChanged(gitignorePath, applyManagedIgnoreBlock(existing, gitignorePath));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Returns the contents of `.sous/.gitignore` with sous's managed block present
|
|
302
|
+
* and up to date, leaving every line outside the markers untouched. Exported so
|
|
303
|
+
* the behavior can be tested without touching a filesystem.
|
|
304
|
+
*
|
|
305
|
+
* @param existing - The file's current contents, or undefined when there is no file.
|
|
306
|
+
* @param label - The file's path, named if the block turns out to be damaged.
|
|
307
|
+
*/
|
|
308
|
+
export function applyManagedIgnoreBlock(
|
|
309
|
+
existing: string | undefined,
|
|
310
|
+
label = ".sous/.gitignore"
|
|
311
|
+
): string {
|
|
312
|
+
const block = [IGNORE_BLOCK_START, ...IGNORE_BLOCK_ENTRIES, IGNORE_BLOCK_END];
|
|
313
|
+
|
|
314
|
+
if (existing === undefined || existing.trim() === "") {
|
|
315
|
+
return `${block.join("\n")}\n`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const lines = existing.split("\n");
|
|
319
|
+
const start = lines.findIndex((line) => line.trim() === IGNORE_BLOCK_START);
|
|
320
|
+
|
|
321
|
+
if (start === -1) {
|
|
322
|
+
const prefix = existing.endsWith("\n") ? existing : `${existing}\n`;
|
|
323
|
+
const separator = prefix.endsWith("\n\n") ? "" : "\n";
|
|
324
|
+
return `${prefix}${separator}${block.join("\n")}\n`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const end = lines.findIndex(
|
|
328
|
+
(line, index) => index > start && line.trim() === IGNORE_BLOCK_END
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
if (end === -1) {
|
|
332
|
+
throw new ConfigError(
|
|
333
|
+
`The sous managed block in ${label} has no closing marker.\n` +
|
|
334
|
+
` It opens with '${IGNORE_BLOCK_START}' but the line '${IGNORE_BLOCK_END}' ` +
|
|
335
|
+
`is missing, so sous cannot tell where the block ends.\n` +
|
|
336
|
+
` Add the closing marker back, or delete the opening one, and run the ` +
|
|
337
|
+
`command again.`
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const rebuilt = [...lines.slice(0, start), ...block, ...lines.slice(end + 1)];
|
|
342
|
+
const joined = rebuilt.join("\n");
|
|
343
|
+
return joined.endsWith("\n") ? joined : `${joined}\n`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// --- Helpers ------------------------------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
/** Writes a file only when its contents would change, so links stay idempotent. */
|
|
349
|
+
function writeIfChanged(filePath: string, contents: string): void {
|
|
350
|
+
if (fs.existsSync(filePath) && fs.readFileSync(filePath, "utf8") === contents) return;
|
|
351
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
352
|
+
fs.writeFileSync(filePath, contents, "utf8");
|
|
353
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading Repositories files off disk.
|
|
3
|
+
*
|
|
4
|
+
* Manifests are HAND-WRITTEN, and deliberately never JavaScript: trust in the
|
|
5
|
+
* Repositories system rests on being able to read a repo's whole surface
|
|
6
|
+
* without executing any of its code. So a manifest is YAML (`.yaml`, `.yml`) or
|
|
7
|
+
* JSON (`.json`, `.jsonc`), and the JSON dialect is permissive, allowing line
|
|
8
|
+
* comments, block comments and trailing commas, so a manifest can explain
|
|
9
|
+
* itself.
|
|
10
|
+
*
|
|
11
|
+
* Machine-written files (the index, the lockfile, store entry markers, the
|
|
12
|
+
* links map) are strict JSON; nothing writes a comment into them, so nothing
|
|
13
|
+
* needs to tolerate one.
|
|
14
|
+
*
|
|
15
|
+
* Every function here returns raw, unvalidated data. Pass the result to the
|
|
16
|
+
* matching `parseX` helper in `formats/` to validate it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import YAML from "yaml";
|
|
22
|
+
import { parse as parseJsonc, printParseErrorCode, type ParseError } from "jsonc-parser";
|
|
23
|
+
import { ConfigError } from "../errors.js";
|
|
24
|
+
import {
|
|
25
|
+
MANIFEST_EXTENSIONS,
|
|
26
|
+
RECIPE_MANIFEST_BASENAME,
|
|
27
|
+
REPO_MANIFEST_BASENAME,
|
|
28
|
+
} from "./formats/common.js";
|
|
29
|
+
|
|
30
|
+
/** Reads a file as UTF-8, raising a ConfigError naming it when that fails. */
|
|
31
|
+
function readFileText(filePath: string, label: string): string {
|
|
32
|
+
try {
|
|
33
|
+
return fs.readFileSync(filePath, "utf8");
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const reason = (error as NodeJS.ErrnoException).code === "ENOENT" ? "does not exist" : "could not be read";
|
|
36
|
+
throw new ConfigError(
|
|
37
|
+
`The ${label} at ${filePath} ${reason}.\n` +
|
|
38
|
+
` ${(error as Error).message}`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Turns a jsonc-parser error offset into a `line N, column N` string. */
|
|
44
|
+
function describeOffset(text: string, offset: number): string {
|
|
45
|
+
const before = text.slice(0, offset);
|
|
46
|
+
const line = before.split("\n").length;
|
|
47
|
+
const column = offset - before.lastIndexOf("\n");
|
|
48
|
+
return `line ${line}, column ${column}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parses permissive JSON: standard JSON plus line comments, block comments and
|
|
53
|
+
* trailing commas. Used for hand-written `.json` and `.jsonc` files, and for
|
|
54
|
+
* `.jsonc` config layers.
|
|
55
|
+
*
|
|
56
|
+
* @param text - The file's contents.
|
|
57
|
+
* @param sourceLabel - The file path, named in error messages.
|
|
58
|
+
*/
|
|
59
|
+
export function parseJsoncText(text: string, sourceLabel: string): unknown {
|
|
60
|
+
const errors: ParseError[] = [];
|
|
61
|
+
const value = parseJsonc(text, errors, {
|
|
62
|
+
allowTrailingComma: true,
|
|
63
|
+
disallowComments: false,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (errors.length > 0) {
|
|
67
|
+
const first = errors[0]!;
|
|
68
|
+
throw new ConfigError(
|
|
69
|
+
`Could not parse ${sourceLabel} as JSON:\n` +
|
|
70
|
+
` ${printParseErrorCode(first.error)} at ${describeOffset(text, first.offset)}.\n` +
|
|
71
|
+
` Comments and trailing commas are allowed; anything else must be valid JSON.`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parses YAML, raising a ConfigError that carries the parser's own message.
|
|
80
|
+
*
|
|
81
|
+
* @param text - The file's contents.
|
|
82
|
+
* @param sourceLabel - The file path, named in error messages.
|
|
83
|
+
*/
|
|
84
|
+
export function parseYamlText(text: string, sourceLabel: string): unknown {
|
|
85
|
+
try {
|
|
86
|
+
return YAML.parse(text);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
throw new ConfigError(
|
|
89
|
+
`Could not parse ${sourceLabel} as YAML:\n ${(error as Error).message}`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Loads a hand-written manifest, picking the parser by extension: `.yaml` and
|
|
96
|
+
* `.yml` are YAML, `.json` and `.jsonc` are permissive JSON. Returns raw,
|
|
97
|
+
* unvalidated data.
|
|
98
|
+
*
|
|
99
|
+
* @param filePath - Absolute path to the manifest file.
|
|
100
|
+
*/
|
|
101
|
+
export function loadManifestFile(filePath: string): unknown {
|
|
102
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
103
|
+
const text = readFileText(filePath, "manifest");
|
|
104
|
+
|
|
105
|
+
if (extension === ".yaml" || extension === ".yml") {
|
|
106
|
+
return parseYamlText(text, filePath);
|
|
107
|
+
}
|
|
108
|
+
if (extension === ".json" || extension === ".jsonc") {
|
|
109
|
+
return parseJsoncText(text, filePath);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
throw new ConfigError(
|
|
113
|
+
`Cannot read the manifest at ${filePath}: '${extension}' is not a manifest format.\n` +
|
|
114
|
+
` A manifest is written as ${MANIFEST_EXTENSIONS.join(", ")}. Manifests are never ` +
|
|
115
|
+
`JavaScript, because sous must be able to read a repository without running its code.`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Loads a machine-written JSON file (the index, a lockfile, a store entry
|
|
121
|
+
* marker, a links map) with strict JSON parsing. Returns raw, unvalidated data.
|
|
122
|
+
*
|
|
123
|
+
* @param filePath - Absolute path to the file.
|
|
124
|
+
* @param label - Plain-language name of the file, used in error messages.
|
|
125
|
+
*/
|
|
126
|
+
export function loadJsonFile(filePath: string, label: string): unknown {
|
|
127
|
+
const text = readFileText(filePath, label);
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(text);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
throw new ConfigError(
|
|
132
|
+
`Could not parse the ${label} at ${filePath} as JSON:\n ${(error as Error).message}\n` +
|
|
133
|
+
` This file is written by sous; if it has been edited by hand, restoring it from ` +
|
|
134
|
+
`version control is usually the quickest fix.`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** True when the path exists and is a regular file. */
|
|
140
|
+
function isFile(candidate: string): boolean {
|
|
141
|
+
try {
|
|
142
|
+
return fs.statSync(candidate).isFile();
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Finds the one manifest with the given base name in a directory, trying each
|
|
150
|
+
* supported extension. Returns undefined when there is none.
|
|
151
|
+
*
|
|
152
|
+
* Two manifests in one directory is a hard error rather than a first-match-win,
|
|
153
|
+
* mirroring how sous treats two primary configs in one `.sous/` directory: a
|
|
154
|
+
* silent winner would make the repo's behavior depend on an implementation
|
|
155
|
+
* detail.
|
|
156
|
+
*
|
|
157
|
+
* @param directory - The directory to look in.
|
|
158
|
+
* @param baseName - The manifest base name, without an extension.
|
|
159
|
+
* @param label - Plain-language name of the manifest, used in error messages.
|
|
160
|
+
*/
|
|
161
|
+
export function findManifest(
|
|
162
|
+
directory: string,
|
|
163
|
+
baseName: string,
|
|
164
|
+
label: string
|
|
165
|
+
): string | undefined {
|
|
166
|
+
const found: string[] = [];
|
|
167
|
+
for (const extension of MANIFEST_EXTENSIONS) {
|
|
168
|
+
const candidate = path.join(directory, `${baseName}${extension}`);
|
|
169
|
+
if (isFile(candidate)) found.push(candidate);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (found.length > 1) {
|
|
173
|
+
const names = found.map((entry) => path.basename(entry)).join(", ");
|
|
174
|
+
throw new ConfigError(
|
|
175
|
+
`Found more than one ${label} in ${directory}: ${names}.\n` +
|
|
176
|
+
` A directory holds exactly one ${label}. Remove the copies you do not want, ` +
|
|
177
|
+
`so it is never ambiguous which one sous reads.`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return found[0];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Finds the repo manifest at the root of a repository.
|
|
186
|
+
*
|
|
187
|
+
* @param repoRoot - The repository's root directory.
|
|
188
|
+
*/
|
|
189
|
+
export function findRepoManifest(repoRoot: string): string | undefined {
|
|
190
|
+
return findManifest(repoRoot, REPO_MANIFEST_BASENAME, "repo manifest");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Finds the recipe manifest in a recipe folder.
|
|
195
|
+
*
|
|
196
|
+
* @param recipeDir - The recipe's directory.
|
|
197
|
+
*/
|
|
198
|
+
export function findRecipeManifest(recipeDir: string): string | undefined {
|
|
199
|
+
return findManifest(recipeDir, RECIPE_MANIFEST_BASENAME, "recipe manifest");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Finds the repo manifest and raises a ConfigError naming the directory when
|
|
204
|
+
* there is none, for callers that require one.
|
|
205
|
+
*
|
|
206
|
+
* @param repoRoot - The repository's root directory.
|
|
207
|
+
*/
|
|
208
|
+
export function requireRepoManifest(repoRoot: string): string {
|
|
209
|
+
const found = findRepoManifest(repoRoot);
|
|
210
|
+
if (found === undefined) {
|
|
211
|
+
throw new ConfigError(
|
|
212
|
+
`No repo manifest in ${repoRoot}.\n` +
|
|
213
|
+
` A sous repository declares itself with a '${REPO_MANIFEST_BASENAME}${MANIFEST_EXTENSIONS[0]}' ` +
|
|
214
|
+
`file at its root.`
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return found;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Finds the recipe manifest and raises a ConfigError naming the directory when
|
|
222
|
+
* there is none, for callers that require one.
|
|
223
|
+
*
|
|
224
|
+
* @param recipeDir - The recipe's directory.
|
|
225
|
+
*/
|
|
226
|
+
export function requireRecipeManifest(recipeDir: string): string {
|
|
227
|
+
const found = findRecipeManifest(recipeDir);
|
|
228
|
+
if (found === undefined) {
|
|
229
|
+
throw new ConfigError(
|
|
230
|
+
`No recipe manifest in ${recipeDir}.\n` +
|
|
231
|
+
` Every directory listed under 'recipes' in a repo manifest holds a ` +
|
|
232
|
+
`'${RECIPE_MANIFEST_BASENAME}${MANIFEST_EXTENSIONS[0]}' file.`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return found;
|
|
236
|
+
}
|