Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e84297b
feat(config): add config-file resolver for root and legacy locations
kboshold Jul 30, 2026
82d922b
test: pin the .config/ exclusion and the directory-config divergence
kboshold Jul 30, 2026
3b46ef4
fix(deps): require c12 >=3.2.0 for _configFile
kboshold Jul 30, 2026
144e1e1
test: note that the .config/.streamctl case guards a distinct regression
kboshold Jul 30, 2026
83a6899
feat(config): return the resolved location from the loader
kboshold Jul 30, 2026
ea20bdc
test: close the post-hoc-rejection blind spot in the .config/ exclusion
kboshold Jul 30, 2026
4c3435d
feat: read the config from either location in every command
kboshold Jul 30, 2026
b04490b
test: correct the console.warn attribution in the ambiguity test
kboshold Jul 30, 2026
0064ef4
feat(upgrade): snapshot and bump the config that was actually loaded
kboshold Jul 30, 2026
96322cf
test(upgrade): pin the CONFIG_INVALID path to the resolved config
kboshold Jul 30, 2026
700924f
feat(manifest): reserve the streamctl config paths from payload manag…
kboshold Jul 30, 2026
b907625
feat(init): scaffold streamctl.config.ts and guard both locations
kboshold Jul 30, 2026
38c449f
test: guard the manifest reservations against resolver drift
kboshold Jul 30, 2026
54503e5
fix(manifest): match reserved config paths case-insensitively
kboshold Jul 30, 2026
4121810
test: pin init's guard ordering and silent both-present failure
kboshold Jul 30, 2026
4aabe0a
refactor(init): bind the scaffold path to CONFIG_FILE
kboshold Jul 30, 2026
3d09e42
docs(test): record what captureStderr can and cannot see
kboshold Jul 30, 2026
a5926ea
test: move fixtures to the root config location
kboshold Jul 30, 2026
40e012a
test(e2e): scaffold and assert the root config in the distribution gate
kboshold Jul 30, 2026
16341f1
docs: point the config location at streamctl.config.ts
kboshold Jul 30, 2026
2b10b20
docs: record the measured .config/ probe results
kboshold Jul 30, 2026
1d67ab1
feat(config): warn when one location holds two config extensions
kboshold Jul 30, 2026
bd8db99
docs: document the extension-shadowing warning
kboshold Jul 30, 2026
ba448a5
test: assert the whole envelope, not a guessed field
kboshold Jul 30, 2026
da61184
test: correct what the tripwire comment claims about relocation
kboshold Jul 30, 2026
484e322
docs: name the property that keeps shadow selection safe
kboshold Jul 30, 2026
45ab1b0
test: pin the shadow warning as descriptive, not prescriptive
kboshold Jul 30, 2026
4a26103
docs: correct what the tripwire comment claims about relocation
kboshold Jul 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ Because streamctl works entirely on local files (the payload is read from `node_

- The exact command you ran and its full output, ideally with `--json` (e.g. `pnpm streamctl sync --dry-run --json`)
- The output of `pnpm streamctl status --json`
- The payload package name and its pinned `version` from `.streamctl/config.ts`
- The payload package name and its pinned `version` from `streamctl.config.ts` (or
`.streamctl/config.ts`, if the repo is on the legacy location)

### Opening an issue

Expand Down
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug-report.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ body:
id: bug-payload
attributes:
label: Payload
description: The payload package name and the pinned `version` from `.streamctl/config.ts` (plus any relevant config knobs)
description: The payload package name and the pinned `version` from your streamctl config -- `streamctl.config.ts`, or `.streamctl/config.ts` on the legacy location (plus any relevant config knobs)
placeholder: "@your-org/config @ 1.2.3"
validations:
required: true
Expand Down
34 changes: 29 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ pnpm streamctl upgrade # move the pinned version forward, re-sync

### `streamctl init`

Wire a repo to a payload for the first time. Reads the payload's manifest to pick the base preset and auto-detect the profile (the manifest ships the detection probes, so the CLI has no framework knowledge), scaffolds `.streamctl/config.ts` from the payload's own template, adds the devDependencies, runs the install, then runs the first `sync`.
Wire a repo to a payload for the first time. Reads the payload's manifest to pick the base preset and auto-detect the profile (the manifest ships the detection probes, so the CLI has no framework knowledge), scaffolds `streamctl.config.ts` from the payload's own template, adds the devDependencies, runs the install, then runs the first `sync`.

| Flag | Type | Effect |
| ---- | ---- | ------ |
Expand Down Expand Up @@ -153,7 +153,7 @@ pnpm streamctl status --outdated --json | jq '.data.files'

### `streamctl upgrade`

The only command that moves the pinned payload version forward. Before it touches anything it snapshots `.streamctl/config.ts`, `package.json` and the lockfile, and any failure along the way (a bad install, an invalid new payload, a conflict) restores all three byte-for-byte. The [reference](docs/reference.md#the-upgrade-transaction) covers the full transaction, including upgrading over a local `file:` override.
The only command that moves the pinned payload version forward. Before it touches anything it snapshots the config file, `package.json` and the lockfile, and any failure along the way (a bad install, an invalid new payload, a conflict) restores all three byte-for-byte. The [reference](docs/reference.md#the-upgrade-transaction) covers the full transaction, including upgrading over a local `file:` override.

| Flag | Type | Effect |
| ---- | ---- | ------ |
Expand Down Expand Up @@ -194,7 +194,7 @@ Structured files (`.json*`, `.ya?ml`) get extra safety: composed output is parse

## Configuration

`init` scaffolds `.streamctl/config.ts` from the payload's own template. The payload exports a typed define function (via its `./config` subpath), so the knobs get full editor inference:
`init` scaffolds `streamctl.config.ts` at the repo root, from the payload's own template. The payload exports a typed define function (via its `./config` subpath), so the knobs get full editor inference:

```ts
import { defineConfig } from "@your-org/config/config";
Expand All @@ -215,6 +215,30 @@ export default defineConfig({

A payload that ships no `config.template.ts` gets a generic fallback that imports the CLI's own `defineStreamctlConfig` from `@sidebase/streamctl` (same fields, minus the typed knobs).

### Where the config lives

Exactly two locations are read, and no others:

1. `streamctl.config.ts` at the repo root — the default, and what `init` writes.
2. `.streamctl/config.<ext>` — the original location, read **permanently**. It is not
deprecated, there is no warning, and there is no plan to remove it.

Nothing under `.config/` is read. Both locations accept any extension c12 supports
(`.ts`, `.js`, `.mjs`, `.json`, `.yaml`, …).

If one location holds two of them, **`.js` wins over `.ts`** — that is c12's own
precedence order, and it is the reverse of what most people expect, so `streamctl`
warns on stderr that the `.ts` is *shadowed by* the `.js`, naming the file it actually
read. Only the location in use is checked: if a root config exists, a collision inside
`.streamctl/` is not reported, because nothing there is read either way.

Moving an existing config is a plain `git mv .streamctl/config.ts streamctl.config.ts`
and nothing else — every command behaves identically either way. `status` is the case
held to that by test: `test/status.command.test.ts` runs it against one repo in both
layouts and asserts the two reports are equal, down to the absence of any trace of which
file was read. If both files exist the root one wins and `streamctl` says so
on stderr once; delete the legacy file to silence it.

## CI setup

Add the gate to your pipeline; exit `3` means the tree drifted from the payload:
Expand All @@ -232,10 +256,10 @@ The probe degrades quietly. If the registry cannot be reached, `check` skips the

| Symptom | Likely cause | Fix |
| ------- | ------------ | --- |
| `NOT_INITIALIZED` | no `.streamctl/config.ts` | run `streamctl init` first |
| `NOT_INITIALIZED` | no `streamctl.config.ts` (and no legacy `.streamctl/config.ts`) | run `streamctl init` first |
| `CONFIG_PKG_MISSING` | the payload package is not installed | `pnpm install` |
| `CONFIG_VERSION_MISMATCH` | installed payload version differs from the pinned `version` | `streamctl upgrade` or `pnpm install` |
| `CONFIG_INVALID` | bad `.streamctl/config.ts`, malformed `preset.json`/`package.json`, or an invalid knob | fix the offending file/value (the `details.path` names it) |
| `CONFIG_INVALID` | bad `streamctl.config.ts`, malformed `preset.json`/`package.json`, or an invalid knob | fix the offending file/value (the `details.path` names it) |
| exit `2` (`CONFLICTS_PENDING`) | a `full` file you edited, a marker/merge/structural fault, or a dirty owned path | review the plan; `sync --interactive` to confirm, or `--force` to accept |
| exit `3` (`DRIFT_DETECTED`) | the working tree drifted from the payload (CI gate) | run `streamctl sync` and commit |
| `REGISTRY_AUTH_FAILED` | cannot read the payload from GitHub Packages | check the token's `read:packages` scope |
Expand Down
8 changes: 4 additions & 4 deletions docs/adoption.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ pnpm dlx @sidebase/streamctl init --package @your-org/config --yes
`init` detects the Nuxt major from `package.json` and proposes `base: "nuxt-app"`
+ `profile: "nuxt-4"`. It then:

- writes `.streamctl/config.ts` (the pinned `version` + your knobs),
- writes `streamctl.config.ts` at the repo root (the pinned `version` + your knobs),
- scaffolds the `.npmrc` registry block (incl. `always-auth=true`),
- adds the `@sidebase/streamctl` + your payload (`@your-org/config`) + `jiti`
devDependencies and runs the install (so the preset payload lands on disk),
Expand Down Expand Up @@ -120,7 +120,7 @@ script is not a semver), so it replaces whatever the repo has — opt out per-ke
`versionSyncExclude`. Everything the baseline does not list (`vue`, `tailwindcss`,
app deps, your own scripts) is project-owned and never touched.

Disable globally or per-key in `.streamctl/config.ts`:
Disable globally or per-key in `streamctl.config.ts`:

```ts
export default {
Expand All @@ -134,7 +134,7 @@ export default {

When a single repo has to hold one pin back, reach for `versionSyncExclude` rather
than turning `versionSync` off wholesale, and record why (a comment in
`.streamctl/config.ts` or the PR description). An exclude entry that is not an
`streamctl.config.ts` or the PR description). An exclude entry that is not an
active allow-list key is rejected with `CONFIG_INVALID`.

## 5. `upgrade` (moving the pin forward)
Expand All @@ -149,7 +149,7 @@ pnpm streamctl upgrade --dry-run # resolve target + intended bumps, write nothi

It resolves the target first (`NO_NEWER_VERSION` if you are already on the latest,
`TARGET_NOT_FOUND` if `--to` names an unpublished version), bumps the
`.streamctl/config.ts` pin and the payload devDep in lockstep, runs the install,
config-file pin and the payload devDep in lockstep, runs the install,
then runs `sync`, interactive by default. Review the diff and commit. A `--dry-run`
issued before the new presets are installed prints `preview unavailable:
<version> presets not installed` instead of a misleading empty plan.
Expand Down
8 changes: 4 additions & 4 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ The dirty-tree guard: if a streamctl-owned path has uncommitted tracked edits, `

## How `init` picks the payload version

The `.streamctl/config.ts` `version` pin is the payload's version, never the CLI's. The two
The `streamctl.config.ts` `version` pin is the payload's version, never the CLI's. The two
packages release on their own cadence. `init` resolves the pin in this order, before it
writes anything:

Expand Down Expand Up @@ -100,7 +100,7 @@ When the version reconcile edits `package.json` (non-`--dry-run`) and a lockfile

`upgrade` is the only command that moves the pinned version forward. Either it applies in full, or it puts the repo back exactly as it was.

The flow runs in this order. Resolve the target, which is the latest published version or whatever `--to` names. Snapshot the three files a failed run could leave inconsistent: `.streamctl/config.ts`, `package.json`, and the detected package manager's lockfile. Bump the pin and the payload devDependency, leaving the CLI's own version alone. Install, so the new bundled presets land on disk. Finally, preflight and apply the first `sync` against the new version.
The flow runs in this order. Resolve the target, which is the latest published version or whatever `--to` names. Snapshot the three files a failed run could leave inconsistent: the config file (wherever it resolved), `package.json`, and the detected package manager's lockfile. Bump the pin and the payload devDependency, leaving the CLI's own version alone. Install, so the new bundled presets land on disk. Finally, preflight and apply the first `sync` against the new version.

The preflight comes for free from `sync` being transactional. It composes and validates the whole batch and writes nothing until the batch is clean, so a non-interactive conflict or an invalid new payload throws before any managed file changes.

Expand Down Expand Up @@ -141,8 +141,8 @@ Local-tarball adoption skips `init`'s registry probe. If a `pnpm.overrides` or r

**Payload content that changes under the same version shows up as an `edit` conflict.** Drift is derived and there is no state file, so sync cannot tell a repacked local tarball apart from a local edit. It blocks and asks for review (`--interactive` or `--force`). Fleet updates are better carried by a version bump and `streamctl upgrade`, where the preflight previews the change for you.

Config changes hit the same wall. Edit a `.streamctl/config.ts` knob that feeds a `full`-strategy render (placeholders, fragment toggles) and the next `sync` reports the render delta as `edit`/`adoption` conflicts and exits `2`. Resolve with `sync --interactive`, `sync --force` or `--only <glob>`. `block`/`merge` reconciles are unaffected. A rendered-content baseline would remove this friction, and may show up later.
Config changes hit the same wall. Edit a `streamctl.config.ts` knob that feeds a `full`-strategy render (placeholders, fragment toggles) and the next `sync` reports the render delta as `edit`/`adoption` conflicts and exits `2`. Resolve with `sync --interactive`, `sync --force` or `--only <glob>`. `block`/`merge` reconciles are unaffected. A rendered-content baseline would remove this friction, and may show up later.

**The shipped ESLint wrapper is not a drop-in for a complex repo.** A bare `createStreamctlEslint()` lints everything, scratch and artifact directories included. Real adopters chain their own ignores onto it: `createStreamctlEslint().append({ ignores: ["scratch/**", ".streamctl/**", /* ... */] })`.
**The shipped ESLint wrapper is not a drop-in for a complex repo.** A bare `createStreamctlEslint()` lints everything, scratch and artifact directories included. Real adopters chain their own ignores onto it: `createStreamctlEslint().append({ ignores: ["scratch/**", /* ... */] })`.

The Node `engines` floor is inherited. `^22.22.2 || ^24.15.0 || >=26.0.0` copies `write-file-atomic@8`'s own `engines` requirement verbatim, because that atomic-write dependency is what sets the real floor. Relaxing streamctl's range below it would just move the install warning down to the dependency.
44 changes: 39 additions & 5 deletions docs/release.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
# Release runbook (`@sidebase/streamctl`)

> **Status: PARKED.** Nothing is published yet. The `Release` workflow
> (`.github/workflows/release.yml`) is a `workflow_dispatch`-only draft gated
> behind the protected `release` environment. It cannot publish anything until a
> maintainer completes the one-time setup below and dispatches it by hand.
> **Status: 0.1.0 is on the registry.** The `Release` workflow
> (`.github/workflows/release.yml`) stays `workflow_dispatch`-only and gated behind
> the protected `release` environment, so every publish is a deliberate manual
> dispatch by a maintainer who has completed the one-time setup below.

`streamctl` publishes to the public npm registry under the `@sidebase` scope. It
is an intentionally ESM-only package; the published tarball ships only `dist/`.
Expand All @@ -19,7 +19,7 @@ What couples the CLI to a payload is the payload manifest's integer
- If the running CLI does not support a payload's `schemaVersion`, the user gets a
dedicated "payload requires a newer/older streamctl" error rather than a generic
`CONFIG_INVALID`. The loader leaves room for per-version migrations later.
- The `.streamctl/config.ts` `version` pin governs the payload package only, and
- The config file's `version` pin governs the payload package only, and
`CONFIG_VERSION_MISMATCH` compares the installed payload against that pin.
`upgrade` moves the payload pin and its devDep, and leaves the CLI version alone.

Expand Down Expand Up @@ -66,6 +66,40 @@ approve the environment gate. The workflow:
After the run, verify the published tarball on npm, the `vX.Y.Z` tag, and the
generated GitHub Release notes.

## Notes for the next release

Include these in the release notes; the rest is generated from commit subjects.

- **The config file's default location moved** to `streamctl.config.ts` at the repo
root. `init` writes it there.
- **`.streamctl/config.*` keeps working, permanently.** Not deprecated, no warning,
no removal planned. Existing repos need to do nothing. A repo that *does* move its
config needs this CLI version or newer.
- **One breaking edge:** a config at `.config/.streamctl/config.ts` resolved before
this release and does not now — it raises `NOT_INITIALIZED`. Measured against
c12 3.3.4: the old `configFile: ".streamctl/config"` spelling made c12 probe
`.config/.streamctl/config`, and the new spelling does not. The form is
undocumented and nested, so realistically nobody is on it, but the fix is one
command:

```sh
git mv .config/.streamctl/config.ts streamctl.config.ts
```

Nothing else under `.config/` is read by streamctl, before or after this release.

The affected population is narrower than it reads: below c12 3.2.0 there is no
`_configFile`, so the old loader raised `NOT_INITIALIZED` from any location. A
repo on this layout was only ever working if its tree resolved c12 >= 3.2.0.
The `git mv` is worth doing either way, so the instruction above is not
conditional on that.
- **New warning:** two extensions of the same config at one location
(`streamctl.config.js` next to `streamctl.config.ts`) now warn on stderr that one is
*shadowed by* the other, naming the one being read. c12's order puts `.js` ahead of
`.ts`, which surprises most people. **Nothing is read differently than before** — this
is a new diagnostic, not new behaviour, so a repo that sees it needs no migration.
- Minor bump: new default, no removals.

Use Conventional Commit subjects (and `!` / `BREAKING CHANGE:` for anything that
moves the `--json` envelope, exit codes, or the manifest `schemaVersion`) so the
history reads clearly for consumers.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
"prepublishOnly": "pnpm build"
},
"dependencies": {
"c12": "^3.0.0",
"c12": "^3.2.0",
"citty": "^0.2.2",
"defu": "^6.1.4",
"diff": "^9.0.0",
Expand Down
2 changes: 1 addition & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading