@doikayt/typescript-build-config 0.1.13 → 0.1.15

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
@@ -5,10 +5,18 @@ Shared build configuration presets for TypeScript-based projects.
5
5
  <!-- TOC:START -->
6
6
  - [@doikayt/typescript-build-config](#doikayttypescript-build-config)
7
7
  - [Purpose](#purpose)
8
+ - [Design Goals](#design-goals)
9
+ - [What each component is for](#what-each-component-is-for)
8
10
  - [Installation](#installation)
11
+ - [Usage](#usage)
12
+ - [The canonical script set](#the-canonical-script-set)
13
+ - [Console / CLI project](#console--cli-project)
14
+ - [UI / web project (with Playwright e2e)](#ui--web-project-with-playwright-e2e)
15
+ - [Existing project](#existing-project)
9
16
  - [Dependency Strategy](#dependency-strategy)
10
17
  - [Current Contents](#current-contents)
11
18
  - [Delivery Model](#delivery-model)
19
+ - [Enforcement Model](#enforcement-model)
12
20
  - [Release Pipeline](#release-pipeline)
13
21
  - [Conventions Every Project Must Adhere To](#conventions-every-project-must-adhere-to)
14
22
  - [The `ci` script](#the-ci-script)
@@ -31,6 +39,51 @@ The plugin encapsulates common build **policy** and release **workflow logic**
31
39
  via the pipeline files it installs into each consumer repo and the canonical release process in
32
40
  [docs/RELEASE-PROCESS.md](docs/RELEASE-PROCESS.md).
33
41
 
42
+ ## Design Goals
43
+
44
+ The package housed in this repo is designed to be 'upstream-most' -- i.e., all other
45
+ packages depend on it (possibly transitively) and this package has no other
46
+ dependencies in this repo.
47
+
48
+ Two goals drive every design decision made for this package, and they pull in different
49
+ directions:
50
+
51
+ 1. **Consistency enforcement** — every `@doikayt` TypeScript repo should expose
52
+ the same command surface (`ci` and `update-all-format`) and share the same
53
+ lint / format / release policy, so tooling, CI, and contributors can assume
54
+ an identical shape in any repo.
55
+ 2. **Minimal per-project setup** — standing up a new repo, or absorbing a policy
56
+ change into an old one, should cost as close to zero manual wiring as
57
+ possible.
58
+
59
+ This package reconciles these two via one architectural choice: **it both
60
+ _defines_ the conventions and _distributes_ them.** A plain template repo would
61
+ copy conventions once and let them drift; a linter would enforce them but set
62
+ nothing up. The npm package defined in this repo does both — it is the single
63
+ artifact that decides "every repo has a `ci` gate" and also installs and
64
+ re-checks it. That single source of truth is what keeps the ecosystem coherent
65
+ as it grows.
66
+
67
+ ### What each component is for
68
+
69
+ Each artifact this package ships exists to serve one or both goals. (See
70
+ [Delivery Model](#delivery-model) for _how_ each is delivered; the table below
71
+ is _why_.)
72
+
73
+ | Component | Value it delivers | Serves |
74
+ | --- | --- | --- |
75
+ | **Presets** — `src/eslint.js`, Prettier, base tsconfig, living in `node_modules` | One lint / format / TS ruleset; change it once here and every repo picks it up on `npm update` | Consistency |
76
+ | **Stubs** — `src/top-level/*` seeded into the project root | Zero-config entry points that only `extends` the presets | Minimal setup |
77
+ | **Pipeline** — `src/pipeline/*` copied on install | A working release + changeset workflow with no hand-wiring; drift is diff-warned | Both |
78
+ | **Convention checks** — postinstall warns on missing `ci` / `update-all-format` | An install-time nudge toward the shared command surface | Consistency |
79
+ | **`ci` gate** — `release.yml` runs `npm run ci` | Fail-closed enforcement: a repo that ignores the convention cannot release | Consistency (hard teeth) |
80
+ | **Policy doc** — `docs/RELEASE-PROCESS.md`, linked never copied | One canonical release policy, impossible to drift | Consistency |
81
+ | **Assets** — `assets/*` seeded into `docs/assets/` | Shared brand logos with no per-repo copies to maintain | Minimal setup |
82
+
83
+ The two goals map onto two lifecycle phases — **delivery** (getting canonical
84
+ artifacts into a consuming repo) and **enforcement** (keeping that repo conformant over
85
+ time). Each has its own top-level section below.
86
+
34
87
  ## Installation
35
88
 
36
89
  ```bash
@@ -40,6 +93,75 @@ npm install --save-dev @doikayt/typescript-build-config
40
93
  The postinstall script copies starter config files into your project root and
41
94
  sets up the release pipeline (see below).
42
95
 
96
+ ## Usage
97
+
98
+ After installing, run the `init` scaffolder to write the
99
+ [canonical npm-script set](#the-canonical-script-set) and declare the dev
100
+ dependencies those scripts need. `init` is interactive and **idempotent** — it
101
+ only adds what is missing and never overwrites a script or config you already
102
+ have, so it is safe to re-run.
103
+
104
+ ### The canonical script set
105
+
106
+ Every project gets the same named scripts, so any repo responds identically to
107
+ `npm run ci`, `npm run update-all-format`, and so on. The aggregates chain to
108
+ small single-purpose scripts, and the check side mirrors the write side:
109
+
110
+ | Script | What it does |
111
+ | --- | --- |
112
+ | `ci` | The release gate: `check-all-format` then `test` (plus `test:e2e` for UI). |
113
+ | `build` | Compile TypeScript to `dist/` (`tsc`). |
114
+ | `prepack` | Runs `build` before packing, so `changeset publish` ships compiled `dist/`. |
115
+ | `test` | Unit / integration tests (`vitest run`). |
116
+ | `test:e2e` | End-to-end tests (`playwright test`) — UI projects only. |
117
+ | `update-all-format` | Reformat everything: code + markdown docs (write). |
118
+ | `check-all-format` | Verify formatting: code + markdown docs (used by `ci`). |
119
+ | `update-code-formatting` / `check-code-formatting` | Prettier write / check. |
120
+ | `update-markdown-docs` / `check-markdown-docs` | Regenerate / verify generated markdown (TOC, UML, …). |
121
+
122
+ Alongside the scripts, `init` sets the publish config a fresh package needs —
123
+ `type: "module"`, `main` / `types` / `exports` pointing at `dist/index.js`, and
124
+ `files: ["dist"]` — each only if you have not already set it. Combined with
125
+ `prepack`, a brand-new project builds and publishes a compiled, typed ESM
126
+ package on push with no extra wiring.
127
+
128
+ ### Console / CLI project
129
+
130
+ ```bash
131
+ npm init -y # if starting fresh
132
+ npm install --save-dev @doikayt/typescript-build-config
133
+ npx @doikayt/typescript-build-config init # answer "n" to the Playwright prompt
134
+ npm install # fetch the declared devDependencies
135
+ ```
136
+
137
+ `init` writes the canonical scripts (`ci`, `build`, `test`, `update-all-format`,
138
+ `check-all-format`, `prepack`, and their sub-tasks), sets the `dist/`-based
139
+ publish config, declares `vitest` + `@doikayt/autogen-markdown-doc` +
140
+ `typescript`, and seeds a commented `vitest.config.ts`.
141
+
142
+ ### UI / web project (with Playwright e2e)
143
+
144
+ ```bash
145
+ npm init -y
146
+ npm install --save-dev @doikayt/typescript-build-config
147
+ npx @doikayt/typescript-build-config init # answer "y" to the Playwright prompt
148
+ npm install
149
+ # then fill in the TODOs in the generated playwright.config.ts (webServer, baseURL)
150
+ ```
151
+
152
+ Answering **yes** to the Playwright prompt additionally: adds a `test:e2e`
153
+ script and folds it into `ci` (so the release gate runs e2e), declares
154
+ `@playwright/test`, and seeds a `playwright.config.ts` template. Browsers are
155
+ installed on demand by the bundled `doikayt-playwright-install` wrapper the
156
+ `test:e2e` script calls — no manual `playwright install` step, locally or in CI.
157
+
158
+ ### Existing project
159
+
160
+ Skip `init` if you prefer. On install, `postinstall` warns about any missing
161
+ required targets (`ci`, `update-all-format`) until you add them — either by
162
+ running `init` to adopt the full canonical set, or by defining the two scripts
163
+ by hand (see [Conventions](#conventions-every-project-must-adhere-to)).
164
+
43
165
  ## Dependency Strategy
44
166
 
45
167
  `eslint` and `prettier` are listed as `dependencies` and are pulled in
@@ -52,6 +174,11 @@ versions of these tools may produce peer dependency conflicts.
52
174
 
53
175
  ## Current Contents
54
176
 
177
+ - `init` scaffolder CLI (`npx @doikayt/typescript-build-config init`) — writes
178
+ the canonical npm-script set, declares dev dependencies, and seeds config
179
+ templates (see [Usage](#usage))
180
+ - `doikayt-playwright-install` bin — env-gated Playwright browser installer used
181
+ by a UI project's `test:e2e` script (adds `--with-deps` only on CI+Linux)
55
182
  - ESLint config
56
183
  - Prettier config
57
184
  - TypeScript config (`tsconfig.json`, `tsconfig.test.json`, `tsconfig.eslint.json`)
@@ -87,9 +214,21 @@ versions of these tools may produce peer dependency conflicts.
87
214
 
88
215
  ## Delivery Model
89
216
 
90
- This package standardizes downstream repos through three delivery channels — script and
91
- config artifacts that _implement_ the common policy, and a canonical document that
92
- _states_ it:
217
+ _Phase 1 of 2: getting canonical artifacts **into** a repo. Keeping a repo
218
+ conformant over time is the [Enforcement Model](#enforcement-model)._
219
+
220
+ This package standardizes downstream repos by delivering **five components through three
221
+ channels** — so as you read the diagram, note that the component count and the channel
222
+ count deliberately differ; several components share a channel. The components are the
223
+ presets, stubs, pipeline, and assets that _implement_ the common policy, plus a canonical
224
+ document that _states_ it. Each reaches the consumer via one of three channels:
225
+
226
+ 1. **Referenced in place** — presets (stay in `node_modules`)
227
+ 2. **Copied on install** — stubs, pipeline, assets
228
+ 3. **Linked, never copied** — policy
229
+
230
+ The diagram below numbers those three channels; the terms after it define the five
231
+ components.
93
232
 
94
233
  ```
95
234
  ┌─────────────────────────────────────────────────┐
@@ -150,6 +289,42 @@ _states_ it:
150
289
  their own contributor docs (as the consumer box shows) — never copied, so it cannot
151
290
  drift.
152
291
 
292
+ ## Enforcement Model
293
+
294
+ _Phase 2 of 2: keeping a repo **conformant** over time, once delivery has seeded
295
+ it. Getting artifacts in is the [Delivery Model](#delivery-model) above._
296
+
297
+ Delivery gets canonical artifacts into a consuming repo; enforcement keeps that repo aligned
298
+ as both it and the upstream evolve. The two phases share machinery:
299
+ **`postinstall` is the workhorse of both** — it _delivers_ on first install
300
+ (copying absent files) and _enforces_ on every subsequent run that rebuilds this
301
+ package into `node_modules` — a fresh `npm install`, an `npm ci` (which always
302
+ rebuilds the whole tree), or an `npm update` that bumps this package — warning on
303
+ drift and on missing conventions. A no-op `npm install` with an unchanged tree
304
+ does not re-trigger it.
305
+
306
+ Enforcement runs on a spectrum from soft to hard:
307
+
308
+ - **Soft — install-time warnings (name-level).** On every `npm install` /
309
+ `npm update`, postinstall warns (non-fatal) when a required target
310
+ (`ci`, `update-all-format`) is missing, and prints a diff when a copied
311
+ pipeline or asset file has drifted from its canonical template. See
312
+ [Release Pipeline](#release-pipeline) for the per-file drift behavior and
313
+ [Conventions Every Project Must Adhere To](#conventions-every-project-must-adhere-to)
314
+ for the required targets.
315
+ - **Hard — the fail-closed `ci` gate.** The release workflow runs `npm run ci`;
316
+ a repo without a working `ci` gate fails the build and cannot publish. This is
317
+ the only enforcement with real teeth — see
318
+ [The `ci` script](#the-ci-script).
319
+
320
+ One deliberate limitation: the soft checks are **name-level only**. They verify
321
+ a `ci` script _exists_, not that it runs anything meaningful — `"ci": "echo ok"`
322
+ satisfies the warning. Guaranteeing that `ci` actually does its job is left to
323
+ the fail-closed pipeline, where a broken gate surfaces as a red build. Put
324
+ differently: delivery is "deep" (full config seeded) while continuous
325
+ enforcement is "shallow" (presence, not behavior) — a gap worth knowing when you
326
+ rely on it.
327
+
153
328
  ## Release Pipeline
154
329
 
155
330
  On install, the postinstall script copies the release pipeline files into your
@@ -173,8 +348,8 @@ configuration is needed.
173
348
  ## Conventions Every Project Must Adhere To
174
349
 
175
350
  Every project that installs this package must implement two named entry points.
176
- `postinstall` warns on every `npm install` or `npm update` until each is in
177
- place.
351
+ `postinstall` warns whenever it re-runs (a fresh `npm install`, an `npm ci`, or
352
+ an `npm update` that bumps this package) until each is in place.
178
353
 
179
354
  ### The `ci` script
180
355
 
@@ -199,11 +374,27 @@ remote CI job will pass.
199
374
 
200
375
  #### NX projects
201
376
 
202
- NX-based projects must still expose `ci` as a thin shim in `package.json`, even
203
- if it only delegates to NX targets:
377
+ `package.json` is the source of truth — it is always present, and the release
378
+ workflow calls `npm run ci` directly. So an NX project keeps the **real** `ci`
379
+ command in `package.json`, exactly like any other project — it does *not*
380
+ delegate package.json to NX. If you want NX orchestration, add NX targets that
381
+ delegate **to** npm, never the reverse:
204
382
 
205
383
  ```json
206
- { "scripts": { "ci": "nx run-many --target=ci --all" } }
384
+ // package.json — the real command lives here
385
+ { "scripts": { "ci": "npm run check-all-format && npm run test" } }
386
+ ```
387
+
388
+ ```json
389
+ // project.json (optional) — an NX target delegates to the npm script
390
+ {
391
+ "targets": {
392
+ "ci": {
393
+ "executor": "nx:run-commands",
394
+ "options": { "command": "npm run ci" }
395
+ }
396
+ }
397
+ }
207
398
  ```
208
399
 
209
400
  The release workflow is NX-agnostic — it calls `npm run ci`, never NX directly.
@@ -238,8 +429,9 @@ Or as an NX target:
238
429
  }
239
430
  ```
240
431
 
241
- If neither is found, `postinstall` prints a warning (non-fatal) on every
242
- `npm install` or `npm update` until the target is added.
432
+ If neither is found, `postinstall` prints a warning (non-fatal) each time it
433
+ re-runs (a fresh `npm install`, an `npm ci`, or an `npm update` that bumps this
434
+ package) until the target is added.
243
435
 
244
436
  NX projects may define the target in `project.json` instead of `package.json`
245
437
  scripts — the postinstall check recognises both. There is no shim requirement
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doikayt/typescript-build-config",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Shared build configuration for TypeScript projects — ESLint, Prettier, tsconfig, and Playwright presets",
5
5
  "_comments": {
6
6
  "exports": "This block lists the config files that will be extended by files in in src/toplevel - which are copied to consuming project root folder. An example: eslint.config.js imports from '@doikayt/typescript-build-config/eslint'.",
@@ -14,6 +14,10 @@
14
14
  "./tsconfig": "./src/tsconfig.base.json",
15
15
  "./playwright": "./src/playwright.js"
16
16
  },
17
+ "bin": {
18
+ "typescript-build-config": "src/cli.js",
19
+ "doikayt-playwright-install": "src/playwright-install.js"
20
+ },
17
21
  "files": [
18
22
  "src/",
19
23
  "src/top-level/.gitignore",
@@ -24,8 +28,8 @@
24
28
  "postinstall": "node src/postinstall.js",
25
29
  "update-all-format": "prettier --write src/ && update-markdown-toc README.md",
26
30
  "check-format": "prettier --check src/ && update-markdown-toc --check README.md",
27
- "ci": "npm run check-format && node --test",
28
- "test": "node --test"
31
+ "ci": "npm run check-format && node --test tests/*.test.js",
32
+ "test": "node --test tests/*.test.js"
29
33
  },
30
34
  "publishConfig": {
31
35
  "access": "public"
@@ -0,0 +1,57 @@
1
+ // The canonical npm-script set init writes into every consumer. package.json is
2
+ // the single source of truth (it is always present); NX projects, if any, add
3
+ // thin targets that delegate to `npm run <x>` — never the reverse. So this set
4
+ // is identical regardless of orchestrator; only the UI/console distinction
5
+ // changes it (Playwright e2e).
6
+
7
+ export function canonicalScripts({ ui = false } = {}) {
8
+ const ci = ui
9
+ ? "npm run check-all-format && npm run test && npm run test:e2e"
10
+ : "npm run check-all-format && npm run test";
11
+
12
+ const scripts = {
13
+ ci,
14
+ build: "tsc",
15
+ test: "vitest run",
16
+ "update-all-format":
17
+ "npm run update-code-formatting && npm run update-markdown-docs",
18
+ "update-code-formatting": "prettier --write src/",
19
+ "update-markdown-docs": "autogen-markdown-doc",
20
+ "check-all-format":
21
+ "npm run check-code-formatting && npm run check-markdown-docs",
22
+ "check-code-formatting": "prettier --check src/",
23
+ "check-markdown-docs": "autogen-markdown-doc check",
24
+ // Build at publish time so `changeset publish` ships compiled dist/ without
25
+ // the release workflow needing a build step.
26
+ prepack: "npm run build",
27
+ };
28
+
29
+ if (ui) scripts["test:e2e"] = "doikayt-playwright-install && playwright test";
30
+
31
+ return scripts;
32
+ }
33
+
34
+ export function devDependencyNames({ ui = false } = {}) {
35
+ const names = ["vitest", "@doikayt/autogen-markdown-doc", "typescript"];
36
+ if (ui) names.push("@playwright/test");
37
+ return names;
38
+ }
39
+
40
+ // Top-level package.json fields init sets (non-destructively) so a fresh project
41
+ // builds to dist/ and publishes a compiled, importable ESM package. Assumes the
42
+ // canonical src/index.ts entry point; a consumer that wants a different shape
43
+ // sets these first and init leaves them untouched.
44
+ export function packageFields() {
45
+ return {
46
+ type: "module",
47
+ main: "dist/index.js",
48
+ types: "dist/index.d.ts",
49
+ exports: {
50
+ ".": {
51
+ types: "./dist/index.d.ts",
52
+ default: "./dist/index.js",
53
+ },
54
+ },
55
+ files: ["dist"],
56
+ };
57
+ }
package/src/cli.js ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ // Main CLI entry for this package, registered as the `typescript-build-config`
3
+ // bin. Invoked as `npx @doikayt/typescript-build-config <subcommand>` — npx
4
+ // resolves the bin whose name matches the package. argv[2] is the subcommand
5
+ // (argv[0]=node, argv[1]=this file). Today the only subcommand is `init`; this
6
+ // dispatcher is the seam where future ones (e.g. `check-conventions`) plug in.
7
+ import { runInit } from "./init.js";
8
+
9
+ const sub = process.argv[2];
10
+
11
+ if (sub === "init") {
12
+ // runInit is async (it prompts via readline); translate resolve/reject into
13
+ // process exit codes so the shell sees success/failure.
14
+ runInit().then(
15
+ () => process.exit(0),
16
+ (err) => {
17
+ console.error(err?.message ?? err);
18
+ process.exit(1);
19
+ },
20
+ );
21
+ } else {
22
+ console.error(
23
+ `Unknown command: ${sub ?? "(none)"}\nUsage: typescript-build-config init`,
24
+ );
25
+ process.exit(1);
26
+ }
@@ -0,0 +1,29 @@
1
+ import { execFileSync } from "child_process";
2
+
3
+ export function toCaretRange(version) {
4
+ return `^${version}`;
5
+ }
6
+
7
+ // Default view: ask npm for a package's latest published version. Injectable so
8
+ // resolveVersions stays unit-testable without shelling out.
9
+ export function npmView(name) {
10
+ return execFileSync("npm", ["view", name, "version"], {
11
+ encoding: "utf8",
12
+ }).trim();
13
+ }
14
+
15
+ // Resolve each package name to a caret range on its latest version. If the
16
+ // lookup fails (offline) or returns nothing, fall back to "latest" so the
17
+ // declared devDependency is still installable once the user runs `npm install`.
18
+ export function resolveVersions(names, view = npmView) {
19
+ const out = {};
20
+ for (const name of names) {
21
+ try {
22
+ const version = view(name);
23
+ out[name] = version ? toCaretRange(version) : "latest";
24
+ } catch {
25
+ out[name] = "latest";
26
+ }
27
+ }
28
+ return out;
29
+ }
package/src/init.js ADDED
@@ -0,0 +1,159 @@
1
+ import {
2
+ readFileSync,
3
+ writeFileSync,
4
+ existsSync,
5
+ copyFileSync,
6
+ mkdirSync,
7
+ readdirSync,
8
+ } from "fs";
9
+ import { resolve } from "path";
10
+ import { fileURLToPath } from "url";
11
+ import { createInterface } from "readline";
12
+ import {
13
+ canonicalScripts,
14
+ devDependencyNames,
15
+ packageFields,
16
+ } from "./canonical-scripts.js";
17
+ import { applyToPackageJson } from "./package-json.js";
18
+ import { resolveVersions } from "./dep-versions.js";
19
+
20
+ const templatesDir = fileURLToPath(new URL("templates/", import.meta.url));
21
+
22
+ // Copy a template into the project only when the destination is absent, so a
23
+ // consumer's existing config is never overwritten. Returns what happened.
24
+ function seedFile(src, destPath) {
25
+ if (existsSync(destPath)) return "skipped";
26
+ copyFileSync(resolve(templatesDir, src), destPath);
27
+ return "seeded";
28
+ }
29
+
30
+ // Recursively copy a template directory tree into the project.
31
+ function copyDir(srcDir, destDir) {
32
+ mkdirSync(destDir, { recursive: true });
33
+ for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
34
+ const from = resolve(srcDir, entry.name);
35
+ const to = resolve(destDir, entry.name);
36
+ if (entry.isDirectory()) copyDir(from, to);
37
+ else copyFileSync(from, to);
38
+ }
39
+ }
40
+
41
+ function dirIsEmpty(dir) {
42
+ return !existsSync(dir) || readdirSync(dir).length === 0;
43
+ }
44
+
45
+ // Default interactive prompt. Injectable so runInit is testable without stdin.
46
+ async function askYesNo(question) {
47
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
48
+ try {
49
+ const answer = await new Promise((res) => rl.question(question, res));
50
+ return /^y(es)?$/i.test(answer.trim());
51
+ } finally {
52
+ rl.close();
53
+ }
54
+ }
55
+
56
+ export async function runInit({
57
+ cwd = process.cwd(),
58
+ prompt = askYesNo,
59
+ resolveDevVersions = resolveVersions,
60
+ log = console.log,
61
+ } = {}) {
62
+ const pkgPath = resolve(cwd, "package.json");
63
+ const raw = readFileSync(pkgPath, "utf8");
64
+
65
+ const ui = await prompt(
66
+ "Is this a UI project that needs Playwright (e2e)? [y/N] ",
67
+ );
68
+
69
+ const scripts = canonicalScripts({ ui });
70
+ const devDependencies = resolveDevVersions(devDependencyNames({ ui }));
71
+
72
+ const { text, added, skipped } = applyToPackageJson(raw, {
73
+ scripts,
74
+ devDependencies,
75
+ fields: packageFields(),
76
+ });
77
+ writeFileSync(pkgPath, text);
78
+
79
+ const configs = [{ name: "vitest.config.ts" }];
80
+ if (ui) configs.push({ name: "playwright.config.ts" });
81
+ const seededConfigs = [];
82
+ const keptConfigs = [];
83
+ for (const { name } of configs) {
84
+ const result = seedFile(name, resolve(cwd, name));
85
+ (result === "seeded" ? seededConfigs : keptConfigs).push(name);
86
+ }
87
+
88
+ const report = (label, keys) => {
89
+ if (keys.length) log(`${label}: ${keys.join(", ")}`);
90
+ };
91
+ report("Added scripts", added.scripts);
92
+ report("Kept existing scripts", skipped.scripts);
93
+ report("Declared devDependencies", added.devDependencies);
94
+ report("Kept existing devDependencies", skipped.devDependencies);
95
+ report("Set package fields", added.fields);
96
+ report("Kept existing package fields", skipped.fields);
97
+ report("Seeded config files", seededConfigs);
98
+ report("Kept existing config files", keptConfigs);
99
+ log("");
100
+ log("Next: run `npm install` to fetch the newly declared devDependencies.");
101
+
102
+ const demo = await maybeScaffoldDemo({ cwd, prompt, log });
103
+
104
+ return { added, skipped, seededConfigs, keptConfigs, demo };
105
+ }
106
+
107
+ // Opt-in starter scaffold for a new project. Seeds a minimal src/ module, a
108
+ // README with doc-generator markers, and a project.json — each independently and
109
+ // only when its slot is empty, so nothing a consumer already has is overwritten.
110
+ // It writes files only; the user runs npm install / test / push themselves.
111
+ async function maybeScaffoldDemo({ cwd, prompt, log }) {
112
+ const wantDemo = await prompt(
113
+ "Scaffold a starter demo (minimal src module + README + project.json) so you\n" +
114
+ "can build, test, and publish immediately? Existing files are never\n" +
115
+ "overwritten. [y/N] ",
116
+ );
117
+ if (!wantDemo) {
118
+ return { scaffolded: false, reason: "declined", wrote: [], skipped: [] };
119
+ }
120
+
121
+ const demoTemplate = resolve(templatesDir, "demo");
122
+ const wrote = [];
123
+ const skipped = [];
124
+ const paths = [];
125
+
126
+ // src/: seed the module only when src/ has no files, so starter code never
127
+ // gets mixed into a project that already has source.
128
+ const srcDir = resolve(cwd, "src");
129
+ if (dirIsEmpty(srcDir)) {
130
+ copyDir(resolve(demoTemplate, "src"), srcDir);
131
+ for (const entry of readdirSync(resolve(demoTemplate, "src"))) {
132
+ paths.push(`src/${entry}`);
133
+ }
134
+ wrote.push("src/ module");
135
+ } else {
136
+ skipped.push("src/ (already has files)");
137
+ }
138
+
139
+ // README.md and project.json: single files, seeded only when absent.
140
+ for (const name of ["README.md", "project.json"]) {
141
+ if (existsSync(resolve(cwd, name))) {
142
+ skipped.push(name);
143
+ } else {
144
+ copyFileSync(resolve(demoTemplate, name), resolve(cwd, name));
145
+ wrote.push(name);
146
+ paths.push(name);
147
+ }
148
+ }
149
+
150
+ log("");
151
+ if (wrote.length) log(`Demo: seeded ${wrote.join(", ")}.`);
152
+ if (skipped.length) log(`Demo: kept existing ${skipped.join(", ")}.`);
153
+ if (paths.length) {
154
+ log(`To remove the starter files later: rm -r ${paths.join(" ")}`);
155
+ log("Then: npm install → npm test → npm run update-all-format → push.");
156
+ }
157
+
158
+ return { scaffolded: paths.length > 0, reason: null, wrote, skipped, paths };
159
+ }
@@ -0,0 +1,69 @@
1
+ // Idempotent helpers for editing a consumer's package.json: add keys only when
2
+ // absent, never overwrite what the consumer already has, and preserve the
3
+ // file's original indentation and trailing-newline style.
4
+
5
+ export function mergeAbsent(existing = {}, additions = {}) {
6
+ const merged = { ...existing };
7
+ const added = [];
8
+ const skipped = [];
9
+ for (const [key, value] of Object.entries(additions)) {
10
+ if (Object.prototype.hasOwnProperty.call(merged, key)) {
11
+ skipped.push(key);
12
+ } else {
13
+ merged[key] = value;
14
+ added.push(key);
15
+ }
16
+ }
17
+ return { merged, added, skipped };
18
+ }
19
+
20
+ export function detectIndent(text) {
21
+ const m = text.match(/\n([\t ]+)/);
22
+ return m ? m[1] : " ";
23
+ }
24
+
25
+ /**
26
+ * Merge scripts / devDependencies into package.json text, adding only absent
27
+ * keys and preserving the file's indentation and trailing-newline style. Does
28
+ * not touch disk — returns the new text plus a report of what changed.
29
+ *
30
+ * @param {string} rawText - current package.json contents
31
+ * @param {{
32
+ * scripts?: Record<string,string>,
33
+ * devDependencies?: Record<string,string>,
34
+ * fields?: Record<string,unknown>
35
+ * }} additions - `fields` are top-level keys (main, type, files, …)
36
+ * @returns {{
37
+ * text: string,
38
+ * added: { scripts: string[], devDependencies: string[], fields: string[] },
39
+ * skipped: { scripts: string[], devDependencies: string[], fields: string[] }
40
+ * }} new file text, plus keys added vs. skipped (already present)
41
+ */
42
+ export function applyToPackageJson(
43
+ rawText,
44
+ { scripts = {}, devDependencies = {}, fields = {} } = {},
45
+ ) {
46
+ const pkg = JSON.parse(rawText);
47
+ const indent = detectIndent(rawText);
48
+
49
+ const s = mergeAbsent(pkg.scripts, scripts);
50
+ const d = mergeAbsent(pkg.devDependencies, devDependencies);
51
+ const f = mergeAbsent(pkg, fields);
52
+
53
+ const next = { ...f.merged };
54
+ if (Object.keys(s.merged).length) next.scripts = s.merged;
55
+ if (Object.keys(d.merged).length) next.devDependencies = d.merged;
56
+
57
+ const trailingNewline = rawText.endsWith("\n") ? "\n" : "";
58
+ const text = JSON.stringify(next, null, indent) + trailingNewline;
59
+
60
+ return {
61
+ text,
62
+ added: { scripts: s.added, devDependencies: d.added, fields: f.added },
63
+ skipped: {
64
+ scripts: s.skipped,
65
+ devDependencies: d.skipped,
66
+ fields: f.skipped,
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "child_process";
3
+ import { realpathSync } from "fs";
4
+ import { fileURLToPath } from "url";
5
+
6
+ // Treat CI as active when the CI env var is present and not an explicit falsy
7
+ // value. Most CI systems (including GitHub Actions) set CI=true.
8
+ export function isCI(env = process.env) {
9
+ const v = env.CI;
10
+ if (v === undefined) return false;
11
+ return v !== "" && v !== "false" && v !== "0";
12
+ }
13
+
14
+ // --with-deps installs OS-level libraries and requires root, so it is only safe
15
+ // (and only needed) on Linux CI runners. Everywhere else — dev laptops, NixOS,
16
+ // macOS — install just the browser binary.
17
+ export function playwrightInstallArgs({ ci, platform }) {
18
+ const args = ["install"];
19
+ if (ci && platform === "linux") args.push("--with-deps");
20
+ args.push("chromium");
21
+ return args;
22
+ }
23
+
24
+ export function run({ env = process.env, platform = process.platform } = {}) {
25
+ const args = [
26
+ "playwright",
27
+ ...playwrightInstallArgs({ ci: isCI(env), platform }),
28
+ ];
29
+ const result = spawnSync("npx", args, { stdio: "inherit" });
30
+ return result.status ?? 1;
31
+ }
32
+
33
+ // This file is both an importable module (unit tests import the pure functions)
34
+ // and an executable bin. Guard the CLI action so `run()` — which shells out to
35
+ // `playwright install` — fires only when the file is run directly, never on
36
+ // import. When launched via the npm `.bin/doikayt-playwright-install` shim,
37
+ // argv[1] is a symlink, so realpath both sides before comparing.
38
+ function invokedDirectly() {
39
+ const entry = process.argv[1];
40
+ if (!entry) return false;
41
+ try {
42
+ return realpathSync(entry) === fileURLToPath(import.meta.url);
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ if (invokedDirectly()) {
49
+ process.exit(run());
50
+ }
@@ -147,7 +147,7 @@ const missingTargets = [];
147
147
  if (!projectPkg.scripts?.["ci"]) {
148
148
  missingTargets.push({
149
149
  name: "ci",
150
- hint: ' "ci": "npm run check-all-format && npm run test"',
150
+ hint: ' "ci": "prettier --check src/ && npm test"',
151
151
  nxNote: false,
152
152
  });
153
153
  }
@@ -170,7 +170,7 @@ if (!hasUpdateAllFormat) {
170
170
  if (!hasUpdateAllFormat) {
171
171
  missingTargets.push({
172
172
  name: "update-all-format",
173
- hint: ' "update-all-format": "npm run update-code-formatting && npm run update-markdown-docs"',
173
+ hint: ' "update-all-format": "prettier --write src/"',
174
174
  nxNote: true,
175
175
  });
176
176
  }
@@ -0,0 +1,42 @@
1
+ <!-- TOC:START -->
2
+ <!-- TOC:END -->
3
+
4
+ # Starter project
5
+
6
+ > **Seeded by `init` — safe to replace.** These starter files (this README, a
7
+ > `math-engine` component under `src/`, and `project.json`) exist so a brand-new
8
+ > project can run its tests, publish on push, and watch the doc generator work
9
+ > before you write any code. Replace them with your own whenever you like —
10
+ > `init` printed the exact paths to delete.
11
+
12
+ Run `npm run update-all-format` to fill the blocks below; GitHub renders the
13
+ Mermaid diagrams inline from the fenced blocks.
14
+
15
+ ## Build Pipeline
16
+
17
+ <!-- NX_GRAPH:START -->
18
+ <!-- NX_GRAPH:END -->
19
+
20
+ ## Component Diagram
21
+
22
+ <!-- UML:components:START -->
23
+ <!-- UML:components:END -->
24
+
25
+ ## Components Table
26
+
27
+ <!-- UML:components-table:START -->
28
+ <!-- UML:components-table:END -->
29
+
30
+ ## Component Details
31
+
32
+ <!-- UML:component-details:START -->
33
+ <!-- UML:component-details:END -->
34
+
35
+ ## Try it
36
+
37
+ ```bash
38
+ npm install # fetch the dev deps init declared
39
+ npm test # the seeded vitest test passes
40
+ npm run update-all-format # fills the doc blocks above
41
+ git commit -am "feat: initial version" && git push # feat:/fix: trigger a publish
42
+ ```
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "demo",
3
+ "targets": {
4
+ "install": {
5
+ "description": "Install npm dependencies"
6
+ },
7
+ "build": {
8
+ "description": "Compile TypeScript source to JavaScript",
9
+ "dependsOn": ["install"]
10
+ },
11
+ "lint": {
12
+ "description": "Run ESLint on TypeScript source files",
13
+ "dependsOn": ["install"]
14
+ },
15
+ "type-check": {
16
+ "description": "Type-check source without emitting",
17
+ "dependsOn": ["build"]
18
+ },
19
+ "test": {
20
+ "description": "Run unit tests with vitest",
21
+ "dependsOn": ["build"]
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,7 @@
1
+ // Starter file from `init` — replace with your own code.
2
+ // Package entry point: re-exports the demo component so the built package has a
3
+ // public surface to publish.
4
+ export { MathEngine } from "./math-engine/MathEngine.js";
5
+ export { MathError } from "./math-engine/MathError.js";
6
+ export type { MathResult } from "./math-engine/MathResult.js";
7
+ export type { Operation } from "./math-engine/Operation.js";
@@ -0,0 +1,24 @@
1
+ // Starter file from `init` — replace with your own code.
2
+ import { describe, test, expect } from "vitest";
3
+ import { MathEngine } from "./MathEngine.js";
4
+ import { MathError } from "./MathError.js";
5
+
6
+ describe("MathEngine", () => {
7
+ const engine = new MathEngine();
8
+
9
+ test("add returns the sum and echoes the operation", () => {
10
+ expect(engine.add(3, 4)).toEqual({
11
+ value: 7,
12
+ operation: "add",
13
+ operands: [3, 4],
14
+ });
15
+ });
16
+
17
+ test("subtract returns the difference", () => {
18
+ expect(engine.subtract(10, 4).value).toBe(6);
19
+ });
20
+
21
+ test("rejects non-finite operands with a MathError", () => {
22
+ expect(() => engine.add(1, Infinity)).toThrow(MathError);
23
+ });
24
+ });
@@ -0,0 +1,22 @@
1
+ // Starter file from `init` — replace with your own code.
2
+ import { MathError } from "./MathError.js";
3
+ import type { MathResult } from "./MathResult.js";
4
+ import type { Operation } from "./Operation.js";
5
+
6
+ export class MathEngine {
7
+ add(a: number, b: number): MathResult {
8
+ this.validate("add", a, b);
9
+ return { value: a + b, operation: "add", operands: [a, b] };
10
+ }
11
+
12
+ subtract(a: number, b: number): MathResult {
13
+ this.validate("subtract", a, b);
14
+ return { value: a - b, operation: "subtract", operands: [a, b] };
15
+ }
16
+
17
+ private validate(op: Operation, a: number, b: number): void {
18
+ if (!Number.isFinite(a) || !Number.isFinite(b)) {
19
+ throw new MathError(op, "Operands must be finite numbers");
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,11 @@
1
+ import type { Operation } from "./Operation.js";
2
+
3
+ export class MathError extends Error {
4
+ readonly operation: Operation;
5
+
6
+ constructor(operation: Operation, message: string) {
7
+ super(message);
8
+ this.name = "MathError";
9
+ this.operation = operation;
10
+ }
11
+ }
@@ -0,0 +1,7 @@
1
+ import type { Operation } from "./Operation.js";
2
+
3
+ export interface MathResult {
4
+ value: number;
5
+ operation: Operation;
6
+ operands: [number, number];
7
+ }
@@ -0,0 +1 @@
1
+ export type Operation = "add" | "subtract";
@@ -0,0 +1,2 @@
1
+ The math-engine component: a small, dependency-free arithmetic core. It validates
2
+ operands and returns a structured result describing the operation performed.
@@ -0,0 +1,17 @@
1
+ import { definePlaywrightConfig } from "@doikayt/typescript-build-config/playwright";
2
+
3
+ // Seeded by `typescript-build-config init`. definePlaywrightConfig merges the
4
+ // NixOS system-Chromium path automatically; fill in the TODOs for your app.
5
+ export default definePlaywrightConfig({
6
+ testDir: "tests/e2e",
7
+ // Playwright starts your app before the run and waits for it to be reachable,
8
+ // so `npm run test:e2e` needs no separate server step in CI.
9
+ webServer: {
10
+ command: "npm run start", // TODO: the command that starts your app
11
+ url: "http://localhost:3000", // TODO: the URL Playwright waits for
12
+ reuseExistingServer: true,
13
+ },
14
+ use: {
15
+ baseURL: "http://localhost:3000", // TODO: base URL for page.goto("/")
16
+ },
17
+ });
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ // Seeded by `typescript-build-config init`. Vitest runs TypeScript with zero
4
+ // config, so everything here is optional — it exists as a signpost for the
5
+ // common knobs. Uncomment and adjust as your project needs.
6
+ export default defineConfig({
7
+ test: {
8
+ // include: ["src/**/*.test.ts"],
9
+ // environment: "node", // or "jsdom" / "happy-dom" for DOM tests
10
+ // coverage: { provider: "v8", reporter: ["text", "html"] },
11
+ },
12
+ });
@@ -4,5 +4,6 @@
4
4
  "outDir": "dist",
5
5
  "rootDir": "src"
6
6
  },
7
- "include": ["src"]
7
+ "include": ["src"],
8
+ "exclude": ["node_modules", "dist", "src/**/*.test.ts"]
8
9
  }