@savvy-web/silk-core 0.1.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/LICENSE +21 -0
- package/README.md +47 -0
- package/_virtual/_rolldown/runtime.js +18 -0
- package/errors/BiomeSyncError.js +22 -0
- package/errors/ChangesetConfigError.js +21 -0
- package/errors/ConfigNotFoundError.js +22 -0
- package/errors/PublishTargetBindingError.js +30 -0
- package/errors/WorkspaceAnalysisError.js +22 -0
- package/index.d.ts +1261 -0
- package/index.js +15 -0
- package/package.json +39 -0
- package/pr-body/body.js +145 -0
- package/pr-body/diagnostics.js +104 -0
- package/pr-body/index.js +22 -0
- package/pr-body/linked-issue.js +44 -0
- package/pr-body/markers.js +103 -0
- package/pr-body/references.js +157 -0
- package/pr-body/region.js +105 -0
- package/schemas/BiomeConfig.js +36 -0
- package/schemas/ConfigDiscoverySchemas.js +50 -0
- package/schemas/SavvyInstallSection.js +232 -0
- package/schemas/SavvySections.js +194 -0
- package/schemas/VersioningSchemas.js +87 -0
- package/schemas/WorkspaceAnalysisSchemas.js +209 -0
- package/tsdoc-metadata.json +11 -0
- package/utils/TrailingSlash.js +21 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
//#region src/pr-body/region.ts
|
|
2
|
+
/**
|
|
3
|
+
* The generic marker-delimited region grammar every silk-managed document
|
|
4
|
+
* uses: `<!-- token:start -->` … `<!-- token:end -->`.
|
|
5
|
+
*
|
|
6
|
+
* @remarks
|
|
7
|
+
* Extracted from `silk-release-action` (its `pr-body.ts` and
|
|
8
|
+
* `managed-sections.ts` both carried a private copy) so the grammar has one
|
|
9
|
+
* owner. **Every marker is a pair.** A lone opening marker can only be located
|
|
10
|
+
* by scanning forward to whatever happens to follow it, which makes the
|
|
11
|
+
* region's extent a function of its neighbours rather than of itself — moving
|
|
12
|
+
* anything nearby silently redefines it. The token is free-form; `:start` and
|
|
13
|
+
* `:end` are the whole contract, so pairs nest and a region can contain
|
|
14
|
+
* sub-regions without either needing to know about the other.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Pure helpers over the `<!-- token:start -->` / `<!-- token:end -->` region
|
|
18
|
+
* grammar.
|
|
19
|
+
*
|
|
20
|
+
* @remarks
|
|
21
|
+
* Every operation is total: a body with no region (or a broken pair) degrades
|
|
22
|
+
* to the documented fail-safe result rather than failing, because the callers
|
|
23
|
+
* are regenerating actions that must still produce a body when the prior one
|
|
24
|
+
* is malformed. Use `PrBodyDiagnostic.scan` when a caller wants to be told
|
|
25
|
+
* about a broken pair instead of silently tolerating it.
|
|
26
|
+
*
|
|
27
|
+
* @public
|
|
28
|
+
*/
|
|
29
|
+
var Region = class Region {
|
|
30
|
+
constructor() {}
|
|
31
|
+
/**
|
|
32
|
+
* The opening delimiter for a named region.
|
|
33
|
+
*
|
|
34
|
+
* @public
|
|
35
|
+
*/
|
|
36
|
+
static start(token) {
|
|
37
|
+
return `<!-- ${token}:start -->`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The closing delimiter for a named region.
|
|
41
|
+
*
|
|
42
|
+
* @public
|
|
43
|
+
*/
|
|
44
|
+
static end(token) {
|
|
45
|
+
return `<!-- ${token}:end -->`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The content between a region's delimiters, or `undefined` when absent.
|
|
49
|
+
*
|
|
50
|
+
* @remarks
|
|
51
|
+
* Finds the FIRST opening marker and the matching close after it, so a
|
|
52
|
+
* nested region of a different token is returned as part of the content
|
|
53
|
+
* rather than truncating it.
|
|
54
|
+
*
|
|
55
|
+
* @public
|
|
56
|
+
*/
|
|
57
|
+
static read(body, token) {
|
|
58
|
+
const from = body.indexOf(Region.start(token));
|
|
59
|
+
const to = body.indexOf(Region.end(token), from === -1 ? 0 : from);
|
|
60
|
+
if (from === -1 || to === -1 || to < from) return void 0;
|
|
61
|
+
return body.slice(from + Region.start(token).length, to);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Everything outside a region, with the region and its delimiters removed.
|
|
65
|
+
*
|
|
66
|
+
* @remarks
|
|
67
|
+
* A body without the region comes back unchanged — removal of an absent
|
|
68
|
+
* region is a no-op, not an error.
|
|
69
|
+
*
|
|
70
|
+
* @public
|
|
71
|
+
*/
|
|
72
|
+
static strip(body, token) {
|
|
73
|
+
const from = body.indexOf(Region.start(token));
|
|
74
|
+
const to = body.indexOf(Region.end(token), from === -1 ? 0 : from);
|
|
75
|
+
if (from === -1 || to === -1 || to < from) return body;
|
|
76
|
+
return `${body.slice(0, from)}${body.slice(to + Region.end(token).length)}`;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Put `rendered` (a fully rendered region, its own markers included) into
|
|
80
|
+
* `body`, replacing a previous region of the same token and leaving
|
|
81
|
+
* everything else alone.
|
|
82
|
+
*
|
|
83
|
+
* @remarks
|
|
84
|
+
* **Human edits outside the markers survive.** A predecessor spliced on a
|
|
85
|
+
* markdown heading, which silently ate any content a human happened to put
|
|
86
|
+
* under a heading of that name and could not tell generated text from
|
|
87
|
+
* theirs. An explicit marker pair can.
|
|
88
|
+
*
|
|
89
|
+
* A body with no markers keeps its content and gains the region **below**
|
|
90
|
+
* it, so an existing hand-written document is not displaced. The result is
|
|
91
|
+
* trimmed.
|
|
92
|
+
*
|
|
93
|
+
* @public
|
|
94
|
+
*/
|
|
95
|
+
static upsert(body, token, rendered) {
|
|
96
|
+
const start = body.indexOf(Region.start(token));
|
|
97
|
+
const end = body.indexOf(Region.end(token));
|
|
98
|
+
if (start !== -1 && end !== -1 && end > start) return `${body.slice(0, start)}${rendered}${body.slice(end + Region.end(token).length)}`.trim();
|
|
99
|
+
const trimmed = body.trim();
|
|
100
|
+
return trimmed === "" ? rendered : `${trimmed}\n\n${rendered}`;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
//#endregion
|
|
105
|
+
export { Region };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/schemas/BiomeConfig.ts
|
|
4
|
+
/**
|
|
5
|
+
* Result of a Biome schema URL sync or check operation.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* - `updated` — paths of config files whose `$schema` URL was changed (or would be changed on `check`).
|
|
9
|
+
* - `skipped` — paths of config files with no `$schema` field or a non-biomejs.dev URL.
|
|
10
|
+
* - `current` — paths of config files already pointing to the expected schema URL.
|
|
11
|
+
*
|
|
12
|
+
* @since 0.1.0
|
|
13
|
+
* @public
|
|
14
|
+
*/
|
|
15
|
+
const BiomeSyncResult = Schema.Struct({
|
|
16
|
+
updated: Schema.Array(Schema.String),
|
|
17
|
+
skipped: Schema.Array(Schema.String),
|
|
18
|
+
current: Schema.Array(Schema.String)
|
|
19
|
+
});
|
|
20
|
+
/**
|
|
21
|
+
* Options for `BiomeSchemaSync` operations.
|
|
22
|
+
*
|
|
23
|
+
* @remarks
|
|
24
|
+
* `cwd` overrides the working directory used to locate `biome.json` / `biome.jsonc`.
|
|
25
|
+
* `gitignore` is reserved for future use to skip gitignored config files (defaults to `true`).
|
|
26
|
+
*
|
|
27
|
+
* @since 0.1.0
|
|
28
|
+
* @public
|
|
29
|
+
*/
|
|
30
|
+
const BiomeSyncOptions = Schema.Struct({
|
|
31
|
+
cwd: Schema.optional(Schema.String),
|
|
32
|
+
gitignore: Schema.Boolean.pipe(Schema.withDecodingDefaultType(Effect.succeed(true)), Schema.withConstructorDefault(Effect.succeed(true)))
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
//#endregion
|
|
36
|
+
export { BiomeSyncOptions, BiomeSyncResult };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/schemas/ConfigDiscoverySchemas.ts
|
|
4
|
+
/**
|
|
5
|
+
* The discovery strategy used to locate a config file.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* - `"lib"` — found under `lib/configs/{name}` relative to the workspace root.
|
|
9
|
+
* - `"root"` — found directly in the workspace root as `{name}`.
|
|
10
|
+
* - `"cosmiconfig"` — reserved for future cosmiconfig-based discovery.
|
|
11
|
+
*
|
|
12
|
+
* @since 0.1.0
|
|
13
|
+
* @public
|
|
14
|
+
*/
|
|
15
|
+
const ConfigSource = Schema.Literals([
|
|
16
|
+
"lib",
|
|
17
|
+
"root",
|
|
18
|
+
"cosmiconfig"
|
|
19
|
+
]);
|
|
20
|
+
/**
|
|
21
|
+
* The resolved location of a discovered config file.
|
|
22
|
+
*
|
|
23
|
+
* @remarks
|
|
24
|
+
* Produced by `ConfigDiscovery.find` and `ConfigDiscovery.findAll`.
|
|
25
|
+
* `path` is the absolute file path; `source` indicates how it was discovered.
|
|
26
|
+
*
|
|
27
|
+
* @since 0.1.0
|
|
28
|
+
* @public
|
|
29
|
+
*/
|
|
30
|
+
const ConfigLocation = Schema.Struct({
|
|
31
|
+
path: Schema.String,
|
|
32
|
+
source: ConfigSource
|
|
33
|
+
});
|
|
34
|
+
/**
|
|
35
|
+
* Options passed to config discovery methods.
|
|
36
|
+
*
|
|
37
|
+
* @remarks
|
|
38
|
+
* `cwd` overrides the working directory for path resolution (defaults to the current working directory).
|
|
39
|
+
* `tool` is reserved for future use as a tool-specific discovery hint.
|
|
40
|
+
*
|
|
41
|
+
* @since 0.1.0
|
|
42
|
+
* @public
|
|
43
|
+
*/
|
|
44
|
+
const ConfigDiscoveryOptions = Schema.Struct({
|
|
45
|
+
cwd: Schema.optional(Schema.String),
|
|
46
|
+
tool: Schema.optional(Schema.String)
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
//#endregion
|
|
50
|
+
export { ConfigDiscoveryOptions, ConfigLocation, ConfigSource };
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { CommentStyle, SectionId } from "@effected/templates";
|
|
2
|
+
|
|
3
|
+
//#region src/schemas/SavvyInstallSection.ts
|
|
4
|
+
/**
|
|
5
|
+
* Section identity for the dependency auto-install block.
|
|
6
|
+
*
|
|
7
|
+
* `toolName` is `"savvy-install"`; pair with {@link savvyInstallDeps}.
|
|
8
|
+
*
|
|
9
|
+
* @since 7.5.0
|
|
10
|
+
* @public
|
|
11
|
+
*/
|
|
12
|
+
const SavvyInstallSection = SectionId.make({
|
|
13
|
+
key: "SAVVY-INSTALL",
|
|
14
|
+
commentStyle: CommentStyle.hash
|
|
15
|
+
});
|
|
16
|
+
/**
|
|
17
|
+
* Pathspecs whose change across a checkout or merge means `node_modules` is stale.
|
|
18
|
+
*
|
|
19
|
+
* @remarks
|
|
20
|
+
* Git's default pathspec globbing does not stop a wildcard at a slash, so the
|
|
21
|
+
* one nested-manifest entry below covers every workspace manifest at any depth
|
|
22
|
+
* without needing a recursive spelling. Manifests are listed alongside lockfiles
|
|
23
|
+
* because a dependency edit that has not been installed yet moves the manifest
|
|
24
|
+
* first and the lockfile not at all.
|
|
25
|
+
*/
|
|
26
|
+
const DEPENDENCY_PATHS = [
|
|
27
|
+
"package.json",
|
|
28
|
+
"*/package.json",
|
|
29
|
+
"pnpm-lock.yaml",
|
|
30
|
+
"pnpm-workspace.yaml",
|
|
31
|
+
"package-lock.json",
|
|
32
|
+
"npm-shrinkwrap.json",
|
|
33
|
+
"yarn.lock",
|
|
34
|
+
"bun.lock",
|
|
35
|
+
"bun.lockb"
|
|
36
|
+
].map((path) => `'${path}'`).join(" ");
|
|
37
|
+
/**
|
|
38
|
+
* Dependency auto-install shared across Silk Suite hook files.
|
|
39
|
+
*
|
|
40
|
+
* @remarks
|
|
41
|
+
* Brings `node_modules` back in line after a branch switch or a pull, so a batch
|
|
42
|
+
* of repos pulled to align their dependencies does not each need a manual
|
|
43
|
+
* install. Installs with lifecycle scripts skipped — `--ignore-scripts`, or
|
|
44
|
+
* `--mode=skip-build` for Yarn Berry, which dropped the former — because the goal
|
|
45
|
+
* is to get the dependency tree on disk, not to run a full postinstall.
|
|
46
|
+
*
|
|
47
|
+
* **That flag has a cost, and one shape of repo cannot pay it.** Where a package
|
|
48
|
+
* publishes through a built link directory — `publishConfig.directory` with
|
|
49
|
+
* `linkDirectory: true`, which is how this monorepo wires `dist/dev/pkg` — the
|
|
50
|
+
* workspace resolves its own dependencies through a directory that a `prepare`
|
|
51
|
+
* script has to build, and skipping scripts yields a populated `node_modules`
|
|
52
|
+
* pointing at nothing.
|
|
53
|
+
*
|
|
54
|
+
* Those repos ask for a full install through the LOCAL git config key
|
|
55
|
+
* `savvy.installLifecycleScripts`, which `savvy init` sets when it finds the
|
|
56
|
+
* shape. The decision deliberately does NOT read the checked-out tree. Doing so
|
|
57
|
+
* failed in both directions at once: a branch that merely declared the shape
|
|
58
|
+
* could turn lifecycle scripts back on just by being checked out, making
|
|
59
|
+
* `git checkout` of an untrusted revision a code-execution path; and the `jq`
|
|
60
|
+
* the scan needed is absent on stock macOS and Ubuntu, where the missing answer
|
|
61
|
+
* silently skipped scripts in precisely the repos that cannot survive it.
|
|
62
|
+
* `.git/config` is neither checked out nor parsed with `jq`, so it has neither
|
|
63
|
+
* failure mode.
|
|
64
|
+
*
|
|
65
|
+
* The package managers gate dependency scripts themselves on top of this —
|
|
66
|
+
* pnpm's `strictDepBuilds` with an `allowBuilds` allowlist being the case in
|
|
67
|
+
* point — but workspace and root lifecycle scripts still run, which is why the
|
|
68
|
+
* opt-in is local rather than inferred. With the flag on, the hook says on the
|
|
69
|
+
* way out that scripts were skipped rather than leaving it to be discovered.
|
|
70
|
+
*
|
|
71
|
+
* Deliberately self-contained, like `savvyToolchainCheck`: its homes carry
|
|
72
|
+
* `SavvyHooksSection` but no `SavvyBaseSection`, so it defines its own root, CI
|
|
73
|
+
* and package-manager lookups rather than depending on `ROOT`, `in_ci` or `PM`.
|
|
74
|
+
*
|
|
75
|
+
* Four guards keep it from firing on the many checkouts that are not dependency
|
|
76
|
+
* events, each a silent no-op:
|
|
77
|
+
*
|
|
78
|
+
* - CI, where the runtime action owns installation by construction.
|
|
79
|
+
* - `SAVVY_SKIP_INSTALL`, the escape hatch for a bisect or a scripted sweep that
|
|
80
|
+
* does not want an install between steps.
|
|
81
|
+
* - `post-checkout` only: a branch-flag of `0`, which is `git checkout -- <file>`
|
|
82
|
+
* and not a move between commits.
|
|
83
|
+
* - **The gate that makes this affordable:** nothing dependency-related actually
|
|
84
|
+
* changed across the move. Without it every branch switch would pay for a full
|
|
85
|
+
* install. A missing `node_modules` overrides it, since there is nothing to be
|
|
86
|
+
* stale.
|
|
87
|
+
*
|
|
88
|
+
* A fresh clone is not among the cases it handles, and cannot be: husky sets
|
|
89
|
+
* `core.hooksPath` from its own `prepare` script, so until the first manual
|
|
90
|
+
* install has run there is no hook installed to fire.
|
|
91
|
+
*
|
|
92
|
+
* The install's exit status is swallowed and its output goes to stderr. Git
|
|
93
|
+
* ignores what `post-checkout` and `post-merge` return, so a failure here must
|
|
94
|
+
* not look like a failed checkout; the hint names the escape hatch instead.
|
|
95
|
+
*
|
|
96
|
+
* Every probe that can legitimately fail — no `ORIG_HEAD` to compare against, an
|
|
97
|
+
* unresolvable range — is neutralised with `|| true` rather than left to its own
|
|
98
|
+
* status. These blocks are co-owned and consumer content can sit below them, so
|
|
99
|
+
* under a hook running `set -e` a bare failing substitution would abort the whole
|
|
100
|
+
* file and take those later sections with it.
|
|
101
|
+
*
|
|
102
|
+
* @param hook - Which hook the block is being generated for; decides the
|
|
103
|
+
* argument guard and how the comparison range is recovered.
|
|
104
|
+
* @returns The install shell, with no surrounding markers or trailing newline.
|
|
105
|
+
*
|
|
106
|
+
* @since 7.5.0
|
|
107
|
+
* @public
|
|
108
|
+
*/
|
|
109
|
+
function savvyInstallDeps(hook) {
|
|
110
|
+
const skip = `! { [ -n "$CI" ] || [ -n "$GITHUB_ACTIONS" ] || [ -n "$SAVVY_SKIP_INSTALL" ]; }`;
|
|
111
|
+
return `if ${hook === "post-checkout" ? `${skip} && [ "$3" = "1" ]` : skip}; then
|
|
112
|
+
install_root=$(git rev-parse --show-toplevel 2>/dev/null) || true
|
|
113
|
+
${hook === "post-checkout" ? ` install_from="$1"
|
|
114
|
+
install_to="$2"` : ` install_from=$(git rev-parse --verify --quiet ORIG_HEAD 2>/dev/null) || true
|
|
115
|
+
install_to="HEAD"`}
|
|
116
|
+
install_pm=""
|
|
117
|
+
if [ -n "$install_root" ] && [ -f "$install_root/package.json" ]; then
|
|
118
|
+
if command -v jq >/dev/null 2>&1; then
|
|
119
|
+
install_pm=$(jq -r '.packageManager // empty' "$install_root/package.json" 2>/dev/null | cut -d'@' -f1)
|
|
120
|
+
fi
|
|
121
|
+
if [ -z "$install_pm" ]; then
|
|
122
|
+
if [ -f "$install_root/pnpm-lock.yaml" ]; then install_pm="pnpm"
|
|
123
|
+
elif [ -f "$install_root/yarn.lock" ]; then install_pm="yarn"
|
|
124
|
+
elif [ -f "$install_root/bun.lock" ] || [ -f "$install_root/bun.lockb" ]; then install_pm="bun"
|
|
125
|
+
else install_pm="npm"; fi
|
|
126
|
+
fi
|
|
127
|
+
fi
|
|
128
|
+
# packageManager comes from the checked-out revision, so it names an executable
|
|
129
|
+
# the tree controls. 'command -v' proves a binary exists, not that it is a
|
|
130
|
+
# package manager, so the name is checked against the supported four first.
|
|
131
|
+
case "$install_pm" in
|
|
132
|
+
npm|pnpm|yarn|bun) ;;
|
|
133
|
+
*) install_pm="" ;;
|
|
134
|
+
esac
|
|
135
|
+
# Nothing to bring up to date unless a manifest or lockfile actually moved.
|
|
136
|
+
# A missing node_modules skips the diff outright: there is no tree to be stale.
|
|
137
|
+
install_stale=""
|
|
138
|
+
if [ -n "$install_pm" ]; then
|
|
139
|
+
if [ ! -d "$install_root/node_modules" ]; then
|
|
140
|
+
install_stale=1
|
|
141
|
+
elif [ -n "$install_from" ] && [ -n "$install_to" ]; then
|
|
142
|
+
install_stale=$(git diff --name-only "$install_from" "$install_to" -- ${DEPENDENCY_PATHS} 2>/dev/null | head -n 1) || true
|
|
143
|
+
fi
|
|
144
|
+
fi
|
|
145
|
+
if [ -n "$install_stale" ] && command -v "$install_pm" >/dev/null 2>&1; then
|
|
146
|
+
# Whether lifecycle scripts run is a LOCAL decision, read from .git/config,
|
|
147
|
+
# which no checkout can rewrite. Default off. 'savvy init' turns it on for a
|
|
148
|
+
# workspace that publishes through built link directories, where the links
|
|
149
|
+
# point at directories a prepare script has to produce.
|
|
150
|
+
install_scripts=$(git -C "$install_root" config --bool --get savvy.installLifecycleScripts 2>/dev/null) || true
|
|
151
|
+
if [ "$install_scripts" = "true" ]; then
|
|
152
|
+
install_flag=""
|
|
153
|
+
else
|
|
154
|
+
# Berry dropped --ignore-scripts for the install mode; Classic never knew the
|
|
155
|
+
# mode. An unreadable major reads as Berry, the likelier of the two today.
|
|
156
|
+
install_flag="--ignore-scripts"
|
|
157
|
+
if [ "$install_pm" = "yarn" ]; then
|
|
158
|
+
case "$(yarn --version 2>/dev/null | cut -d. -f1)" in
|
|
159
|
+
1) ;;
|
|
160
|
+
*) install_flag="--mode=skip-build" ;;
|
|
161
|
+
esac
|
|
162
|
+
fi
|
|
163
|
+
fi
|
|
164
|
+
# ':+' so a full install announces itself as "pnpm install", not "pnpm install ".
|
|
165
|
+
printf '↻ dependencies changed, running %s install%s\\n' "$install_pm" "\${install_flag:+ $install_flag}" >&2
|
|
166
|
+
# Unquoted on purpose: empty must expand to NO argument rather than an empty
|
|
167
|
+
# one. Safe because every value is a static flag literal from this generator.
|
|
168
|
+
if ( cd "$install_root" && "$install_pm" install $install_flag ) >&2; then
|
|
169
|
+
if [ -n "$install_flag" ]; then
|
|
170
|
+
printf ' Lifecycle scripts were skipped. Run a full install if you need build outputs.\\n' >&2
|
|
171
|
+
fi
|
|
172
|
+
else
|
|
173
|
+
printf '⚠ %s install failed; run it yourself to see why.\\n' "$install_pm" >&2
|
|
174
|
+
printf ' Set SAVVY_SKIP_INSTALL=1 to stop this hook from trying.\\n' >&2
|
|
175
|
+
fi
|
|
176
|
+
fi
|
|
177
|
+
unset install_root install_from install_to install_pm install_stale install_scripts install_flag
|
|
178
|
+
fi`;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The local git config key that authorizes lifecycle scripts during a hook install.
|
|
182
|
+
*
|
|
183
|
+
* @remarks
|
|
184
|
+
* Local scope only. It lives in `.git/config`, which is never checked out, so no
|
|
185
|
+
* incoming revision can set it — that is the whole point of reading the decision
|
|
186
|
+
* from here rather than from a manifest in the tree.
|
|
187
|
+
*
|
|
188
|
+
* @since 7.5.0
|
|
189
|
+
* @public
|
|
190
|
+
*/
|
|
191
|
+
const LIFECYCLE_SCRIPTS_CONFIG_KEY = "savvy.installLifecycleScripts";
|
|
192
|
+
/**
|
|
193
|
+
* Whether `manifest` publishes through a built link directory.
|
|
194
|
+
*
|
|
195
|
+
* @remarks
|
|
196
|
+
* `publishConfig.directory` with `linkDirectory: true` means consumers of this
|
|
197
|
+
* package resolve it through a directory that a `prepare` script has to produce,
|
|
198
|
+
* so an install that skips lifecycle scripts leaves the link pointing at nothing.
|
|
199
|
+
* A workspace containing any such package is one whose owner probably wants
|
|
200
|
+
* {@link LIFECYCLE_SCRIPTS_CONFIG_KEY} set.
|
|
201
|
+
*
|
|
202
|
+
* Reporting the shape is deliberately separate from acting on it: this answers
|
|
203
|
+
* "does this repo need scripts", and a human still decides whether hook-time
|
|
204
|
+
* installs may run them.
|
|
205
|
+
*
|
|
206
|
+
* @param manifest - A parsed `package.json`; any non-object reads as `false`.
|
|
207
|
+
*
|
|
208
|
+
* @since 7.5.0
|
|
209
|
+
* @public
|
|
210
|
+
*/
|
|
211
|
+
function publishesBuiltLinkDirectory(manifest) {
|
|
212
|
+
if (typeof manifest !== "object" || manifest === null) return false;
|
|
213
|
+
const { publishConfig } = manifest;
|
|
214
|
+
if (typeof publishConfig !== "object" || publishConfig === null) return false;
|
|
215
|
+
const { directory, linkDirectory } = publishConfig;
|
|
216
|
+
return typeof directory === "string" && directory.length > 0 && linkDirectory === true;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Build the dependency auto-install block for `hook`.
|
|
220
|
+
*
|
|
221
|
+
* @param hook - Which hook the section is destined for.
|
|
222
|
+
* @returns A shell `Section` (`commentStyle: hash`) keyed `SAVVY-INSTALL`.
|
|
223
|
+
*
|
|
224
|
+
* @since 7.5.0
|
|
225
|
+
* @public
|
|
226
|
+
*/
|
|
227
|
+
function savvyInstallBlock(hook) {
|
|
228
|
+
return SavvyInstallSection.section(savvyInstallDeps(hook));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
//#endregion
|
|
232
|
+
export { LIFECYCLE_SCRIPTS_CONFIG_KEY, SavvyInstallSection, publishesBuiltLinkDirectory, savvyInstallBlock, savvyInstallDeps };
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { CommentStyle, SectionId } from "@effected/templates";
|
|
2
|
+
|
|
3
|
+
//#region src/schemas/SavvySections.ts
|
|
4
|
+
/**
|
|
5
|
+
* Build a shell-hook section identity from a Silk tool name.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* **The key is uppercased here, and that is load-bearing.** The kit renders a
|
|
9
|
+
* key verbatim into its markers (`# --- BEGIN <key> MANAGED SECTION ---`),
|
|
10
|
+
* while the section model this replaces uppercased `toolName` on the way in. A
|
|
11
|
+
* lowercase key would therefore emit `# --- BEGIN savvy-base MANAGED SECTION ---`
|
|
12
|
+
* and no longer match the `SAVVY-BASE` markers already written into every
|
|
13
|
+
* consumer repo's hook files — `check` would report the section absent and
|
|
14
|
+
* `sync` would append a second copy beside the first. Uppercasing keeps the
|
|
15
|
+
* marker bytes identical across the migration.
|
|
16
|
+
*/
|
|
17
|
+
const shellSection = (toolName) => SectionId.make({
|
|
18
|
+
key: toolName.toUpperCase(),
|
|
19
|
+
commentStyle: CommentStyle.hash
|
|
20
|
+
});
|
|
21
|
+
/**
|
|
22
|
+
* Section identity for the shared package-manager preamble.
|
|
23
|
+
*
|
|
24
|
+
* `toolName` is `"savvy-base"`; pair with {@link savvyBasePreamble} to build the block:
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* const section = SavvyBaseSection.section(savvyBasePreamble());
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* @since 0.5.0
|
|
32
|
+
* @public
|
|
33
|
+
*/
|
|
34
|
+
const SavvyBaseSection = shellSection("savvy-base");
|
|
35
|
+
/**
|
|
36
|
+
* Section identity for the shared repo-hygiene block.
|
|
37
|
+
*
|
|
38
|
+
* `toolName` is `"savvy-hooks"`; pair with {@link savvyHooksHygiene}.
|
|
39
|
+
*
|
|
40
|
+
* @since 0.5.0
|
|
41
|
+
* @public
|
|
42
|
+
*/
|
|
43
|
+
const SavvyHooksSection = shellSection("savvy-hooks");
|
|
44
|
+
/**
|
|
45
|
+
* Package-manager detection preamble shared across Silk Suite hook files.
|
|
46
|
+
*
|
|
47
|
+
* @remarks
|
|
48
|
+
* Side-effect-free definitions meant to run unconditionally — no markers, no outer CI
|
|
49
|
+
* guard. Defines `ROOT`, the `in_ci` predicate, `PM` (via `detect_pm`), and `pm_exec`.
|
|
50
|
+
* `pm_exec` uses local/exec semantics for every package manager and `bun x` (space form),
|
|
51
|
+
* which works regardless of how bun was installed (the `bunx` shim is not always on PATH).
|
|
52
|
+
*
|
|
53
|
+
* @returns The preamble shell, with no surrounding markers or trailing newline.
|
|
54
|
+
*
|
|
55
|
+
* @since 0.5.0
|
|
56
|
+
* @public
|
|
57
|
+
*/
|
|
58
|
+
function savvyBasePreamble() {
|
|
59
|
+
return `ROOT=$(git rev-parse --show-toplevel)
|
|
60
|
+
|
|
61
|
+
in_ci() { [ -n "$CI" ] || [ -n "$GITHUB_ACTIONS" ]; }
|
|
62
|
+
|
|
63
|
+
detect_pm() {
|
|
64
|
+
if [ -f "$ROOT/package.json" ]; then
|
|
65
|
+
pm=$(jq -r '.packageManager // empty' "$ROOT/package.json" 2>/dev/null | cut -d'@' -f1)
|
|
66
|
+
if [ -n "$pm" ]; then echo "$pm"; return; fi
|
|
67
|
+
fi
|
|
68
|
+
if [ -f "$ROOT/pnpm-lock.yaml" ]; then echo "pnpm"
|
|
69
|
+
elif [ -f "$ROOT/yarn.lock" ]; then echo "yarn"
|
|
70
|
+
elif [ -f "$ROOT/bun.lock" ]; then echo "bun"
|
|
71
|
+
else echo "npm"; fi
|
|
72
|
+
}
|
|
73
|
+
PM=$(detect_pm)
|
|
74
|
+
|
|
75
|
+
pm_exec() {
|
|
76
|
+
case "$PM" in
|
|
77
|
+
pnpm) pnpm exec "$@" ;;
|
|
78
|
+
yarn) yarn exec "$@" ;;
|
|
79
|
+
bun) bun x "$@" ;;
|
|
80
|
+
*) npx --no -- "$@" ;;
|
|
81
|
+
esac
|
|
82
|
+
}`;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Repo-hygiene block shared across Silk Suite hook files.
|
|
86
|
+
*
|
|
87
|
+
* @remarks
|
|
88
|
+
* Self-guarded against CI and needs no package manager: disables Git's `core.fileMode`
|
|
89
|
+
* tracking and marks tracked shell scripts executable.
|
|
90
|
+
*
|
|
91
|
+
* @returns The hygiene shell, with no surrounding markers or trailing newline.
|
|
92
|
+
*
|
|
93
|
+
* @since 0.5.0
|
|
94
|
+
* @public
|
|
95
|
+
*/
|
|
96
|
+
function savvyHooksHygiene() {
|
|
97
|
+
return `if ! { [ -n "$CI" ] || [ -n "$GITHUB_ACTIONS" ]; }; then
|
|
98
|
+
git config core.fileMode false
|
|
99
|
+
git ls-files -z '*.sh' | xargs -0 chmod +x 2>/dev/null || true
|
|
100
|
+
fi`;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Build a consumer's one-line tool section so every consumer calls the shared base
|
|
104
|
+
* helpers identically.
|
|
105
|
+
*
|
|
106
|
+
* @remarks
|
|
107
|
+
* The returned block's content is exactly `in_ci || pm_exec <command>` with `command`
|
|
108
|
+
* appended verbatim — it is not parsed, quoted, or interpolated, so shell tokens like
|
|
109
|
+
* `$ROOT` and `$1` survive into the generated literal.
|
|
110
|
+
*
|
|
111
|
+
* **Precondition:** a {@link SavvyBaseSection} block must precede this section in the same
|
|
112
|
+
* hook file so `in_ci` and `pm_exec` are defined. Consumers guarantee this by passing both
|
|
113
|
+
* to `ManagedSection.syncAll` in order:
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* ```ts
|
|
117
|
+
* yield* sections.syncAll(".husky/commit-msg", [
|
|
118
|
+
* SavvyBaseSection.section(savvyBasePreamble()),
|
|
119
|
+
* savvyToolSection("savvy-commit", 'commitlint --config "$ROOT/lib/configs/commitlint.config.ts" --edit "$1"'),
|
|
120
|
+
* ]);
|
|
121
|
+
* ```
|
|
122
|
+
*
|
|
123
|
+
* @param toolName - Section identity; also drives the marker names (uppercased).
|
|
124
|
+
* @param command - The command passed verbatim to `pm_exec`, run only outside CI.
|
|
125
|
+
* @returns A shell `Section` (`commentStyle: hash`) for `toolName`.
|
|
126
|
+
*
|
|
127
|
+
* @since 0.5.0
|
|
128
|
+
* @public
|
|
129
|
+
*/
|
|
130
|
+
function savvyToolSection(toolName, command) {
|
|
131
|
+
return shellSection(toolName).section(`in_ci || pm_exec ${command}`);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Section identity for the package-manager toolchain drift check.
|
|
135
|
+
*
|
|
136
|
+
* `toolName` is `"savvy-toolchain"`; pair with {@link savvyToolchainCheck}.
|
|
137
|
+
*
|
|
138
|
+
* @since 7.3.0
|
|
139
|
+
* @public
|
|
140
|
+
*/
|
|
141
|
+
const SavvyToolchainSection = shellSection("savvy-toolchain");
|
|
142
|
+
/**
|
|
143
|
+
* Package-manager drift check shared across Silk Suite hook files.
|
|
144
|
+
*
|
|
145
|
+
* @remarks
|
|
146
|
+
* Compares the running package manager's version against the repo's
|
|
147
|
+
* `devEngines.packageManager` pin and prints a warning on mismatch. **Warn only** —
|
|
148
|
+
* it never blocks the hook and never installs anything, so nobody mid-bisect or
|
|
149
|
+
* mid-rebase on an older pin is stranded.
|
|
150
|
+
*
|
|
151
|
+
* Deliberately self-contained: its homes are `.husky/post-checkout` and
|
|
152
|
+
* `.husky/post-merge`, which carry {@link SavvyHooksSection} but no
|
|
153
|
+
* {@link SavvyBaseSection}, so it defines its own root/CI/pin lookups rather than
|
|
154
|
+
* depending on `ROOT`, `in_ci` or `PM`. It honours the `name` recorded in the pin
|
|
155
|
+
* rather than assuming pnpm.
|
|
156
|
+
*
|
|
157
|
+
* Every input is treated as optional: no `git` root, no `jq`, no `devEngines` block,
|
|
158
|
+
* or a package manager that is not on `PATH` all mean "say nothing". Only an exact
|
|
159
|
+
* pin is comparable, so ranges (`^1.2.3`, `>=1 || <2`) and wildcards (`1.x`) are
|
|
160
|
+
* skipped, and the `+sha512…` integrity tail `devEngines` versions routinely carry is
|
|
161
|
+
* stripped before comparison. Skipped under CI, where the runtime action installs the
|
|
162
|
+
* pin by construction.
|
|
163
|
+
*
|
|
164
|
+
* @returns The drift-check shell, with no surrounding markers or trailing newline.
|
|
165
|
+
*
|
|
166
|
+
* @since 7.3.0
|
|
167
|
+
* @public
|
|
168
|
+
*/
|
|
169
|
+
function savvyToolchainCheck() {
|
|
170
|
+
return `if ! { [ -n "$CI" ] || [ -n "$GITHUB_ACTIONS" ]; }; then
|
|
171
|
+
toolchain_root=$(git rev-parse --show-toplevel 2>/dev/null)
|
|
172
|
+
toolchain_pm=""
|
|
173
|
+
toolchain_pin=""
|
|
174
|
+
if [ -n "$toolchain_root" ] && [ -f "$toolchain_root/package.json" ] && command -v jq >/dev/null 2>&1; then
|
|
175
|
+
toolchain_pm=$(jq -r '.devEngines.packageManager.name // empty' "$toolchain_root/package.json" 2>/dev/null)
|
|
176
|
+
toolchain_pin=$(jq -r '.devEngines.packageManager.version // empty' "$toolchain_root/package.json" 2>/dev/null | cut -d'+' -f1)
|
|
177
|
+
fi
|
|
178
|
+
# Only an exact pin is comparable: drop ranges (^ ~ >= ||) and wildcards (x, *).
|
|
179
|
+
case "$toolchain_pin" in ""|[!0-9]*|*[!0-9A-Za-z.-]*|*x*|*X*) toolchain_pin="" ;; esac
|
|
180
|
+
if [ -n "$toolchain_pm" ] && [ -n "$toolchain_pin" ] && command -v "$toolchain_pm" >/dev/null 2>&1; then
|
|
181
|
+
toolchain_have=$("$toolchain_pm" --version 2>/dev/null | head -n 1 | tr -d '[:space:]')
|
|
182
|
+
# A manager that failed or answered with prose says nothing about drift.
|
|
183
|
+
case "$toolchain_have" in [!0-9]*|*[!0-9A-Za-z.-]*) toolchain_have="" ;; esac
|
|
184
|
+
if [ -n "$toolchain_have" ] && [ "$toolchain_have" != "$toolchain_pin" ]; then
|
|
185
|
+
printf '⚠ %s %s does not match %s, the version pinned in devEngines.packageManager.\\n' "$toolchain_pm" "$toolchain_have" "$toolchain_pin" >&2
|
|
186
|
+
printf ' Lockfiles written by this version may differ from CI. Fix: corepack use %s@%s\\n' "$toolchain_pm" "$toolchain_pin" >&2
|
|
187
|
+
fi
|
|
188
|
+
fi
|
|
189
|
+
unset toolchain_root toolchain_pm toolchain_pin toolchain_have
|
|
190
|
+
fi`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
//#endregion
|
|
194
|
+
export { SavvyBaseSection, SavvyHooksSection, SavvyToolchainSection, savvyBasePreamble, savvyHooksHygiene, savvyToolSection, savvyToolchainCheck };
|