@drkmttr/adhere 0.11.0 → 0.13.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.
Files changed (102) hide show
  1. package/README.md +184 -37
  2. package/package.json +9 -9
  3. package/presets/effect/basics/{instrument-with-pipe.md → instrument-with-pipe/RULE.md} +1 -1
  4. package/presets/effect/config/{business-logic-depends-on-config-service.md → business-logic-depends-on-config-service/RULE.md} +3 -3
  5. package/presets/effect/config/{secrets-are-redacted.md → secrets-are-redacted/RULE.md} +3 -3
  6. package/presets/effect/config/{validate-with-schema.md → validate-with-schema/RULE.md} +4 -4
  7. package/skills/adhere/SKILL.md +206 -118
  8. package/skills/adhere-fix/SKILL.md +9 -8
  9. package/skills/adhere-setup/SKILL.md +244 -0
  10. package/src/cli.ts +114 -25
  11. package/src/config.ts +50 -3
  12. package/src/index.ts +2 -0
  13. package/src/init.ts +193 -27
  14. package/src/install.ts +158 -0
  15. package/src/main.ts +1 -1
  16. package/src/rules.ts +135 -56
  17. package/src/services/AdhereConfig.ts +2 -2
  18. package/src/services/Credentials.ts +4 -4
  19. package/src/services/Jev.http.ts +76 -10
  20. package/src/services/Jev.ts +39 -10
  21. package/src/workflows/audit.ts +23 -12
  22. package/src/workflows/format.ts +172 -35
  23. /package/presets/alchemy/apis/{match-remote-errors-by-tag.md → match-remote-errors-by-tag/RULE.md} +0 -0
  24. /package/presets/alchemy/apis/{schemas-at-trust-boundaries.md → schemas-at-trust-boundaries/RULE.md} +0 -0
  25. /package/presets/alchemy/data/{commit-migrations-then-deploy.md → commit-migrations-then-deploy/RULE.md} +0 -0
  26. /package/presets/alchemy/data/{hyperdrive-direct-origin.md → hyperdrive-direct-origin/RULE.md} +0 -0
  27. /package/presets/alchemy/data/{migrations-use-direct-url.md → migrations-use-direct-url/RULE.md} +0 -0
  28. /package/presets/alchemy/data/{never-disable-tls-verification.md → never-disable-tls-verification/RULE.md} +0 -0
  29. /package/presets/alchemy/data/{private-endpoint-at-runtime.md → private-endpoint-at-runtime/RULE.md} +0 -0
  30. /package/presets/alchemy/data/{single-migration-owner.md → single-migration-owner/RULE.md} +0 -0
  31. /package/presets/alchemy/durable/{idempotent-event-handlers.md → idempotent-event-handlers/RULE.md} +0 -0
  32. /package/presets/alchemy/durable/{io-inside-steps.md → io-inside-steps/RULE.md} +0 -0
  33. /package/presets/alchemy/durable/{no-self-triggering-bucket-writes.md → no-self-triggering-bucket-writes/RULE.md} +0 -0
  34. /package/presets/alchemy/durable/{stable-step-names.md → stable-step-names/RULE.md} +0 -0
  35. /package/presets/alchemy/durable/{webhook-state-is-truth.md → webhook-state-is-truth/RULE.md} +0 -0
  36. /package/presets/alchemy/providers/{diff-guards-unresolved-props.md → diff-guards-unresolved-props/RULE.md} +0 -0
  37. /package/presets/alchemy/providers/{idempotent-delete.md → idempotent-delete/RULE.md} +0 -0
  38. /package/presets/alchemy/providers/{lazy-layer-construction.md → lazy-layer-construction/RULE.md} +0 -0
  39. /package/presets/alchemy/providers/{reconcile-from-observed-state.md → reconcile-from-observed-state/RULE.md} +0 -0
  40. /package/presets/alchemy/resources/{anchor-paths-with-import-meta.md → anchor-paths-with-import-meta/RULE.md} +0 -0
  41. /package/presets/alchemy/resources/{idempotent-actions-and-seeds.md → idempotent-actions-and-seeds/RULE.md} +0 -0
  42. /package/presets/alchemy/resources/{previews-reference-shared-resources.md → previews-reference-shared-resources/RULE.md} +0 -0
  43. /package/presets/alchemy/resources/{protect-irreplaceable-data.md → protect-irreplaceable-data/RULE.md} +0 -0
  44. /package/presets/alchemy/resources/{remote-state-for-ci.md → remote-state-for-ci/RULE.md} +0 -0
  45. /package/presets/alchemy/resources/{stable-logical-ids.md → stable-logical-ids/RULE.md} +0 -0
  46. /package/presets/alchemy/resources/{stage-unique-physical-names.md → stage-unique-physical-names/RULE.md} +0 -0
  47. /package/presets/alchemy/runtime/{bind-tag-not-implementation.md → bind-tag-not-implementation/RULE.md} +0 -0
  48. /package/presets/alchemy/runtime/{deterministic-config-defaults.md → deterministic-config-defaults/RULE.md} +0 -0
  49. /package/presets/alchemy/runtime/{disposables-in-request-scope.md → disposables-in-request-scope/RULE.md} +0 -0
  50. /package/presets/alchemy/runtime/{lambda-no-post-response-work.md → lambda-no-post-response-work/RULE.md} +0 -0
  51. /package/presets/alchemy/runtime/{no-per-request-work-at-construction.md → no-per-request-work-at-construction/RULE.md} +0 -0
  52. /package/presets/alchemy/runtime/{resolve-config-and-bindings-at-construction.md → resolve-config-and-bindings-at-construction/RULE.md} +0 -0
  53. /package/presets/alchemy/secrets/{let-alchemy-mint-infra-secrets.md → let-alchemy-mint-infra-secrets/RULE.md} +0 -0
  54. /package/presets/alchemy/secrets/{none-in-browser-env.md → none-in-browser-env/RULE.md} +0 -0
  55. /package/presets/alchemy/secrets/{redacted-until-use.md → redacted-until-use/RULE.md} +0 -0
  56. /package/presets/alchemy/secrets/{unwrap-before-compare-or-send.md → unwrap-before-compare-or-send/RULE.md} +0 -0
  57. /package/presets/alchemy/security/{authorize-public-endpoints.md → authorize-public-endpoints/RULE.md} +0 -0
  58. /package/presets/alchemy/security/{github-secret-not-variable.md → github-secret-not-variable/RULE.md} +0 -0
  59. /package/presets/alchemy/security/{identity-from-verified-session.md → identity-from-verified-session/RULE.md} +0 -0
  60. /package/presets/alchemy/security/{keep-auth-protections-on.md → keep-auth-protections-on/RULE.md} +0 -0
  61. /package/presets/alchemy/security/{least-privilege-bindings.md → least-privilege-bindings/RULE.md} +0 -0
  62. /package/presets/alchemy/security/{least-privilege-ci-credentials.md → least-privilege-ci-credentials/RULE.md} +0 -0
  63. /package/presets/alchemy/security/{lookup-failure-is-not-anonymous.md → lookup-failure-is-not-anonymous/RULE.md} +0 -0
  64. /package/presets/effect/basics/{gen-for-sequencing.md → gen-for-sequencing/RULE.md} +0 -0
  65. /package/presets/effect/config/{tests-provide-values-directly.md → tests-provide-values-directly/RULE.md} +0 -0
  66. /package/presets/effect/data/{brand-meaningful-primitives.md → brand-meaningful-primitives/RULE.md} +0 -0
  67. /package/presets/effect/data/{variants-are-tagged-unions.md → variants-are-tagged-unions/RULE.md} +0 -0
  68. /package/presets/effect/errors/{catch-defects-at-boundaries-only.md → catch-defects-at-boundaries-only/RULE.md} +0 -0
  69. /package/presets/effect/errors/{defects-for-bugs.md → defects-for-bugs/RULE.md} +0 -0
  70. /package/presets/effect/errors/{wrap-external-errors.md → wrap-external-errors/RULE.md} +0 -0
  71. /package/presets/effect/services/{dependencies-through-layers.md → dependencies-through-layers/RULE.md} +0 -0
  72. /package/presets/effect/services/{fresh-layer-per-test.md → fresh-layer-per-test/RULE.md} +0 -0
  73. /package/presets/effect/services/{memoize-parameterized-layers.md → memoize-parameterized-layers/RULE.md} +0 -0
  74. /package/presets/effect/services/{no-mutable-state.md → no-mutable-state/RULE.md} +0 -0
  75. /package/presets/effect/services/{operations-have-no-requirements.md → operations-have-no-requirements/RULE.md} +0 -0
  76. /package/presets/effect/services/{test-layers-are-in-memory.md → test-layers-are-in-memory/RULE.md} +0 -0
  77. /package/presets/effect/testing/{test-clock-for-time.md → test-clock-for-time/RULE.md} +0 -0
  78. /package/presets/react/effects/{clean-up-what-they-start.md → clean-up-what-they-start/RULE.md} +0 -0
  79. /package/presets/react/effects/{event-logic-in-handlers.md → event-logic-in-handlers/RULE.md} +0 -0
  80. /package/presets/react/effects/{external-stores-use-sync-external-store.md → external-stores-use-sync-external-store/RULE.md} +0 -0
  81. /package/presets/react/effects/{fetches-ignore-stale-responses.md → fetches-ignore-stale-responses/RULE.md} +0 -0
  82. /package/presets/react/effects/{notify-parents-in-handlers.md → notify-parents-in-handlers/RULE.md} +0 -0
  83. /package/presets/react/server/{no-private-data-to-client.md → no-private-data-to-client/RULE.md} +0 -0
  84. /package/presets/react/server/{server-functions-authorize-and-validate.md → server-functions-authorize-and-validate/RULE.md} +0 -0
  85. /package/presets/react/state/{no-contradictory-state.md → no-contradictory-state/RULE.md} +0 -0
  86. /package/presets/react/state/{no-props-copied-into-state.md → no-props-copied-into-state/RULE.md} +0 -0
  87. /package/presets/react/state/{store-ids-not-copies.md → store-ids-not-copies/RULE.md} +0 -0
  88. /package/presets/security/{constant-time-secret-compare.md → constant-time-secret-compare/RULE.md} +0 -0
  89. /package/presets/security/{no-secrets-in-output.md → no-secrets-in-output/RULE.md} +0 -0
  90. /package/presets/security/{no-untrusted-input-in-commands-or-paths.md → no-untrusted-input-in-commands-or-paths/RULE.md} +0 -0
  91. /package/presets/security/{parameterized-queries.md → parameterized-queries/RULE.md} +0 -0
  92. /package/presets/typescript/async/{concurrent-independent-work.md → concurrent-independent-work/RULE.md} +0 -0
  93. /package/presets/typescript/async/{network-calls-have-timeouts.md → network-calls-have-timeouts/RULE.md} +0 -0
  94. /package/presets/typescript/design/{no-argument-mutation.md → no-argument-mutation/RULE.md} +0 -0
  95. /package/presets/typescript/errors/{no-swallowed-errors.md → no-swallowed-errors/RULE.md} +0 -0
  96. /package/presets/typescript/errors/{release-on-every-path.md → release-on-every-path/RULE.md} +0 -0
  97. /package/presets/typescript/testing/{assert-outcomes.md → assert-outcomes/RULE.md} +0 -0
  98. /package/presets/typescript/testing/{independent-tests.md → independent-tests/RULE.md} +0 -0
  99. /package/presets/typescript/types/{assertions-only-when-proven.md → assertions-only-when-proven/RULE.md} +0 -0
  100. /package/presets/typescript/types/{derive-dont-restate.md → derive-dont-restate/RULE.md} +0 -0
  101. /package/presets/typescript/types/{unrepresentable-invalid-states.md → unrepresentable-invalid-states/RULE.md} +0 -0
  102. /package/presets/typescript/types/{validate-external-data.md → validate-external-data/RULE.md} +0 -0
package/src/init.ts CHANGED
@@ -12,15 +12,29 @@ 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;
31
+ /** Who can see the shared repo alchemy creates. Defaults to public. */
32
+ readonly visibility?: Visibility;
21
33
  }
22
34
 
23
- // 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.
35
+ export type Visibility = "public" | "private";
36
+
37
+ // adhere-ignore-file 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.
24
38
  const CONFIG = `import { defineConfig } from "@drkmttr/adhere";
25
39
 
26
40
  export default defineConfig({
@@ -29,10 +43,11 @@ export default defineConfig({
29
43
  `;
30
44
 
31
45
  /**
32
- * A project for the config alone. A tsconfig's globs skip dot directories, so
33
- * without it the editor opens `.adhere/config.ts` outside any project, where
34
- * it cannot resolve `@drkmttr/adhere`, whose types only `bundler`, `node16`,
35
- * and `nodenext` resolution reach.
46
+ * A project for the config and any rules written in TypeScript. A tsconfig's
47
+ * globs skip dot directories, so without it the editor opens
48
+ * `.adhere/config.ts` outside any project, where it cannot resolve
49
+ * `@drkmttr/adhere`, whose types only `bundler`, `node16`, and `nodenext`
50
+ * resolution reach.
36
51
  */
37
52
  const TSCONFIG = `{
38
53
  "compilerOptions": {
@@ -44,7 +59,7 @@ const TSCONFIG = `{
44
59
  "skipLibCheck": true,
45
60
  "allowImportingTsExtensions": true
46
61
  },
47
- "include": ["config.ts"]
62
+ "include": ["**/*.ts"]
48
63
  }
49
64
  `;
50
65
 
@@ -100,18 +115,164 @@ export const handle = (id: string) =>
100
115
  \`\`\`
101
116
  `;
102
117
 
118
+ const sharedReadme = (org: string, repo: string) => `# ${repo}
119
+
120
+ Shared [adhere](https://github.com/darkmatter/adhere) rules for ${org}'s
121
+ repositories. Each rule is a directory in \`.adhere/rules/\` that holds a
122
+ \`RULE.md\`, or a \`RULE.ts\`, and a repo that copies it in has Jev judge its
123
+ files against it when it runs \`adhere lint\`.
124
+
125
+ ## Use these rules
126
+
127
+ List the rules, then copy them into a repo, all of them or one topic or rule:
128
+
129
+ \`\`\`sh
130
+ adhere list ${org}/${repo}
131
+ adhere install ${org}/${repo}
132
+ adhere install ${org}/${repo}/style
133
+ adhere install ${org}/${repo}/style/prefer-small-files
134
+ \`\`\`
135
+
136
+ The copies land in \`.adhere/rules/${org}/${repo}/\` and are that repo's own from then
137
+ on, to edit or to start other rules from. To take a later version of a rule,
138
+ install it again with \`--force\`, which overwrites local edits.
139
+
140
+ ## Add a rule
141
+
142
+ Add a directory under \`.adhere/rules/<topic>/\` with the rule in a \`RULE.md\`;
143
+ its path is the rule's id. See
144
+ [Rules as Markdown files](https://github.com/darkmatter/adhere#rules-as-markdown-files)
145
+ and the [rule writing tips](https://github.com/darkmatter/adhere#rule-writing-tips).
146
+ \`adhere validate\` checks each rule's wording and asks Jev whether any two
147
+ contradict. CI runs it on every push to main and every pull request.
148
+
149
+ ## Setup
150
+
151
+ \`alchemy.run.ts\` creates this repository on GitHub, or adopts it when it
152
+ exists, and sets the \`TYPESAFE_API_KEY\` secret CI's \`adhere validate\` needs.
153
+ Install, which init already did, deploy once, then push:
154
+
155
+ \`\`\`sh
156
+ npm install
157
+ TYPESAFE_API_KEY=... npx alchemy deploy
158
+ git init && git add -A && git commit -m "Add shared adhere rules"
159
+ git remote add origin git@github.com:${org}/${repo}.git && git push -u origin main
160
+ \`\`\`
161
+
162
+ Deploying again updates the secret, as after rotating the key.
163
+ `;
164
+
165
+ const SHARED_WORKFLOW = `name: adhere
166
+
167
+ on:
168
+ pull_request:
169
+ push:
170
+ branches: [main]
171
+
172
+ jobs:
173
+ validate:
174
+ runs-on: ubuntu-latest
175
+ steps:
176
+ - uses: actions/checkout@v4
177
+ - run: npx --yes @drkmttr/adhere validate
178
+ env:
179
+ TYPESAFE_API_KEY: \${{ secrets.TYPESAFE_API_KEY }}
180
+ `;
181
+
182
+ const sharedStack = (
183
+ org: string,
184
+ repo: string,
185
+ visibility: Visibility,
186
+ ) => `import * as Alchemy from "alchemy";
187
+ import * as GitHub from "alchemy/GitHub";
188
+ import * as Output from "alchemy/Output";
189
+ import * as Config from "effect/Config";
190
+ import * as Effect from "effect/Effect";
191
+
192
+ const owner = "${org}";
193
+
194
+ export default Alchemy.Stack(
195
+ "${repo}",
196
+ { providers: GitHub.providers(), state: Alchemy.localState() },
197
+ Effect.gen(function* () {
198
+ const repository = yield* GitHub.Repository("rules", {
199
+ owner,
200
+ name: "${repo}",
201
+ description: "Shared adhere rules",
202
+ visibility: "${visibility}",
203
+ });
204
+ // CI's adhere validate asks Jev whether any two rules contradict. Naming
205
+ // the repository by its output makes alchemy create it before the secret.
206
+ yield* GitHub.Secret("typesafe-api-key", {
207
+ owner,
208
+ repository: Output.map(repository.fullName, (fullName) => fullName.split("/")[1] ?? ""),
209
+ name: "TYPESAFE_API_KEY",
210
+ value: yield* Config.Redacted("TYPESAFE_API_KEY"),
211
+ });
212
+ return { url: repository.htmlUrl };
213
+ }).pipe(Effect.orDie),
214
+ );
215
+ `;
216
+
217
+ /**
218
+ * alchemy's CLI runs on effect and its Node platform. They are pinned, as is
219
+ * the platform's own dependency, since a range of prereleases takes the newest:
220
+ * effect 4.0.0-rc.118 moved modules alchemy 2.0.0-beta.79 imports (checked
221
+ * 2026-09-28).
222
+ */
223
+ const sharedPackage = (repo: string) =>
224
+ `${JSON.stringify(
225
+ {
226
+ name: repo,
227
+ private: true,
228
+ type: "module",
229
+ devDependencies: {
230
+ "@effect/platform-node": "4.0.0-rc.117",
231
+ alchemy: "2.0.0-beta.79",
232
+ effect: "4.0.0-rc.117",
233
+ },
234
+ overrides: { "@effect/platform-node-shared": "4.0.0-rc.117" },
235
+ },
236
+ null,
237
+ 2,
238
+ )}\n`;
239
+
240
+ const SHARED_GITIGNORE = `node_modules/
241
+ .alchemy/
242
+ `;
243
+
103
244
  const CONFIG_PATH = CONFIG_FILES[0];
104
245
 
105
- /** The config's own project, which only a config at `.adhere/config.ts` needs. */
246
+ /** The project of the config at `.adhere/config.ts`, and of the rules written in TypeScript beside it. */
106
247
  const CONFIG_PROJECT = ".adhere/tsconfig.json";
107
248
 
108
- const files = [
109
- [CONFIG_PATH, CONFIG],
110
- [CONFIG_PROJECT, TSCONFIG],
111
- [".adhere/style/prefer-small-files.md", SMALL_FILES],
112
- [".adhere/style/name-domain-actions.md", NAME_EFFECTS],
249
+ const EXAMPLES = [
250
+ [".adhere/rules/style/prefer-small-files/RULE.md", SMALL_FILES],
251
+ [".adhere/rules/style/name-domain-actions/RULE.md", NAME_EFFECTS],
113
252
  ] as const;
114
253
 
254
+ /**
255
+ * A shared repo has no config: \`adhere install\` copies only rules, and
256
+ * \`validate\` reads \`.adhere/rules/\` without one.
257
+ */
258
+ const filesFor = (
259
+ shared: string | undefined,
260
+ visibility: Visibility,
261
+ ): ReadonlyArray<readonly [string, string]> => {
262
+ if (shared === undefined) {
263
+ return [[CONFIG_PATH, CONFIG], [CONFIG_PROJECT, TSCONFIG], ...EXAMPLES];
264
+ }
265
+ const [org = "", repo = ""] = shared.split("/");
266
+ return [
267
+ ["README.md", sharedReadme(org, repo)],
268
+ [".github/workflows/adhere.yaml", SHARED_WORKFLOW],
269
+ ["alchemy.run.ts", sharedStack(org, repo, visibility)],
270
+ ["package.json", sharedPackage(repo)],
271
+ [".gitignore", SHARED_GITIGNORE],
272
+ ...EXAMPLES,
273
+ ];
274
+ };
275
+
115
276
  const exists = (path: string): Promise<boolean> =>
116
277
  access(path).then(
117
278
  () => true,
@@ -165,31 +326,36 @@ const packageManagerAt = async (root: string): Promise<PackageManager> => {
165
326
 
166
327
  /**
167
328
  * pnpm refuses to add to a workspace root without a flag. `-w` fails outside a
168
- * workspace, so the first one, which works in both, stands in for it. pnpm 11
169
- * also fails on msgpackr-extract's unapproved build script. It comes with
170
- * effect, and adhere never calls msgpackr, so the second flag lets pnpm skip
171
- * the build with a warning.
329
+ * workspace, so this one, which works in both, stands in for it.
172
330
  */
173
331
  const addFlags: Record<PackageManager, ReadonlyArray<string>> = {
174
332
  bun: [],
175
- pnpm: ["--ignore-workspace-root-check", "--config.strict-dep-builds=false"],
333
+ pnpm: ["--ignore-workspace-root-check"],
176
334
  yarn: [],
177
335
  npm: [],
178
336
  };
179
337
 
180
- /** Adds adhere to devDependencies, so the config's `import type` resolves. */
181
- const installAdhere = (root: string) =>
338
+ /**
339
+ * Adds adhere to devDependencies, so the config's `import type` resolves. A
340
+ * shared repo's package.json, which init writes, lists what it needs already,
341
+ * so there it installs that instead.
342
+ */
343
+ const installPackages = (root: string, shared: boolean) =>
182
344
  Effect.tryPromise({
183
345
  try: async (): Promise<Install> => {
184
346
  const manifest = await readFile(join(root, "package.json"), "utf8").catch(() => undefined);
185
347
  if (manifest === undefined) return { status: "no-package-json" };
186
348
  const { dependencies, devDependencies } = JSON.parse(manifest);
187
- if (dependencies?.[PACKAGE] !== undefined || devDependencies?.[PACKAGE] !== undefined) {
349
+ if (
350
+ !shared &&
351
+ (dependencies?.[PACKAGE] !== undefined || devDependencies?.[PACKAGE] !== undefined)
352
+ ) {
188
353
  return { status: "listed" };
189
354
  }
190
355
  const packageManager = await packageManagerAt(root);
356
+ const args = shared ? ["install"] : ["add", "-D", PACKAGE, ...addFlags[packageManager]];
191
357
  await new Promise<void>((resolve, reject) => {
192
- spawn(packageManager, ["add", "-D", PACKAGE, ...addFlags[packageManager]], {
358
+ spawn(packageManager, args, {
193
359
  cwd: root,
194
360
  stdio: "inherit",
195
361
  })
@@ -198,11 +364,11 @@ const installAdhere = (root: string) =>
198
364
  code === 0 ? resolve() : reject(new Error(`${packageManager} exited with ${code}`)),
199
365
  );
200
366
  });
201
- return { status: "installed", packageManager };
367
+ return { status: "installed", what: shared ? "package.json" : PACKAGE, packageManager };
202
368
  },
203
369
  catch: (cause) =>
204
370
  new Error(
205
- `Could not add ${PACKAGE} to package.json: ${cause instanceof Error ? cause.message : String(cause)}`,
371
+ `Could not install ${shared ? "package.json" : PACKAGE}: ${cause instanceof Error ? cause.message : String(cause)}`,
206
372
  ),
207
373
  });
208
374
 
@@ -213,8 +379,8 @@ export const initProject = (
213
379
  Effect.gen(function* () {
214
380
  const created: Array<string> = [];
215
381
  const skipped: Array<string> = [];
216
- const existing = yield* otherConfig(root);
217
- for (const [path, contents] of files) {
382
+ const existing = options.shared === undefined ? yield* otherConfig(root) : undefined;
383
+ for (const [path, contents] of filesFor(options.shared, options.visibility ?? "public")) {
218
384
  if (path === CONFIG_PATH && existing !== undefined) {
219
385
  skipped.push(existing);
220
386
  continue;
@@ -228,6 +394,6 @@ export const initProject = (
228
394
  skipped.push(relative(root, join(root, path)));
229
395
  }
230
396
  }
231
- const install = yield* installAdhere(root);
397
+ const install = yield* installPackages(root, options.shared !== undefined);
232
398
  return { created, skipped, install };
233
399
  });
package/src/install.ts ADDED
@@ -0,0 +1,158 @@
1
+ import { spawn } from "node:child_process";
2
+ import { access, cp, 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 { RULES_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
+ /**
67
+ * The repo's rules in its root `.adhere/rules/`, as lint reads them there:
68
+ * each directory that holds a RULE.md or a RULE.ts, by its path, with that file.
69
+ */
70
+ const rulesIn = (source: Source, clone: string) =>
71
+ Effect.tryPromise({
72
+ try: async () => {
73
+ const directory = join(clone, RULES_DIRECTORY);
74
+ const entries = await readdir(directory, { recursive: true }).catch(() => []);
75
+ const rules = entries
76
+ .flatMap((entry) => {
77
+ const [, id, name] = /^(.+)\/(RULE\.(?:md|ts))$/.exec(entry.split(sep).join("/")) ?? [];
78
+ return id === undefined || name === undefined ? [] : [{ id: id as RuleId, name }];
79
+ })
80
+ .filter(
81
+ ({ id }) => source.rule === "" || id === source.rule || id.startsWith(`${source.rule}/`),
82
+ )
83
+ .sort((a, b) => a.id.localeCompare(b.id));
84
+ if (rules.length === 0) {
85
+ throw new Error(
86
+ source.rule === ""
87
+ ? `it has no rules in ${RULES_DIRECTORY}/`
88
+ : `it has no rule or topic ${source.rule} in ${RULES_DIRECTORY}/`,
89
+ );
90
+ }
91
+ return rules.map(({ id, name }) => ({ id, name, directory: join(directory, id) }));
92
+ },
93
+ catch: failed(`Nothing to copy from ${source.repo}`),
94
+ });
95
+
96
+ /**
97
+ * Each rule a source names, with its description. A rule written in
98
+ * TypeScript is listed without one: reading it would run the repo's code.
99
+ */
100
+ export const listRules = (source: Source) =>
101
+ Effect.scoped(
102
+ Effect.gen(function* () {
103
+ const rules = yield* rulesIn(source, yield* cloned(source));
104
+ return yield* Effect.forEach(rules, ({ id, name, directory }) =>
105
+ Effect.gen(function* () {
106
+ if (name === "RULE.ts") return { id, description: "(TypeScript, which list does not run)" };
107
+ const text = yield* Effect.tryPromise({
108
+ try: () => readFile(join(directory, name), "utf8"),
109
+ catch: failed(`Could not read ${id}`),
110
+ });
111
+ const rule = yield* parseRuleMarkdown(text, `${source.repo}/${id}/${name}`);
112
+ return { id, description: rule.description };
113
+ }),
114
+ );
115
+ }),
116
+ );
117
+
118
+ export interface InstallResult {
119
+ readonly created: ReadonlyArray<string>;
120
+ readonly skipped: ReadonlyArray<string>;
121
+ }
122
+
123
+ /**
124
+ * Copies the rules a source names, each its whole directory, helpers and all,
125
+ * into `root`'s `.adhere/rules/` under the source's org and repo, so
126
+ * `data/ports` from acme/rules is the rule `acme/rules/data/ports`, and two
127
+ * sources' rules never collide. They are the project's own rules from then
128
+ * on, and a copied RULE.ts runs whenever lint does. As with init, a rule
129
+ * already there is skipped, unless `force` replaces its directory.
130
+ */
131
+ export const installRules = (root: string, source: Source, options: { readonly force?: boolean }) =>
132
+ Effect.scoped(
133
+ Effect.gen(function* () {
134
+ const rules = yield* rulesIn(source, yield* cloned(source));
135
+ const created: Array<string> = [];
136
+ const skipped: Array<string> = [];
137
+ for (const { id, directory } of rules) {
138
+ const path = `${RULES_DIRECTORY}/${source.repo}/${id}/`;
139
+ const target = join(root, path);
140
+ const copied = yield* Effect.tryPromise({
141
+ try: async () => {
142
+ const exists = await access(target).then(
143
+ () => true,
144
+ () => false,
145
+ );
146
+ if (exists && options.force !== true) return false;
147
+ await rm(target, { recursive: true, force: true });
148
+ await mkdir(dirname(target), { recursive: true });
149
+ await cp(directory, target, { recursive: true });
150
+ return true;
151
+ },
152
+ catch: failed(`Could not write ${path}`),
153
+ });
154
+ (copied ? created : skipped).push(path);
155
+ }
156
+ return { created, skipped } satisfies InstallResult;
157
+ }),
158
+ );
package/src/main.ts CHANGED
@@ -5,7 +5,7 @@ import { version } from "#version.ts";
5
5
  import { BunRuntime, BunServices } from "@effect/platform-bun";
6
6
  import { plugin } from "bun";
7
7
  import { Cause, Effect, Layer, Logger, References, Runtime } from "effect";
8
- import { Command } from "effect/unstable/cli";
8
+ import { Command } from "effect/cli";
9
9
 
10
10
  // A config file may import `@drkmttr/adhere`, for `defineConfig`. The compiled
11
11
  // executable resolves no packages from disk, so it answers that import with