@sidebase/base-config 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +554 -0
  3. package/dist/config.d.mts +189 -0
  4. package/dist/config.d.ts +189 -0
  5. package/dist/config.mjs +46 -0
  6. package/dist/eslint/index.d.mts +55 -0
  7. package/dist/eslint/index.d.ts +55 -0
  8. package/dist/eslint/index.mjs +278 -0
  9. package/dist/prisma/index.d.mts +74 -0
  10. package/dist/prisma/index.d.ts +74 -0
  11. package/dist/prisma/index.mjs +61 -0
  12. package/dist/shared/base-config.CuUhyvQo.d.mts +47 -0
  13. package/dist/shared/base-config.CuUhyvQo.d.ts +47 -0
  14. package/docs/migration.md +764 -0
  15. package/package.json +94 -0
  16. package/presets/base/AGENTS.md +31 -0
  17. package/presets/base/CLAUDE.md +3 -0
  18. package/presets/base/dockerignore +18 -0
  19. package/presets/base/editorconfig +12 -0
  20. package/presets/base/github/workflows/streamctl-upgrade.yml +130 -0
  21. package/presets/base/gitignore +19 -0
  22. package/presets/base/oxlintrc.json +31 -0
  23. package/presets/base/pnpm-workspace.yaml +27 -0
  24. package/presets/base/preset.json +38 -0
  25. package/presets/base/templates/pnpm/only-built-dependency.yml +1 -0
  26. package/presets/base/tsconfig.json +3 -0
  27. package/presets/base/vscode/extensions.json +8 -0
  28. package/presets/base/vscode/settings.json +43 -0
  29. package/presets/config.template.ts +18 -0
  30. package/presets/manifest.json +17 -0
  31. package/presets/nuxt-app/Dockerfile +63 -0
  32. package/presets/nuxt-app/eslint.config.ts +5 -0
  33. package/presets/nuxt-app/github/workflows/ci.yml +66 -0
  34. package/presets/nuxt-app/github/workflows/pr-preview-cleanup.yml +41 -0
  35. package/presets/nuxt-app/preset.json +67 -0
  36. package/presets/nuxt-app/prisma.config.ts +6 -0
  37. package/presets/nuxt-app/templates/ci/e2e-job.yml +39 -0
  38. package/presets/nuxt-app/templates/ci/test-job.yml +13 -0
  39. package/presets/nuxt-app/tsconfig.json +6 -0
  40. package/tsconfig.base.json +19 -0
@@ -0,0 +1,764 @@
1
+ # Migrating from `@sidestream-tech/nuxt-config` to `@sidebase/base-config`
2
+
3
+ An ordered runbook for moving one repository onto the renamed payload.
4
+
5
+ The rename is not a version bump. `@sidebase/base-config` is a new npm package with its
6
+ own name, scope, and version line, so no automated upgrade path crosses it. You do the
7
+ steps below by hand, once, per repository.
8
+
9
+ **Read "Why the order matters" below before you start.** Step 2 must happen before step 4. Getting
10
+ that wrong does not produce an error. It produces a repository that reports "all clean"
11
+ and exits 0 while receiving no dependency updates at all.
12
+
13
+ ## Scope
14
+
15
+ This is written for a real migration. At the time of writing, the known adopters are all
16
+ on unmerged branches, and none of them can install at all: every one pins both the payload
17
+ and the CLI, through `pnpm.overrides` or a direct `file:` dependency, into a `/tmp`
18
+ directory that no longer exists. For a branch in that state, re-running
19
+ `streamctl init` against the published package on a fresh branch is usually less work than
20
+ migrating it. Use this runbook when you have a working checkout worth preserving.
21
+
22
+ ## CLI prerequisite
23
+
24
+ Your `@sidebase/streamctl` must satisfy two separate requirements. They landed in that
25
+ order, so the second one is the binding constraint:
26
+
27
+ 1. **The undeclared-profile guard.** Makes a profile name that the payload does not declare
28
+ a hard error, instead of an empty baseline that reconciles nothing and fails no command.
29
+ This is what protects step 4.
30
+ 2. **The `scripts.*` reconcile widening** (`RECONCILABLE_KEY_PATTERN`, streamctl commit
31
+ `090c674`). This payload's version baseline includes `scripts.lint`, and a CLI without
32
+ the widening rejects the whole payload at load time with `CONFIG_INVALID`, so `init`,
33
+ `sync`, `check`, `upgrade`, and `status` all fail.
34
+
35
+ The widening came after the guard, so **a build containing the widening also contains the
36
+ guard, and the widening is the effective minimum.** Requiring it is sufficient.
37
+
38
+ > **Minimum version: `0.2.0`.**
39
+ > That release carries both prerequisites above, and one more that binds harder: it reads
40
+ > `streamctl.config.ts` from the repo root, which is the layout this payload's fixtures,
41
+ > docs, and `ignoresTypeAware` default all assume. `0.1.0` already has the widening, so on
42
+ > that build the payload loads — it just cannot find a root config file.
43
+
44
+ ### Verify your CLI has both, without needing the version number
45
+
46
+ You do not need a version number to check this. The payload itself is the test. Run this
47
+ once you have completed step 3 and the `package:` half of step 4, and before you change
48
+ `profile:`:
49
+
50
+ ```sh
51
+ pnpm streamctl check
52
+ ```
53
+
54
+ Read the result against this table:
55
+
56
+ | What you see | What it means |
57
+ | ------------ | ------------- |
58
+ | `[ERROR] Invalid presets/nuxt-app/preset.json: ... "scripts.lint" must be a reconcilable version key ...` | The widening is MISSING. Upgrade the CLI. Nothing else will work until you do. |
59
+ | `[ERROR] profile "nuxt" is not declared in presets/manifest.json profiles[]; declared: nuxt-4.` with exit 1 | Both requirements are met. This error is the guard doing its job. Continue to the `profile:` edit. |
60
+ | `all clean.` with exit 0, while `profile:` is still `nuxt` | The guard is MISSING. Stop. Do not continue. Upgrade the CLI first. See "Why the order matters". This should be impossible on any build cut from the current source, since the widening implies the guard; if you see it, you are on an older published build that predates both. |
61
+
62
+ The middle row is the expected outcome on a correct CLI. It looks like a failure and is
63
+ not one: you are deliberately in a half-migrated state, and the CLI is telling you the
64
+ profile name has not been updated yet.
65
+
66
+ ## Why the order matters
67
+
68
+ **Skip this section if you already confirmed your CLI with the table above.** The failure it
69
+ describes cannot happen on a CLI that has the guard. It is here because every adopter branch
70
+ today runs a build that does not, and because the failure is invisible if you meet it.
71
+
72
+ The CLI resolves a version baseline with `versionProfiles?.[profile] ?? {}`. A profile name
73
+ that no preset declares therefore resolves to an empty object rather than raising an error.
74
+ An empty baseline reconciles nothing.
75
+
76
+ On a CLI **with** the guard, an undeclared profile is a hard error and exit 1. You cannot
77
+ get into the bad state.
78
+
79
+ On a CLI **without** the guard, renaming the profile to `nuxt-4` before the payload that
80
+ declares `nuxt-4` is resolvable gives you a repository where:
81
+
82
+ - `streamctl check` prints `all clean.` and exits 0,
83
+ - `streamctl sync` reports that everything is up to date and writes nothing,
84
+ - every managed file really is in sync, so the report is not lying about files,
85
+ - and yet `eslint`, `jiti`, `prisma`, `@prisma/client`, `typescript`, and the
86
+ `scripts.postinstall` and `scripts.lint` entries are never reconciled again.
87
+
88
+ Exactly one thing surfaces, a non-blocking warning on stderr:
89
+
90
+ ```
91
+ [WARN] profile "nuxt-4" does not match the profile detected from package.json: "nuxt" (nuxt ^4.3.0 in devDependencies).
92
+ ```
93
+
94
+ Do not count on that line to save you. It does not fail the command, it does not change the
95
+ exit code, and it prints directly above a green `all clean.` in a CI log that nobody opens
96
+ when the job passes. Treat it as the only warning you will get, not as a safety net.
97
+
98
+ The repository quietly stops receiving the version floors the fleet relies on, and the next
99
+ person to look sees a green check. This was reproduced deliberately, not theorised: on a
100
+ guardless CLI with the profile renamed ahead of the payload, `jiti` was hand-edited down to
101
+ `^1.0.0`, `eslint` down to `^8.0.0`, and `scripts.postinstall` deleted outright. The next
102
+ `sync` restored none of the three and reported nothing to write, and `check` still exited 0
103
+ with `all clean.`
104
+
105
+ If you suspect a repository is already in this state, do not trust `check`. Open
106
+ `package.json` and confirm the pins actually match the baseline in
107
+ `presets/nuxt-app/preset.json`.
108
+
109
+ ## Why `streamctl upgrade` cannot do this
110
+
111
+ `upgrade` moves the version pin and nothing else. Internally it builds the new config as
112
+ `{ ...config, version: toVersion }`, so `package:` is carried through untouched, and it
113
+ bumps the devDependency under that same unchanged name. There is no code path in which it
114
+ rewrites `package:` from one npm name to another.
115
+
116
+ So `upgrade` can take you from `@sidebase/base-config@0.1.0` to a later `@sidebase/base-config`,
117
+ but it cannot take you from `@sidestream-tech/nuxt-config` to `@sidebase/base-config`. That
118
+ is why this runbook exists and why step 4 is a manual edit.
119
+
120
+ ## The runbook
121
+
122
+ ### 0. Remove any dead local override
123
+
124
+ Skip this if `pnpm install` already works. If it does not, this is almost certainly why, and
125
+ every later step runs an install.
126
+
127
+ A repository that installed the payload through a local override, for example
128
+ `pnpm.overrides` pointing at a checkout or a tarball path, cannot install once that path
129
+ disappears. Every known adopter branch is in exactly this state, pinned into a `/tmp`
130
+ directory that no longer exists.
131
+
132
+ ```sh
133
+ grep -n "file:" package.json
134
+ ```
135
+
136
+ If a `file:` entry points somewhere that no longer exists, there is nothing to repoint and
137
+ nothing to preserve: delete it. Note that `pnpm remove` in step 3 does NOT clear a matching
138
+ `pnpm.overrides` entry, so the override survives the swap unless you remove it by hand.
139
+
140
+ If the path still exists and you are deliberately testing a local build, repoint it at the
141
+ artifact you actually want before continuing. The CLI never rewrites an override for you,
142
+ because that is package-manager specific.
143
+
144
+ ### 1. Get to a clean check first
145
+
146
+ ```sh
147
+ pnpm streamctl check
148
+ ```
149
+
150
+ Expected before you go any further: exit 0 and `all clean.`
151
+
152
+ If the repository has pre-existing drift, `check` exits 3 and prints the drifted paths:
153
+
154
+ ```
155
+ streamctl check
156
+
157
+ [x] drift 1 AGENTS.md (content)
158
+
159
+ DRIFT_DETECTED. Run `streamctl sync` to reconcile.
160
+ ```
161
+
162
+ Resolve it now, either by accepting the payload version with `pnpm streamctl sync`, or by
163
+ opting the file out (see the escapes section) if the repository genuinely owns it. Do not
164
+ carry drift into a rename: any automated path takes the drift branch instead of the
165
+ upgrade branch, and hand-resolving a conflict at the same time as a rename makes it much
166
+ harder to tell which change caused what.
167
+
168
+ ### 2. Upgrade the CLI
169
+
170
+ Do this BEFORE touching the config.
171
+
172
+ ```sh
173
+ pnpm add -D @sidebase/streamctl@^0.2.0
174
+ ```
175
+
176
+ ### 3. Swap the payload package
177
+
178
+ ```sh
179
+ pnpm remove @sidestream-tech/nuxt-config
180
+ pnpm add -D @sidebase/base-config
181
+ ```
182
+
183
+ ### 4. Edit `streamctl.config.ts`
184
+
185
+ Do this in two parts so you get the CLI verification for free.
186
+
187
+ First change `package:` and `version:`, and leave `profile:` alone:
188
+
189
+ ```ts
190
+ export default defineNuxtBaseConfig({
191
+ package: "@sidebase/base-config", // was @sidestream-tech/nuxt-config
192
+ base: "nuxt-app", // unchanged
193
+ version: "0.1.0", // the @sidebase/base-config version you installed
194
+ profile: "nuxt", // still the OLD name, on purpose, for one command
195
+ });
196
+ ```
197
+
198
+ Run `pnpm streamctl check` and read it against the table in the CLI prerequisite section.
199
+ You want the `profile "nuxt" is not declared` error with exit 1.
200
+
201
+ If you instead get `CONFIG_INVALID` naming a key in your own config, that key was removed in
202
+ this release; `ci.deploy` and `ci.migrationLint` are the two. The CLI shape-checks the config
203
+ against the payload's declared `configKeys`, so a key that no longer exists is a hard error
204
+ rather than an ignored field. Delete it and re-run.
205
+
206
+ Then change the profile:
207
+
208
+ ```ts
209
+ profile: "nuxt-4",
210
+ ```
211
+
212
+ The import in that file changes too, since `defineNuxtBaseConfig` now comes from the new
213
+ package:
214
+
215
+ ```ts
216
+ import { defineNuxtBaseConfig } from "@sidebase/base-config";
217
+ ```
218
+
219
+ ### 5. Update the other import specifiers
220
+
221
+ Two files import from the payload by name. The ESLint factory was also RENAMED in this
222
+ release, from `createStreamctlEslint` to `createSidebaseEslint`, so that call changes too:
223
+
224
+ ```ts
225
+ // eslint.config.ts
226
+ import { createSidebaseEslint } from "@sidebase/base-config/eslint";
227
+
228
+ export default createSidebaseEslint();
229
+ ```
230
+
231
+ ```ts
232
+ // prisma.config.ts
233
+ import { applyPrismaDevEnv, buildPrismaConfig } from "@sidebase/base-config/prisma";
234
+ ```
235
+
236
+ `eslint.config.ts` is a scaffold, written once and then owned by your project, so streamctl
237
+ will not rewrite this for you. If you skip it, ESLint fails to load the config because the
238
+ old name no longer exists.
239
+
240
+ Check for any others:
241
+
242
+ ```sh
243
+ grep -rn "@sidestream-tech/nuxt-config\|createStreamctlEslint" . --exclude-dir=node_modules --exclude=pnpm-lock.yaml
244
+ ```
245
+
246
+ That command should return nothing when you are done.
247
+
248
+ `pnpm-lock.yaml` is excluded on purpose. It still names the old package until both new
249
+ packages are published and you can regenerate it, so leaving it in would make a correctly
250
+ migrated repository fail its own check. Do not hand-edit the lockfile to make hits go away.
251
+
252
+ ### 6. Decide how you own `pnpm-workspace.yaml`, BEFORE you sync
253
+
254
+ Do this first. Once sync has adopted the file, the content you need is gone.
255
+
256
+ `pnpm-workspace.yaml` is managed as a WHOLE FILE, not a block, so adopting it replaces
257
+ everything in it. This step applies to a fresh `streamctl init` just as much as to a
258
+ migration; the only difference is that a fresh repository may have nothing to lose.
259
+
260
+ Look at what yours currently holds:
261
+
262
+ ```sh
263
+ cat pnpm-workspace.yaml
264
+ ```
265
+
266
+ If the file does not exist, there is nothing to preserve. Skip to step 7 and let sync
267
+ create the managed one. Otherwise take exactly one of the two branches below.
268
+
269
+ **Branch A: your file declares a real workspace.** That means a `packages:` key with
270
+ entries in it, or an `ignoredBuiltDependencies` key, or any other pnpm setting the payload
271
+ does not render. A real example, from a repository that would have lost its monorepo:
272
+
273
+ ```yaml
274
+ packages:
275
+ - modules/*
276
+
277
+ ignoredBuiltDependencies:
278
+ - puppeteer
279
+
280
+ onlyBuiltDependencies:
281
+ - '@parcel/watcher'
282
+ # ...nine more
283
+ ```
284
+
285
+ Opt the file out entirely:
286
+
287
+ ```ts
288
+ files: { "pnpm-workspace.yaml": "off" },
289
+ ```
290
+
291
+ Do NOT use the `pnpm.onlyBuiltDependencies` knob for this case. The managed file hardcodes
292
+ `packages: []`, so adopting it deletes your workspace definition and every setting the
293
+ payload does not know about, and the knob only carries the build allowlist back. A real
294
+ monorepo that adopts this file stops being a monorepo. Opting out preserves the workspace,
295
+ the ignore lists, and the full build allowlist in one move.
296
+
297
+ The cost of opting out is that you no longer receive the supply-chain cooldown or any
298
+ future change to this file. If you want the cooldown, copy these into your own file and
299
+ maintain them yourself:
300
+
301
+ ```yaml
302
+ minimumReleaseAge: 10080
303
+ minimumReleaseAgeExclude:
304
+ - "@sidebase/*"
305
+ ```
306
+
307
+ **Branch B: your file is only an `onlyBuiltDependencies` list.** Adopt the managed file and
308
+ carry your approvals across. `onlyBuiltDependencies` is the allowlist of packages permitted
309
+ to run install lifecycle scripts, and the payload's baseline is just three entries:
310
+ `@prisma/client`, `esbuild`, and `prisma`. Everything else you have is project-specific and
311
+ must be re-added, or it is dropped.
312
+
313
+ Record the list first:
314
+
315
+ ```sh
316
+ sed -n '/onlyBuiltDependencies:/,/^[^ -]/p' pnpm-workspace.yaml
317
+ ```
318
+
319
+ Then add the non-baseline entries to `streamctl.config.ts`. The knob is additive on top of
320
+ the baseline, so list only the extras:
321
+
322
+ ```ts
323
+ pnpm: {
324
+ onlyBuiltDependencies: ["@parcel/watcher", "@prisma/engines", "@tailwindcss/oxide", "sharp", "unrs-resolver", "vue-demi"],
325
+ },
326
+ ```
327
+
328
+ Those six are a real example, from a repository that had approved nine and would have been
329
+ left with three. Use the knob rather than `pnpm approve-builds`, which writes straight into
330
+ the managed file and is reverted on the next sync.
331
+
332
+ **Why this is worth doing before anything else: losing an approval is silent.** Nothing
333
+ warns you. `streamctl check` stays green, the file looks deliberate, and a package whose
334
+ install scripts are blocked still installs.
335
+
336
+ Be precise about the consequence, because it is narrower than it first appears. The usual
337
+ suspects (`sharp`, `@tailwindcss/oxide`, `unrs-resolver`, `@parcel/watcher`) ship their
338
+ native binary as a prebuilt optional dependency, so on a platform with a prebuild they keep
339
+ working whether or not they are approved, cold install included. Verified across pnpm
340
+ 10.28.1, 10.29.1 and 10.29.3. Where the loss is real:
341
+
342
+ - architectures with no prebuild, where the binary genuinely has to be compiled
343
+ - packages whose postinstall does essential work that is not a native build
344
+
345
+ So this is not usually a broken CI on day one. It is an unrequested, unannounced change to
346
+ a config your project owns, whose cost lands later and somewhere else. Re-add the entries.
347
+
348
+ ### 7. Sync, then install
349
+
350
+ ```sh
351
+ pnpm streamctl sync
352
+ pnpm install
353
+ ```
354
+
355
+ **Expect sync to stop the first time, without writing anything.** On any repository
356
+ migrating from `@sidestream-tech/nuxt-config` it reports something like:
357
+
358
+ ```
359
+ [!] adoption (4) pre-existing file(s) streamctl now manages
360
+ tsconfig.json - pre-existing file streamctl now manages; adopting is expected
361
+ .dockerignore - pre-existing file streamctl now manages; adopting is expected
362
+ pnpm-workspace.yaml - pre-existing file streamctl now manages; adopting is expected
363
+ Dockerfile - pre-existing file streamctl now manages; adopting is expected
364
+ -> `sync --interactive` to adopt per file, `sync --force` to take ownership, or `--only <glob>` to scope
365
+
366
+ 4 conflict(s) pending; see the per-kind guidance above.
367
+ ```
368
+
369
+ This is not a failure and it is not something to route around. A fully-managed file whose
370
+ on-disk content differs from what the payload would write becomes an adoption conflict, and
371
+ the managed content changed substantially between the old payload and this one, so every
372
+ migrating repository hits it. The stop is the CLI refusing to overwrite files it has not
373
+ been told it owns.
374
+
375
+ Resolve it deliberately, in this order:
376
+
377
+ 1. Finish step 6 first if you have not. `pnpm-workspace.yaml` is in that conflict list, and
378
+ adopting it is exactly what discards your build approvals, or your whole workspace
379
+ definition if you are a monorepo. If you took branch A and opted the file out, it will
380
+ not appear in the conflict list at all.
381
+ 2. Adopt. Only `--interactive` and `--force` actually resolve a conflict; without one of
382
+ them the command repeats the same message and writes nothing.
383
+ - `pnpm streamctl sync --interactive` walks the files one at a time. Best if you are not
384
+ sure what your repository has customised.
385
+ - `pnpm streamctl sync --force` takes ownership of all of them at once. Only reach for
386
+ this once you have read what it will overwrite, because it is what silently replaces
387
+ your `onlyBuiltDependencies` list, and your `packages:` entries with it.
388
+ - `--only <glob>` scopes WHICH files are considered; it does not adopt anything by
389
+ itself. Combine it with `--force` to take one file at a time and read each diff
390
+ separately, which is the reviewable middle ground if you have no terminal for
391
+ `--interactive`:
392
+
393
+ ```sh
394
+ pnpm streamctl sync --force --only 'Dockerfile'
395
+ git diff Dockerfile
396
+ ```
397
+
398
+ One more thing `--force` does: it overrides the refusal to sync when streamctl-owned
399
+ files have uncommitted changes. Commit or stash your work first, so `git diff` after
400
+ each adoption shows only what sync did.
401
+ 3. `pnpm install` afterwards. It is separate and required, because `sync` edits
402
+ `package.json` but does not install. **Commit the regenerated `pnpm-lock.yaml` with the
403
+ rest of the migration**, then confirm it is actually usable:
404
+
405
+ ```sh
406
+ pnpm install --frozen-lockfile --ignore-scripts --offline
407
+ ```
408
+
409
+ That is what CI and the managed Dockerfile run. It fails before any network call, so it
410
+ needs no registry token. A stale lockfile fails here with `ERR_PNPM_OUTDATED_LOCKFILE`
411
+ (or `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH` if you also changed `pnpm.overrides`), long
412
+ before anyone sees it in CI.
413
+
414
+ Expect a large diff regardless: it carries every payload change since the adoption branch
415
+ was cut, including the `scripts.lint` reconcile and the renamed Dockerfile stage. If any
416
+ deploy workflow or compose file builds with `--target pro`, update it to `--target
417
+ production` now, because the final stage was renamed.
418
+
419
+ Read the diff before committing. The GitHub release notes for the version you pinned say
420
+ what changed and what each change requires of you.
421
+
422
+ ### 8. Reconcile `.editorconfig` by hand
423
+
424
+ **This is not migration-specific.** It happens to any repository that already has an
425
+ `.editorconfig` when the payload first manages one, including a fresh `streamctl init`. The
426
+ block writer appends its managed block without looking at what is already in the file, so
427
+ whatever was there stays above the block, duplicated or contradicted by it.
428
+
429
+ Check whether anything at all sits above the managed block:
430
+
431
+ ```sh
432
+ awk '/# BEGIN streamctl MANAGED BLOCK editorconfig/{exit} {print}' .editorconfig \
433
+ | command grep -qv '^[[:space:]]*\([#;].*\)\?$' && echo AFFECTED || echo clean
434
+ ```
435
+
436
+ Use `command grep`, not bare `grep`. Some shells (and some dotfiles) alias or wrap `grep`
437
+ with a tool that does not preserve `-q` exit codes, and this detector reads that exit code
438
+ as its entire answer. A wrapped `grep` can return `clean` on an affected file, which is the
439
+ same silent miss the step exists to catch.
440
+
441
+ `clean` means there is nothing to do. `AFFECTED` means which fix you need depends on whether
442
+ your existing settings AGREE with the payload's. Paste this helper, then run it:
443
+
444
+ ```sh
445
+ # Flatten an .editorconfig into "section<TAB>key = value" pairs. Comparing whole
446
+ # lines is not enough: sorting separates a setting from its section header, and a
447
+ # header that also appears in the managed block gets subtracted away, so a value
448
+ # can be reported under the wrong section.
449
+ ec_pairs() {
450
+ awk '
451
+ /^[[:space:]]*[#;]/ { next }
452
+ /^[[:space:]]*$/ { next }
453
+ /^[[:space:]]*\[/ { s=$0; gsub(/^[[:space:]]+|[[:space:]]+$/,"",s); next }
454
+ { l=$0; gsub(/^[[:space:]]+|[[:space:]]+$/,"",l)
455
+ print (s==""?"(preamble)":s) "\t" l }
456
+ ' | sort -u
457
+ }
458
+
459
+ BLOCK=$(mktemp)
460
+ sed -n '/# BEGIN streamctl MANAGED BLOCK editorconfig/,/# END streamctl MANAGED BLOCK editorconfig/p' \
461
+ .editorconfig | sed '1d;$d' > "$BLOCK"
462
+
463
+ # Every setting above the block that the block does not already provide, either
464
+ # under the same section or globally via [*].
465
+ awk -F'\t' 'NR==FNR{star[$0];next} !($2 in star)' \
466
+ <(ec_pairs < "$BLOCK" | awk -F'\t' '$1=="[*]"{print $2}') \
467
+ <(comm -23 \
468
+ <(awk '/# BEGIN streamctl MANAGED BLOCK editorconfig/{exit} {print}' .editorconfig | ec_pairs) \
469
+ <(ec_pairs < "$BLOCK"))
470
+ ```
471
+
472
+ Every line it prints carries its own section, so there is never any doubt about where a
473
+ setting belongs:
474
+
475
+ ```
476
+ [*.md] indent_size = 4
477
+ ```
478
+
479
+ Do not test this by counting `root = true`. That key is conventional, not required, and a
480
+ pre-existing file without it still leaves the count at `1` after sync while its settings are
481
+ being silently overridden. The `awk` above the block is deliberate for a related reason: a
482
+ `sed` end-regex is searched from the line AFTER the start, so if the marker lands on line 1
483
+ the range never closes and the whole file is printed. (The `sed` range that extracts the
484
+ block is safe, because `# BEGIN` cannot be the last line of a well-formed file.)
485
+
486
+ **If it prints nothing, your copy is redundant.** Delete everything ABOVE the
487
+ `# BEGIN streamctl MANAGED BLOCK editorconfig` line, keeping the managed block and anything
488
+ below the `# END` line that your project genuinely owns.
489
+
490
+ **If it prints anything, do NOT just delete your copy: you would be throwing away a real
491
+ preference.** Recreate each printed line below the `# END` marker, under the section header
492
+ printed beside it, then delete the rest of the pre-block content. Order matters here, and it
493
+ is the whole reason the payload wants project overrides underneath: EditorConfig applies
494
+ later matching sections over earlier ones, so a section below the block wins and a section
495
+ above it loses.
496
+
497
+ A repository with `indent_size = 4` above the block, against the payload's `indent_size = 2`
498
+ inside it, is silently switched to 2. Nothing warns, and the file still contains the 4 you
499
+ wrote, which makes it look intentional. The result should be:
500
+
501
+ ```
502
+ # BEGIN streamctl MANAGED BLOCK editorconfig
503
+ ...payload settings, including indent_size = 2...
504
+ # END streamctl MANAGED BLOCK editorconfig
505
+
506
+ [*]
507
+ indent_size = 4
508
+ ```
509
+
510
+ Either way, finish by re-running the `AFFECTED`/`clean` detector above. It should now say
511
+ `clean`.
512
+
513
+ **Also check your pre-adoption `.editorconfig`, not only the current one.** If this
514
+ repository already synced the payload at some earlier version, and that version managed
515
+ `.editorconfig` with the `full` strategy rather than `block`, your settings were replaced
516
+ wholesale instead of being left above the block. The detector then reports `clean` while a
517
+ preference is already gone, because the current file no longer contains the evidence. Run
518
+ the same comparison against the version from before the first sync:
519
+
520
+ ```sh
521
+ git show origin/main:.editorconfig > /tmp/ec-before # or the commit before your first sync
522
+ awk -F'\t' 'NR==FNR{star[$0];next} !($2 in star)' \
523
+ <(ec_pairs < .editorconfig | awk -F'\t' '$1=="[*]"{print $2}') \
524
+ <(comm -23 <(ec_pairs < /tmp/ec-before) <(ec_pairs < .editorconfig))
525
+ ```
526
+
527
+ Note this one compares against the WHOLE current file, not just `$BLOCK`, so it also sees
528
+ the overrides you have already put below `# END`. That makes it self-verifying: it goes
529
+ empty once you have restored everything, whereas a block-only comparison would keep
530
+ reporting your own fix back at you forever.
531
+
532
+ Anything it prints was lost earlier and should be restored below `# END` the same way. A
533
+ real case: a repository whose old file set `[*.md] indent_size = 4` had it replaced by a
534
+ block that sets only `trim_trailing_whitespace` for `[*.md]`, so its markdown silently
535
+ started following the `[*]` value of 2.
536
+
537
+ **`streamctl check` exits 0 either way, so it will not catch this for you.** The block
538
+ strategy only inspects its own markers, and the block is present and correct; anything
539
+ outside it is invisible to the check. That is why this is a manual step rather than
540
+ something drift detection reports.
541
+
542
+ ### 9. Clean up what the payload no longer manages
543
+
544
+ Two different kinds of leftover, with the same cause: streamctl manages by path, so
545
+ anything it managed under an old path or an old payload is simply dropped from the
546
+ managed set rather than removed. `check` reports clean either way, because it only
547
+ inspects paths that are currently managed. Nothing will ever tell you these are there.
548
+
549
+ #### 9a. The two orphaned workflow files
550
+
551
+ Do this only if your repository synced the payload before the workflow extensions were
552
+ normalized, which is the case for every adoption branch cut before this release. Check:
553
+
554
+ ```sh
555
+ ls .github/workflows/
556
+ ```
557
+
558
+ If you see `ci.yaml` or `pr-preview-cleanup.yaml` sitting next to `ci.yml` and
559
+ `pr-preview-cleanup.yml`, remove the `.yaml` pair:
560
+
561
+ ```sh
562
+ git rm --ignore-unmatch .github/workflows/ci.yaml .github/workflows/pr-preview-cleanup.yaml
563
+ ```
564
+
565
+ `--ignore-unmatch` matters: the condition above is "either file", but `git rm` is atomic, so
566
+ naming a path that does not exist aborts with `fatal: pathspec ... did not match any files`
567
+ and removes NEITHER. A repository that opted one workflow out, or never enabled preview
568
+ cleanup, would skim that error and keep running both copies of the other.
569
+
570
+ This is required, not tidying. The payload renamed those two managed files from `.yaml` to
571
+ `.yml`. streamctl manages files by path, so the sync in step 7 wrote the new `.yml` files
572
+ but did NOT delete the old `.yaml` ones: they are simply no longer in the managed set.
573
+ GitHub Actions runs every file in `.github/workflows/`, so if you leave them the
574
+ repository runs BOTH copies. That means duplicate CI on every push, and two
575
+ `pr-preview-cleanup` jobs racing to tear down the same preview environment. The leftover
576
+ copies are also frozen forever, so they never receive later fixes, including security
577
+ updates to pinned action SHAs.
578
+
579
+ While you are here, if your config opts out of either workflow by its old path, for
580
+ example `files: { ".github/workflows/ci.yaml": "off" }`, re-key it to the `.yml` path.
581
+ An opt-out naming a path that is no longer managed does nothing, so the file you meant to
582
+ suppress would start arriving on the next sync.
583
+
584
+ #### 9b. Orphaned managed BLOCKS
585
+
586
+ The same problem in a harder-to-see form. A `block` file keeps its markers after the
587
+ payload stops managing that path, so the block sits there forever: never updated, never
588
+ reported, and indistinguishable from a block that is still live. The two `.yaml`
589
+ workflows above are the known orphaned FILES for this release; orphaned blocks are not
590
+ enumerable in advance, because they depend on which payload version your repository
591
+ adopted first.
592
+
593
+ Do not hardcode a list. Ask the repository instead: every file carrying a streamctl
594
+ marker, minus everything the payload currently manages.
595
+
596
+ ```sh
597
+ LC_ALL=C comm -23 \
598
+ <(git grep -lI "BEGIN streamctl MANAGED BLOCK" -- . | LC_ALL=C sort -u) \
599
+ <(pnpm streamctl status --json \
600
+ | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{
601
+ JSON.parse(s).data.files.forEach(f=>console.log(f.path));});' \
602
+ | LC_ALL=C sort -u)
603
+ # anything printed is an orphaned block
604
+ ```
605
+
606
+ **Do not add a comment prefix to that marker string.** streamctl picks the comment syntax
607
+ from the file extension, so the same block opens three different ways:
608
+
609
+ | Extension | Marker |
610
+ | --------- | ------ |
611
+ | `.js`, `.mjs`, `.cjs`, `.ts`, `.mts`, `.cts`, `.json`, `.jsonc`, `.json5` | `// BEGIN streamctl MANAGED BLOCK x` |
612
+ | `.md`, `.markdown`, `.html`, `.htm`, `.vue` | `<!-- BEGIN streamctl MANAGED BLOCK x -->` |
613
+ | everything else (`.editorconfig`, `.gitignore`, `.npmrc`, yaml, toml, ini, sh, Dockerfile) | `# BEGIN streamctl MANAGED BLOCK x` |
614
+
615
+ Anchoring on `# BEGIN ...`, which is what step 8 above uses because it is specifically
616
+ about `.editorconfig`, silently returns clean for an orphan in any `.md`, `.ts`, `.json`
617
+ or `.vue`. Seeded against a repository with one orphan in each of the three syntaxes:
618
+
619
+ ```
620
+ un-prefixed anchor (correct) -> .npmrc, NOTES.md, legacy.ts
621
+ # BEGIN ... (hash-anchored) -> .npmrc
622
+ ```
623
+
624
+ Both current block-strategy files are hash-syntax, so the wrong version gives the right
625
+ answer on every repository we have today and fails on the first one nobody has yet. The
626
+ whole point of this step is repositories migrating from OLDER payloads whose block files
627
+ cannot be enumerated.
628
+
629
+ `status` rather than `sync --dry-run`: it reports the managed set directly, including
630
+ files this repository has disabled with `files: { ... : "off" }` -- a disabled file is
631
+ still managed, and treating it as unmanaged would report it as an orphan. It also writes
632
+ nothing and prints no plan diff.
633
+
634
+ **Judge this by its OUTPUT, not its exit code.** `streamctl status --help` claims it
635
+ always exits 0; it does not -- it exits 1 when the payload is declared but not installed.
636
+ That state does not arise here, because step 7 installed two steps ago, but do not build
637
+ an `if` around the exit status.
638
+
639
+ `LC_ALL=C` on **all three** commands, not just the sorts. `comm` validates its input
640
+ against the collation of the locale it is running in, and the default UTF-8 collation
641
+ orders `AGENTS.md` before `.dockerignore` while `C` does the reverse. Mixing them makes
642
+ `comm` emit `file 2 is not in sorted order` and compare two orderings it cannot reconcile.
643
+
644
+ `git grep` searches tracked files only. That is the right scope here -- an orphaned block
645
+ is by definition pre-existing committed content -- but it means a block in an untracked
646
+ file will not be reported.
647
+
648
+ **What to do with a hit depends on what the block provides. Do not delete on sight.**
649
+
650
+ - **The block is still doing a job.** The registry block is the case that actually
651
+ occurs: a `@sidestream-tech:registry=...` line in `.npmrc` is no longer managed by this
652
+ payload, but if anything in the repository still resolves from that scope, deleting it
653
+ breaks `pnpm install` on the next cold run and the failure surfaces in CI rather than
654
+ here. Check before touching it:
655
+
656
+ ```sh
657
+ if [ ! -f package.json ] || [ ! -f pnpm-lock.yaml ]; then
658
+ echo "CANNOT TELL - a file this check reads is missing. Keep the block."
659
+ elif command grep -rn '@sidestream-tech/' package.json pnpm-lock.yaml; then
660
+ echo "IN USE - keep the block."
661
+ else
662
+ echo "UNUSED - safe to delete."
663
+ fi
664
+ ```
665
+
666
+ **The existence guard is the load-bearing part, not the grep.** A missing
667
+ `pnpm-lock.yaml` is not evidence that nothing uses the scope; it is evidence that the
668
+ question cannot be answered yet. Silencing the error with a bare
669
+ `2>/dev/null` produces empty output and routes straight to "nothing depends on it,
670
+ delete" -- the one action this section warns against, in exactly the situation that
671
+ makes the answer unknowable. Missing input must reach the "you cannot tell" branch
672
+ below, which keeps the block.
673
+
674
+ Note which way the unguarded version failed: `grep: pnpm-lock.yaml: No such file or
675
+ directory` on stderr reads as output, so it produced **keep**. That was wrong for the
676
+ wrong reason but landed on the safe action. Both states are defects; only one of them
677
+ breaks an install.
678
+
679
+ If you keep it, consider removing the streamctl markers around the block so it reads
680
+ as the repository's own content, which is now what it is -- the markers are the only
681
+ thing implying something else maintains it.
682
+
683
+ - **Nothing depends on it any more.** Delete the block, markers included. If the block
684
+ was the entire file, delete the file.
685
+
686
+ - **You cannot tell.** Leave it and note it in the PR. An orphaned block is inert; a
687
+ wrongly deleted one is a broken install. The asymmetry favours leaving it.
688
+
689
+ ### 10. Verify
690
+
691
+ ```sh
692
+ pnpm streamctl check
693
+ ```
694
+
695
+ Expected:
696
+
697
+ ```
698
+ streamctl check
699
+
700
+ [+] in sync all managed files match
701
+
702
+ all clean.
703
+ ```
704
+
705
+ Exit 0. Then confirm the migration actually reconciled, which `check` alone does not tell
706
+ you:
707
+
708
+ ```sh
709
+ grep -E '"(@prisma/client|eslint|jiti|prisma|typescript)"' package.json
710
+ grep -E '"(lint|postinstall)"' package.json
711
+ ```
712
+
713
+ The versions should match the `nuxt-4` baseline in
714
+ `node_modules/@sidebase/base-config/presets/nuxt-app/preset.json`, and `scripts.lint`
715
+ should now be the oxlint-then-eslint command. If `check` says clean but these did not
716
+ move, you are in the silent-failure state described above.
717
+
718
+ Finally, run the repository's own gates:
719
+
720
+ ```sh
721
+ pnpm lint
722
+ pnpm test
723
+ ```
724
+
725
+ ## If you keep a `file:` override after migrating
726
+
727
+ Step 0 covers removing a dead one. If you deliberately keep a local override, for example to
728
+ test an unreleased payload build, later `streamctl upgrade` runs need `--to <version>`
729
+ explicitly: with an override in place the CLI cannot resolve the latest release from the
730
+ registry and fails with `CONFIG_INVALID` asking for a target. It also refuses if the
731
+ override embeds a version different from the target, so repoint it first.
732
+
733
+ ## Two per-repo escapes
734
+
735
+ A migrating repository often needs one or both of these. Both go in `streamctl.config.ts`.
736
+
737
+ **Stop managing one file.**
738
+
739
+ ```ts
740
+ files: { "Dockerfile": "off" },
741
+ ```
742
+
743
+ Use this when the repository genuinely owns a file the payload also manages. The cost is
744
+ total: that file stops receiving every future payload fix, including security ones. Prefer
745
+ the `docker.*` knobs or the `.editorconfig` managed block over opting out, when the change
746
+ you need is only part of a file.
747
+
748
+ **Keep your own value for one reconciled key.**
749
+
750
+ ```ts
751
+ versionSyncExclude: ["scripts.lint"],
752
+ ```
753
+
754
+ Use this when the repository needs a different value for a specific key. `scripts.lint` is
755
+ the common one, because the reconcile is a plain overwrite with no version floor: every
756
+ sync replaces whatever you have. A repository that runs type-aware linting, for example,
757
+ keeps its own command this way.
758
+
759
+ ## Rollback
760
+
761
+ Nothing here is destructive if you work on a branch. `sync` rewrites tracked files, so
762
+ `git diff` shows everything it did and `git restore` undoes it. Keep the migration on its
763
+ own branch and its own commit so that if the sync diff turns out to be larger than
764
+ expected, reverting it is one operation rather than an archaeology exercise.