@drkmttr/adhere 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/package.json +6 -6
- package/src/cli.ts +76 -3
- package/src/init.ts +168 -13
- package/src/install.ts +147 -0
package/README.md
CHANGED
|
@@ -139,6 +139,9 @@ adhere lint --deny-warnings # fail on warnings as well as errors
|
|
|
139
139
|
adhere lint --log-level debug # log each request to Jev on stderr
|
|
140
140
|
adhere validate # check the rules' wording; ask Jev whether any contradict
|
|
141
141
|
adhere init [--force] # scaffold .adhere/config.ts and two example rules
|
|
142
|
+
adhere init --shared org/repo # scaffold a repo of rules other repos install
|
|
143
|
+
adhere list org/repo # list the rules in another repo's .adhere/
|
|
144
|
+
adhere install org/repo # copy them into this repo's .adhere/org/repo/
|
|
142
145
|
adhere login # save a TypeSafe AI API key for later runs
|
|
143
146
|
adhere logout # delete the saved key
|
|
144
147
|
adhere skill # print the agent skill (below)
|
|
@@ -307,6 +310,52 @@ msgpackr-extract, an optional native add-on that comes with `effect` and that
|
|
|
307
310
|
adhere never uses, with a warning instead of failing.
|
|
308
311
|
It skips the install when `package.json` already lists adhere.
|
|
309
312
|
|
|
313
|
+
`adhere init --shared org/repo` scaffolds a repo whose rules other repos copy
|
|
314
|
+
in with `adhere install` (see [Sharing rules](#sharing-rules)) instead of one
|
|
315
|
+
that lints itself. It writes the two example rules and no config, since
|
|
316
|
+
install copies only rule files, and with them:
|
|
317
|
+
|
|
318
|
+
- `README.md`, which says what the repo is for, how to install its rules, and
|
|
319
|
+
how to add one.
|
|
320
|
+
- `.github/workflows/adhere.yaml`, which runs `adhere validate` on every push
|
|
321
|
+
to main and every pull request, with the `TYPESAFE_API_KEY` secret. A pull
|
|
322
|
+
request from a fork gets no secrets, so validate refuses there.
|
|
323
|
+
- `alchemy.run.ts`, an [alchemy](https://alchemy.run) stack that creates the
|
|
324
|
+
private GitHub repo, or adopts it, and sets that secret from
|
|
325
|
+
`TYPESAFE_API_KEY` when deployed with `npx alchemy deploy`.
|
|
326
|
+
- `package.json`, with alchemy and effect pinned to versions that work
|
|
327
|
+
together, which init installs, and a `.gitignore` for them and alchemy's
|
|
328
|
+
state.
|
|
329
|
+
|
|
330
|
+
### Sharing rules
|
|
331
|
+
|
|
332
|
+
An organization's rules can live in one repo's `.adhere/`, and other repos
|
|
333
|
+
copy them in. `adhere list org/repo` prints each rule there with its
|
|
334
|
+
description. `adhere install org/repo` copies every rule into this repo's
|
|
335
|
+
`.adhere/org/repo/`, so `data/brand-ports` from darkmatter/standards is
|
|
336
|
+
`.adhere/darkmatter/standards/data/brand-ports.md`, the rule
|
|
337
|
+
`darkmatter/standards/data/brand-ports`, and two repos' rules never collide.
|
|
338
|
+
A topic or a rule after the repo copies only that part, and `#` picks a branch
|
|
339
|
+
or tag:
|
|
340
|
+
|
|
341
|
+
```sh
|
|
342
|
+
adhere install darkmatter/standards/data # the data topic
|
|
343
|
+
adhere install darkmatter/standards/data/brand-ports # one rule
|
|
344
|
+
adhere install darkmatter/standards#v3 # every rule, as tagged v3
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
The copies are the repo's own rules from then on: commit them, edit them, or
|
|
348
|
+
start new rules from them. Nothing tracks where they came from, so a later
|
|
349
|
+
install skips a file already there, as init does, and `--force` overwrites it,
|
|
350
|
+
edits and all. Only Markdown rule files are copied; the source's config, its
|
|
351
|
+
inline rules, and its cache are not. A config with inline `rules` replaces
|
|
352
|
+
the `.adhere/` rule files, so it replaces copied ones too.
|
|
353
|
+
|
|
354
|
+
The source is cloned with `git` from `https://github.com/org/repo.git`, so a
|
|
355
|
+
private repo needs git's credentials for GitHub, as `gh auth setup-git` sets
|
|
356
|
+
up. To clone over SSH instead, let git rewrite the URL:
|
|
357
|
+
`git config --global url.git@github.com:.insteadOf https://github.com/`.
|
|
358
|
+
|
|
310
359
|
### Validate
|
|
311
360
|
|
|
312
361
|
`adhere validate` loads the config, rule files, and presets the way `lint`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drkmttr/adhere",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Lints a repository against the rules in .adhere/, judged by Jev.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -68,10 +68,10 @@
|
|
|
68
68
|
}
|
|
69
69
|
},
|
|
70
70
|
"optionalDependencies": {
|
|
71
|
-
"@drkmttr/adhere-darwin-arm64": "0.
|
|
72
|
-
"@drkmttr/adhere-darwin-x64": "0.
|
|
73
|
-
"@drkmttr/adhere-linux-arm64": "0.
|
|
74
|
-
"@drkmttr/adhere-linux-x64": "0.
|
|
75
|
-
"@drkmttr/adhere-win32-x64": "0.
|
|
71
|
+
"@drkmttr/adhere-darwin-arm64": "0.12.0",
|
|
72
|
+
"@drkmttr/adhere-darwin-x64": "0.12.0",
|
|
73
|
+
"@drkmttr/adhere-linux-arm64": "0.12.0",
|
|
74
|
+
"@drkmttr/adhere-linux-x64": "0.12.0",
|
|
75
|
+
"@drkmttr/adhere-win32-x64": "0.12.0"
|
|
76
76
|
}
|
|
77
77
|
}
|
package/src/cli.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import type { Flags } from "#config.ts";
|
|
24
24
|
import { findContradictions, formatContradictions } from "#contradictions.ts";
|
|
25
25
|
import { type Install, initProject } from "#init.ts";
|
|
26
|
+
import { installRules, listRules, parseSource } from "#install.ts";
|
|
26
27
|
import { formatLinterCheck, tallyProjectRules } from "#mechanical.ts";
|
|
27
28
|
import { type PresetName, presetNames } from "#presets.ts";
|
|
28
29
|
import { globalRuleSet } from "#rules.ts";
|
|
@@ -326,7 +327,7 @@ export const validateCommand = Command.make("validate", { preset }, () =>
|
|
|
326
327
|
const installLine = (install: Install): string => {
|
|
327
328
|
switch (install.status) {
|
|
328
329
|
case "installed":
|
|
329
|
-
return `installed:
|
|
330
|
+
return `installed: ${install.what} with ${install.packageManager}`;
|
|
330
331
|
case "listed":
|
|
331
332
|
return "installed: none, @drkmttr/adhere is already in package.json";
|
|
332
333
|
case "no-package-json":
|
|
@@ -334,11 +335,25 @@ const installLine = (install: Install): string => {
|
|
|
334
335
|
}
|
|
335
336
|
};
|
|
336
337
|
|
|
338
|
+
const shared = Flag.string("shared").pipe(
|
|
339
|
+
Flag.filter(
|
|
340
|
+
(repo) => /^[\w.-]+\/[\w.-]+$/.test(repo),
|
|
341
|
+
(repo) => `--shared names a GitHub repo as org/repo, not ${repo}.`,
|
|
342
|
+
),
|
|
343
|
+
Flag.optional,
|
|
344
|
+
Flag.withDescription(
|
|
345
|
+
"Scaffold a repo of rules other repos copy with adhere install, named org/repo on GitHub: example rules, a README, a CI workflow that runs adhere validate, and an alchemy.run.ts that creates the repo and its TYPESAFE_API_KEY secret.",
|
|
346
|
+
),
|
|
347
|
+
);
|
|
348
|
+
|
|
337
349
|
/** `adhere init`: the scaffold. */
|
|
338
|
-
export const initCommand = Command.make("init", { force }, (input) =>
|
|
350
|
+
export const initCommand = Command.make("init", { force, shared }, (input) =>
|
|
339
351
|
Effect.gen(function* () {
|
|
340
352
|
const path = yield* Path.Path;
|
|
341
|
-
const result = yield* initProject(path.resolve(), {
|
|
353
|
+
const result = yield* initProject(path.resolve(), {
|
|
354
|
+
force: input.force,
|
|
355
|
+
...Option.match(input.shared, { onNone: () => ({}), onSome: (repo) => ({ shared: repo }) }),
|
|
356
|
+
});
|
|
342
357
|
const lines = [
|
|
343
358
|
result.created.length > 0 ? `created: ${result.created.join(", ")}` : "created: none",
|
|
344
359
|
result.skipped.length > 0 ? `skipped: ${result.skipped.join(", ")}` : "skipped: none",
|
|
@@ -352,6 +367,58 @@ export const initCommand = Command.make("init", { force }, (input) =>
|
|
|
352
367
|
),
|
|
353
368
|
);
|
|
354
369
|
|
|
370
|
+
const source = Argument.string("source").pipe(
|
|
371
|
+
Argument.filterMap(
|
|
372
|
+
(spec) => Option.fromNullishOr(parseSource(spec)),
|
|
373
|
+
(spec) => `org/repo, then optionally a topic or rule and #ref, not ${spec}`,
|
|
374
|
+
),
|
|
375
|
+
Argument.withDescription(
|
|
376
|
+
"A GitHub repo with rules in .adhere/, as org/repo, optionally followed by a topic or a rule, as in org/repo/data/brand-ports, and by #branch or #tag.",
|
|
377
|
+
),
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
/** `adhere list org/repo`: the rules another repo's `.adhere/` holds. */
|
|
381
|
+
export const listCommand = Command.make("list", { source }, (input) =>
|
|
382
|
+
Effect.gen(function* () {
|
|
383
|
+
const rules = yield* listRules(input.source);
|
|
384
|
+
const width = Math.max(...rules.map(({ id }) => id.length));
|
|
385
|
+
yield* Console.log(
|
|
386
|
+
rules.map(({ id, description }) => `${id.padEnd(width)} ${description}`).join("\n"),
|
|
387
|
+
);
|
|
388
|
+
}),
|
|
389
|
+
).pipe(
|
|
390
|
+
Command.withDescription(
|
|
391
|
+
"List the rules in a GitHub repo's .adhere/, each with its description, for adhere install. Clones with git, so a private repo needs git's credentials for it.",
|
|
392
|
+
),
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
/** `adhere install org/repo`: another repo's rules, copied into this one's `.adhere/`. */
|
|
396
|
+
export const installCommand = Command.make(
|
|
397
|
+
"install",
|
|
398
|
+
{
|
|
399
|
+
source,
|
|
400
|
+
force: Flag.boolean("force").pipe(
|
|
401
|
+
Flag.withDefault(false),
|
|
402
|
+
Flag.withDescription("Overwrite rule files already in .adhere/."),
|
|
403
|
+
),
|
|
404
|
+
},
|
|
405
|
+
(input) =>
|
|
406
|
+
Effect.gen(function* () {
|
|
407
|
+
const path = yield* Path.Path;
|
|
408
|
+
const result = yield* installRules(path.resolve(), input.source, { force: input.force });
|
|
409
|
+
yield* Console.log(
|
|
410
|
+
[
|
|
411
|
+
result.created.length > 0 ? `created: ${result.created.join(", ")}` : "created: none",
|
|
412
|
+
result.skipped.length > 0 ? `skipped: ${result.skipped.join(", ")}` : "skipped: none",
|
|
413
|
+
].join("\n"),
|
|
414
|
+
);
|
|
415
|
+
}),
|
|
416
|
+
).pipe(
|
|
417
|
+
Command.withDescription(
|
|
418
|
+
"Copy the rules in a GitHub repo's .adhere/, or one topic or rule of them, into this repo's .adhere/<org>/<repo>/. They are this repo's own rules from then on, to edit or keep in step by hand. Existing files are skipped unless --force.",
|
|
419
|
+
),
|
|
420
|
+
);
|
|
421
|
+
|
|
355
422
|
/**
|
|
356
423
|
* The key typed at a masked prompt, or piped in: `adhere login < key.txt`.
|
|
357
424
|
* Whitespace around it, like the newline a pipe ends with, is dropped.
|
|
@@ -439,6 +506,10 @@ export const cli = Command.make("adhere").pipe(
|
|
|
439
506
|
},
|
|
440
507
|
{ command: "adhere login", description: "Save your TypeSafe AI API key for later runs" },
|
|
441
508
|
{ command: "adhere lint", description: "Audit the working directory" },
|
|
509
|
+
{
|
|
510
|
+
command: "adhere install org/repo",
|
|
511
|
+
description: "Copy another repo's .adhere/ rules into this one",
|
|
512
|
+
},
|
|
442
513
|
{
|
|
443
514
|
command: "adhere lint --preset effect",
|
|
444
515
|
description: "Audit against the built-in Effect rules",
|
|
@@ -448,6 +519,8 @@ export const cli = Command.make("adhere").pipe(
|
|
|
448
519
|
lintCommand,
|
|
449
520
|
validateCommand,
|
|
450
521
|
initCommand,
|
|
522
|
+
listCommand,
|
|
523
|
+
installCommand,
|
|
451
524
|
loginCommand,
|
|
452
525
|
logoutCommand,
|
|
453
526
|
skillCommand,
|
package/src/init.ts
CHANGED
|
@@ -12,12 +12,22 @@ export interface InitResult {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export type Install =
|
|
15
|
-
| {
|
|
15
|
+
| {
|
|
16
|
+
readonly status: "installed";
|
|
17
|
+
/** @drkmttr/adhere, added, or in a shared repo, package.json, installed. */
|
|
18
|
+
readonly what: string;
|
|
19
|
+
readonly packageManager: PackageManager;
|
|
20
|
+
}
|
|
16
21
|
| { readonly status: "listed" }
|
|
17
22
|
| { readonly status: "no-package-json" };
|
|
18
23
|
|
|
19
24
|
export interface InitOptions {
|
|
20
25
|
readonly force?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* `org/repo`: scaffold a repo whose rules other repos copy with `adhere
|
|
28
|
+
* install`, rather than one that lints itself.
|
|
29
|
+
*/
|
|
30
|
+
readonly shared?: string;
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
// adhere-ignore-file rules/import-written-files -- src/index.ts exports initProject, so a Bun text import here breaks loading the package on Node and in Vitest (checked 2026-09-26), and the compiled executable has no files beside it to read.
|
|
@@ -100,18 +110,155 @@ export const handle = (id: string) =>
|
|
|
100
110
|
\`\`\`
|
|
101
111
|
`;
|
|
102
112
|
|
|
113
|
+
const sharedReadme = (org: string, repo: string) => `# ${repo}
|
|
114
|
+
|
|
115
|
+
Shared [adhere](https://github.com/darkmatter/adhere) rules for ${org}'s
|
|
116
|
+
repositories. Each rule is a Markdown file in \`.adhere/\`, and a repo that
|
|
117
|
+
copies it in has Jev judge its files against it when it runs \`adhere lint\`.
|
|
118
|
+
|
|
119
|
+
## Use these rules
|
|
120
|
+
|
|
121
|
+
List the rules, then copy them into a repo, all of them or one topic or rule:
|
|
122
|
+
|
|
123
|
+
\`\`\`sh
|
|
124
|
+
adhere list ${org}/${repo}
|
|
125
|
+
adhere install ${org}/${repo}
|
|
126
|
+
adhere install ${org}/${repo}/style
|
|
127
|
+
adhere install ${org}/${repo}/style/prefer-small-files
|
|
128
|
+
\`\`\`
|
|
129
|
+
|
|
130
|
+
The copies land in \`.adhere/${org}/${repo}/\` and are that repo's own from then
|
|
131
|
+
on, to edit or to start other rules from. To take a later version of a rule,
|
|
132
|
+
install it again with \`--force\`, which overwrites local edits.
|
|
133
|
+
|
|
134
|
+
## Add a rule
|
|
135
|
+
|
|
136
|
+
Add a Markdown file under \`.adhere/<topic>/\`; its path is its id. See
|
|
137
|
+
[Rules as Markdown files](https://github.com/darkmatter/adhere#rules-as-markdown-files)
|
|
138
|
+
and the [rule writing tips](https://github.com/darkmatter/adhere#rule-writing-tips).
|
|
139
|
+
\`adhere validate\` checks each rule's wording and asks Jev whether any two
|
|
140
|
+
contradict. CI runs it on every push to main and every pull request.
|
|
141
|
+
|
|
142
|
+
## Setup
|
|
143
|
+
|
|
144
|
+
\`alchemy.run.ts\` creates this repository on GitHub, or adopts it when it
|
|
145
|
+
exists, and sets the \`TYPESAFE_API_KEY\` secret CI's \`adhere validate\` needs.
|
|
146
|
+
Install, which init already did, deploy once, then push:
|
|
147
|
+
|
|
148
|
+
\`\`\`sh
|
|
149
|
+
npm install
|
|
150
|
+
TYPESAFE_API_KEY=... npx alchemy deploy
|
|
151
|
+
git init && git add -A && git commit -m "Add shared adhere rules"
|
|
152
|
+
git remote add origin git@github.com:${org}/${repo}.git && git push -u origin main
|
|
153
|
+
\`\`\`
|
|
154
|
+
|
|
155
|
+
Deploying again updates the secret, as after rotating the key.
|
|
156
|
+
`;
|
|
157
|
+
|
|
158
|
+
const SHARED_WORKFLOW = `name: adhere
|
|
159
|
+
|
|
160
|
+
on:
|
|
161
|
+
pull_request:
|
|
162
|
+
push:
|
|
163
|
+
branches: [main]
|
|
164
|
+
|
|
165
|
+
jobs:
|
|
166
|
+
validate:
|
|
167
|
+
runs-on: ubuntu-latest
|
|
168
|
+
steps:
|
|
169
|
+
- uses: actions/checkout@v4
|
|
170
|
+
- run: npx --yes @drkmttr/adhere validate
|
|
171
|
+
env:
|
|
172
|
+
TYPESAFE_API_KEY: \${{ secrets.TYPESAFE_API_KEY }}
|
|
173
|
+
`;
|
|
174
|
+
|
|
175
|
+
const sharedStack = (org: string, repo: string) => `import * as Alchemy from "alchemy";
|
|
176
|
+
import * as GitHub from "alchemy/GitHub";
|
|
177
|
+
import * as Output from "alchemy/Output";
|
|
178
|
+
import * as Config from "effect/Config";
|
|
179
|
+
import * as Effect from "effect/Effect";
|
|
180
|
+
|
|
181
|
+
const owner = "${org}";
|
|
182
|
+
|
|
183
|
+
export default Alchemy.Stack(
|
|
184
|
+
"${repo}",
|
|
185
|
+
{ providers: GitHub.providers(), state: Alchemy.localState() },
|
|
186
|
+
Effect.gen(function* () {
|
|
187
|
+
const repository = yield* GitHub.Repository("rules", {
|
|
188
|
+
owner,
|
|
189
|
+
name: "${repo}",
|
|
190
|
+
description: "Shared adhere rules",
|
|
191
|
+
visibility: "private",
|
|
192
|
+
});
|
|
193
|
+
// CI's adhere validate asks Jev whether any two rules contradict. Naming
|
|
194
|
+
// the repository by its output makes alchemy create it before the secret.
|
|
195
|
+
yield* GitHub.Secret("typesafe-api-key", {
|
|
196
|
+
owner,
|
|
197
|
+
repository: Output.map(repository.fullName, (fullName) => fullName.split("/")[1] ?? ""),
|
|
198
|
+
name: "TYPESAFE_API_KEY",
|
|
199
|
+
value: yield* Config.Redacted("TYPESAFE_API_KEY"),
|
|
200
|
+
});
|
|
201
|
+
return { url: repository.htmlUrl };
|
|
202
|
+
}).pipe(Effect.orDie),
|
|
203
|
+
);
|
|
204
|
+
`;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* alchemy's CLI runs on effect and its Node platform. They are pinned, as is
|
|
208
|
+
* the platform's own dependency, since a range of prereleases takes the newest:
|
|
209
|
+
* effect 4.0.0-rc.118 moved modules alchemy 2.0.0-beta.79 imports (checked
|
|
210
|
+
* 2026-09-28).
|
|
211
|
+
*/
|
|
212
|
+
const sharedPackage = (repo: string) =>
|
|
213
|
+
`${JSON.stringify(
|
|
214
|
+
{
|
|
215
|
+
name: repo,
|
|
216
|
+
private: true,
|
|
217
|
+
type: "module",
|
|
218
|
+
devDependencies: {
|
|
219
|
+
"@effect/platform-node": "4.0.0-rc.117",
|
|
220
|
+
alchemy: "2.0.0-beta.79",
|
|
221
|
+
effect: "4.0.0-rc.117",
|
|
222
|
+
},
|
|
223
|
+
overrides: { "@effect/platform-node-shared": "4.0.0-rc.117" },
|
|
224
|
+
},
|
|
225
|
+
null,
|
|
226
|
+
2,
|
|
227
|
+
)}\n`;
|
|
228
|
+
|
|
229
|
+
const SHARED_GITIGNORE = `node_modules/
|
|
230
|
+
.alchemy/
|
|
231
|
+
`;
|
|
232
|
+
|
|
103
233
|
const CONFIG_PATH = CONFIG_FILES[0];
|
|
104
234
|
|
|
105
235
|
/** The config's own project, which only a config at `.adhere/config.ts` needs. */
|
|
106
236
|
const CONFIG_PROJECT = ".adhere/tsconfig.json";
|
|
107
237
|
|
|
108
|
-
const
|
|
109
|
-
[CONFIG_PATH, CONFIG],
|
|
110
|
-
[CONFIG_PROJECT, TSCONFIG],
|
|
238
|
+
const EXAMPLES = [
|
|
111
239
|
[".adhere/style/prefer-small-files.md", SMALL_FILES],
|
|
112
240
|
[".adhere/style/name-domain-actions.md", NAME_EFFECTS],
|
|
113
241
|
] as const;
|
|
114
242
|
|
|
243
|
+
/**
|
|
244
|
+
* A shared repo has no config: \`adhere install\` copies only rule files, and
|
|
245
|
+
* \`validate\` reads \`.adhere/\` without one.
|
|
246
|
+
*/
|
|
247
|
+
const filesFor = (shared: string | undefined): ReadonlyArray<readonly [string, string]> => {
|
|
248
|
+
if (shared === undefined) {
|
|
249
|
+
return [[CONFIG_PATH, CONFIG], [CONFIG_PROJECT, TSCONFIG], ...EXAMPLES];
|
|
250
|
+
}
|
|
251
|
+
const [org = "", repo = ""] = shared.split("/");
|
|
252
|
+
return [
|
|
253
|
+
["README.md", sharedReadme(org, repo)],
|
|
254
|
+
[".github/workflows/adhere.yaml", SHARED_WORKFLOW],
|
|
255
|
+
["alchemy.run.ts", sharedStack(org, repo)],
|
|
256
|
+
["package.json", sharedPackage(repo)],
|
|
257
|
+
[".gitignore", SHARED_GITIGNORE],
|
|
258
|
+
...EXAMPLES,
|
|
259
|
+
];
|
|
260
|
+
};
|
|
261
|
+
|
|
115
262
|
const exists = (path: string): Promise<boolean> =>
|
|
116
263
|
access(path).then(
|
|
117
264
|
() => true,
|
|
@@ -177,19 +324,27 @@ const addFlags: Record<PackageManager, ReadonlyArray<string>> = {
|
|
|
177
324
|
npm: [],
|
|
178
325
|
};
|
|
179
326
|
|
|
180
|
-
/**
|
|
181
|
-
|
|
327
|
+
/**
|
|
328
|
+
* Adds adhere to devDependencies, so the config's `import type` resolves. A
|
|
329
|
+
* shared repo's package.json, which init writes, lists what it needs already,
|
|
330
|
+
* so there it installs that instead.
|
|
331
|
+
*/
|
|
332
|
+
const installPackages = (root: string, shared: boolean) =>
|
|
182
333
|
Effect.tryPromise({
|
|
183
334
|
try: async (): Promise<Install> => {
|
|
184
335
|
const manifest = await readFile(join(root, "package.json"), "utf8").catch(() => undefined);
|
|
185
336
|
if (manifest === undefined) return { status: "no-package-json" };
|
|
186
337
|
const { dependencies, devDependencies } = JSON.parse(manifest);
|
|
187
|
-
if (
|
|
338
|
+
if (
|
|
339
|
+
!shared &&
|
|
340
|
+
(dependencies?.[PACKAGE] !== undefined || devDependencies?.[PACKAGE] !== undefined)
|
|
341
|
+
) {
|
|
188
342
|
return { status: "listed" };
|
|
189
343
|
}
|
|
190
344
|
const packageManager = await packageManagerAt(root);
|
|
345
|
+
const args = shared ? ["install"] : ["add", "-D", PACKAGE, ...addFlags[packageManager]];
|
|
191
346
|
await new Promise<void>((resolve, reject) => {
|
|
192
|
-
spawn(packageManager,
|
|
347
|
+
spawn(packageManager, args, {
|
|
193
348
|
cwd: root,
|
|
194
349
|
stdio: "inherit",
|
|
195
350
|
})
|
|
@@ -198,11 +353,11 @@ const installAdhere = (root: string) =>
|
|
|
198
353
|
code === 0 ? resolve() : reject(new Error(`${packageManager} exited with ${code}`)),
|
|
199
354
|
);
|
|
200
355
|
});
|
|
201
|
-
return { status: "installed", packageManager };
|
|
356
|
+
return { status: "installed", what: shared ? "package.json" : PACKAGE, packageManager };
|
|
202
357
|
},
|
|
203
358
|
catch: (cause) =>
|
|
204
359
|
new Error(
|
|
205
|
-
`Could not
|
|
360
|
+
`Could not install ${shared ? "package.json" : PACKAGE}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
206
361
|
),
|
|
207
362
|
});
|
|
208
363
|
|
|
@@ -213,8 +368,8 @@ export const initProject = (
|
|
|
213
368
|
Effect.gen(function* () {
|
|
214
369
|
const created: Array<string> = [];
|
|
215
370
|
const skipped: Array<string> = [];
|
|
216
|
-
const existing = yield* otherConfig(root);
|
|
217
|
-
for (const [path, contents] of
|
|
371
|
+
const existing = options.shared === undefined ? yield* otherConfig(root) : undefined;
|
|
372
|
+
for (const [path, contents] of filesFor(options.shared)) {
|
|
218
373
|
if (path === CONFIG_PATH && existing !== undefined) {
|
|
219
374
|
skipped.push(existing);
|
|
220
375
|
continue;
|
|
@@ -228,6 +383,6 @@ export const initProject = (
|
|
|
228
383
|
skipped.push(relative(root, join(root, path)));
|
|
229
384
|
}
|
|
230
385
|
}
|
|
231
|
-
const install = yield*
|
|
386
|
+
const install = yield* installPackages(root, options.shared !== undefined);
|
|
232
387
|
return { created, skipped, install };
|
|
233
388
|
});
|
package/src/install.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { access, copyFile, mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join, sep } from "node:path";
|
|
5
|
+
import { ADHERE_DIRECTORY, type RuleId } from "#config.ts";
|
|
6
|
+
import { parseRuleMarkdown } from "#markdown.ts";
|
|
7
|
+
import { Effect } from "effect";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A GitHub repo whose `.adhere/` rules another repo copies: `org/repo`, then
|
|
11
|
+
* optionally a topic or a rule's id, then optionally `#` and a branch or tag,
|
|
12
|
+
* as in `darkmatter/standards/data/brand-ports#v3`.
|
|
13
|
+
*/
|
|
14
|
+
export interface Source {
|
|
15
|
+
readonly repo: string;
|
|
16
|
+
/** A rule id or a topic, `data/brand-ports` or `data`; empty for every rule. */
|
|
17
|
+
readonly rule: string;
|
|
18
|
+
readonly ref?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const parseSource = (spec: string): Source | undefined => {
|
|
22
|
+
const [path = "", ref] = spec.split("#");
|
|
23
|
+
const [org, repo, ...rule] = path.split("/").filter((segment) => segment.length > 0);
|
|
24
|
+
if (org === undefined || repo === undefined || ref === "") return undefined;
|
|
25
|
+
return { repo: `${org}/${repo}`, rule: rule.join("/"), ...(ref === undefined ? {} : { ref }) };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const failed = (what: string) => (cause: unknown) =>
|
|
29
|
+
new Error(`${what}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A shallow clone in a temporary directory, deleted when the scope closes.
|
|
33
|
+
* git, rather than GitHub's API, so a private repo is reached with whatever
|
|
34
|
+
* credentials git already has, and `url.<base>.insteadOf` can point
|
|
35
|
+
* github.com elsewhere, such as at SSH.
|
|
36
|
+
*/
|
|
37
|
+
const cloned = (source: Source) =>
|
|
38
|
+
Effect.acquireRelease(
|
|
39
|
+
Effect.tryPromise({
|
|
40
|
+
try: async () => {
|
|
41
|
+
const directory = await mkdtemp(join(tmpdir(), "adhere-install-"));
|
|
42
|
+
const branch = source.ref === undefined ? [] : ["--branch", source.ref];
|
|
43
|
+
const url = `https://github.com/${source.repo}.git`;
|
|
44
|
+
await new Promise<void>((resolve, reject) => {
|
|
45
|
+
let stderr = "";
|
|
46
|
+
spawn("git", ["clone", "--quiet", "--depth", "1", ...branch, url, directory], {
|
|
47
|
+
stdio: ["inherit", "ignore", "pipe"],
|
|
48
|
+
})
|
|
49
|
+
.on("error", reject)
|
|
50
|
+
.on("exit", (code) =>
|
|
51
|
+
code === 0
|
|
52
|
+
? resolve()
|
|
53
|
+
: reject(new Error(stderr.trim() || `git exited with ${code}`)),
|
|
54
|
+
)
|
|
55
|
+
.stderr?.on("data", (chunk) => {
|
|
56
|
+
stderr += chunk;
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
return directory;
|
|
60
|
+
},
|
|
61
|
+
catch: failed(`Could not clone ${source.repo}`),
|
|
62
|
+
}),
|
|
63
|
+
(directory) => Effect.promise(() => rm(directory, { recursive: true, force: true })),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
/** The repo's root `.adhere/` rule files by id, as lint reads them there: every `*.md` outside `cache/`. */
|
|
67
|
+
const rulesIn = (source: Source, clone: string) =>
|
|
68
|
+
Effect.tryPromise({
|
|
69
|
+
try: async () => {
|
|
70
|
+
const directory = join(clone, ADHERE_DIRECTORY);
|
|
71
|
+
const entries = await readdir(directory, { recursive: true }).catch(() => []);
|
|
72
|
+
const ids = entries
|
|
73
|
+
.filter((entry) => entry.endsWith(".md") && !entry.startsWith(`cache${sep}`))
|
|
74
|
+
.map((entry): RuleId => entry.slice(0, -".md".length).split(sep).join("/"))
|
|
75
|
+
.filter(
|
|
76
|
+
(id) => source.rule === "" || id === source.rule || id.startsWith(`${source.rule}/`),
|
|
77
|
+
)
|
|
78
|
+
.sort();
|
|
79
|
+
if (ids.length === 0) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
source.rule === ""
|
|
82
|
+
? `it has no rules in ${ADHERE_DIRECTORY}/`
|
|
83
|
+
: `it has no rule or topic ${source.rule} in ${ADHERE_DIRECTORY}/`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return ids.map((id) => ({ id, file: join(directory, `${id}.md`) }));
|
|
87
|
+
},
|
|
88
|
+
catch: failed(`Nothing to copy from ${source.repo}`),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/** Each rule a source names, with its description. */
|
|
92
|
+
export const listRules = (source: Source) =>
|
|
93
|
+
Effect.scoped(
|
|
94
|
+
Effect.gen(function* () {
|
|
95
|
+
const rules = yield* rulesIn(source, yield* cloned(source));
|
|
96
|
+
return yield* Effect.forEach(rules, ({ id, file }) =>
|
|
97
|
+
Effect.gen(function* () {
|
|
98
|
+
const text = yield* Effect.tryPromise({
|
|
99
|
+
try: () => readFile(file, "utf8"),
|
|
100
|
+
catch: failed(`Could not read ${id}`),
|
|
101
|
+
});
|
|
102
|
+
const rule = yield* parseRuleMarkdown(text, `${source.repo}/${id}.md`);
|
|
103
|
+
return { id, description: rule.description };
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
export interface InstallResult {
|
|
110
|
+
readonly created: ReadonlyArray<string>;
|
|
111
|
+
readonly skipped: ReadonlyArray<string>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Copies the rules a source names into `root`'s `.adhere/` under the source's
|
|
116
|
+
* org and repo, so `data/ports` from acme/rules is the rule
|
|
117
|
+
* `acme/rules/data/ports`, and two sources' rules never collide. They are the
|
|
118
|
+
* project's own rules from then on. As with init, a file already there is
|
|
119
|
+
* skipped unless `force`.
|
|
120
|
+
*/
|
|
121
|
+
export const installRules = (root: string, source: Source, options: { readonly force?: boolean }) =>
|
|
122
|
+
Effect.scoped(
|
|
123
|
+
Effect.gen(function* () {
|
|
124
|
+
const rules = yield* rulesIn(source, yield* cloned(source));
|
|
125
|
+
const created: Array<string> = [];
|
|
126
|
+
const skipped: Array<string> = [];
|
|
127
|
+
for (const { id, file } of rules) {
|
|
128
|
+
const path = `${ADHERE_DIRECTORY}/${source.repo}/${id}.md`;
|
|
129
|
+
const target = join(root, path);
|
|
130
|
+
const copied = yield* Effect.tryPromise({
|
|
131
|
+
try: async () => {
|
|
132
|
+
const exists = await access(target).then(
|
|
133
|
+
() => true,
|
|
134
|
+
() => false,
|
|
135
|
+
);
|
|
136
|
+
if (exists && options.force !== true) return false;
|
|
137
|
+
await mkdir(dirname(target), { recursive: true });
|
|
138
|
+
await copyFile(file, target);
|
|
139
|
+
return true;
|
|
140
|
+
},
|
|
141
|
+
catch: failed(`Could not write ${path}`),
|
|
142
|
+
});
|
|
143
|
+
(copied ? created : skipped).push(path);
|
|
144
|
+
}
|
|
145
|
+
return { created, skipped } satisfies InstallResult;
|
|
146
|
+
}),
|
|
147
|
+
);
|