@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 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.11.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.11.0",
72
- "@drkmttr/adhere-darwin-x64": "0.11.0",
73
- "@drkmttr/adhere-linux-arm64": "0.11.0",
74
- "@drkmttr/adhere-linux-x64": "0.11.0",
75
- "@drkmttr/adhere-win32-x64": "0.11.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: @drkmttr/adhere with ${install.packageManager}`;
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(), { force: input.force });
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
- | { readonly status: "installed"; readonly packageManager: PackageManager }
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 files = [
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
- /** Adds adhere to devDependencies, so the config's `import type` resolves. */
181
- const installAdhere = (root: string) =>
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 (dependencies?.[PACKAGE] !== undefined || devDependencies?.[PACKAGE] !== undefined) {
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, ["add", "-D", PACKAGE, ...addFlags[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 add ${PACKAGE} to package.json: ${cause instanceof Error ? cause.message : String(cause)}`,
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 files) {
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* installAdhere(root);
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
+ );