Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ Read `REVIEW.md` at the repository root and apply it in full as the review rules

## Plugins

Follow the [plugin guide](docs/manual/plugins.md). Place bundled plugins under `plugins/<name>/` and compose ordered defaults only in root `cli.ts`; keep `src/` feature-neutral and keep plugin module graphs out of private `src/` implementation. Import `@fx/tx/plugin` and every published capability contract (`@fx/tx/theme`, `@fx/tx/theme-override`, `@fx/tx/grid`) type-only — a published specifier is also the only way one bundled plugin may name another's vocabulary — add Bun tests for observable behavior, preserve required coverage, and run `bun run check`.
Follow the [plugin guide](docs/manual/plugins.md). Place bundled plugins under `plugins/<name>/` and compose ordered defaults only in root `cli.ts`; keep `src/` feature-neutral and keep plugin module graphs out of private `src/` implementation. Import `@fx/tx/plugin` and every published capability contract (`@fx/tx/config`, `@fx/tx/theme`, `@fx/tx/theme-override`, `@fx/tx/grid`) type-only — a published specifier is also the only way one bundled plugin may name another's vocabulary — add Bun tests for observable behavior, preserve required coverage, and run `bun run check`.
2 changes: 1 addition & 1 deletion REVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ DNS-rebinding protection stays on instead: `server.allowedHosts` is the `.ts.net

Use the [plugin guide](docs/manual/plugins.md) as the practical reference. For plugin changes, verify:

- Core and plugin ownership boundaries remain intact, including type-only use of `@fx/tx/plugin` and of every published capability contract (`@fx/tx/theme`, `@fx/tx/theme-override`, `@fx/tx/grid`). A bundled plugin may name another bundled plugin's vocabulary only through such a published specifier, never through a relative path into its directory; no module under `src/` may import one at all.
- Core and plugin ownership boundaries remain intact, including type-only use of `@fx/tx/plugin` and of every published capability contract (`@fx/tx/config`, `@fx/tx/theme`, `@fx/tx/theme-override`, `@fx/tx/grid`). A bundled plugin may name another bundled plugin's vocabulary only through such a published specifier, never through a relative path into its directory; no module under `src/` may import one at all.
- A failed plugin contributes nothing and does not block healthy plugins.
- Marketplace plugin names are unique and safe; configured entries are non-empty repository-relative regular files contained after resolution.
- React and Ink come from injected dependencies rather than separate runtime imports.
Expand Down
14 changes: 7 additions & 7 deletions docs/changes/0031-publish-the-dialogs-and-config-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,13 @@ The cleanup is the larger half. The sites are found by searching for the declara

## Tasks

- [ ] Publish the config contract
- [ ] Rename the `config` key to `@fx/tx/config` at every provider, consumer, demo, and test naming it, in the same commit that publishes the contract
- [ ] `plugins/config/contract.ts` declaring `Config` and `ConfigValidator` as types alone
- [ ] Have `plugins/config/index.ts` import it type-only and register a value checked against `Config`
- [ ] Reduce `plugins/marketplace/configured.ts` to importing the published contract, keeping `requireConfigCapability` and the marketplace's own key and value types
- [ ] `exports` entry `./config`, its `files` entries, the packed-file assertion, and the consumer-fixture import
- [ ] `test/config-plugin.test.ts` and `test/marketplace-plugin.test.ts` import the published contract; the latter declares its own `ConfigValidator` today
- [x] Publish the config contract
- [x] Rename the `config` key to `@fx/tx/config` at every provider, consumer, demo, and test naming it, in the same commit that publishes the contract
- [x] `plugins/config/contract.ts` declaring `Config` and `ConfigValidator` as types alone
- [x] Have `plugins/config/index.ts` import it type-only and register a value checked against `Config`
- [x] Reduce `plugins/marketplace/configured.ts` to importing the published contract, keeping `requireConfigCapability` and the marketplace's own key and value types
- [x] `exports` entry `./config`, its `files` entries, the packed-file assertion, and the consumer-fixture import
- [x] `test/config-plugin.test.ts` and `test/marketplace-plugin.test.ts` import the published contract; the latter declares its own `ConfigValidator` today
- [ ] Publish the dialogs contract
- [ ] Rename the `dialogs` key to `@fx/tx/dialogs` at every provider, consumer, demo, and test naming it, in the same commit that publishes the contract
- [ ] `plugins/dialogs/contract.ts` declaring the request, option, field, filter, expand, and result types and `Dialogs`, as types alone
Expand Down
18 changes: 7 additions & 11 deletions docs/manual/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,19 +320,15 @@ This is not a dependency-injection or lifecycle container. There are no schemas,

## Use the bundled config capability

The namespace-free bundled config provider registers one internal capability under the exact opaque key `config`. It persists small JSON values across invocations in one per-user document. Its local structural shape is:
The namespace-free bundled config provider registers one capability under the exact opaque key `@fx/tx/config`. It persists small JSON values across invocations in one per-user document. Import the contract from that same key rather than restating it — the string you read the capability from and the string you import its shape from are one string, so a contract that moves fails your build rather than your command:

```ts
type ConfigValidator<T> = (value: unknown) => value is T

type Config = {
define<T>(key: string, isValid: ConfigValidator<T>): void
read<T>(key: string): Promise<T | undefined>
write<T>(key: string, value: T): Promise<void>
}
import type { Config, ConfigValidator } from "@fx/tx/config";
```

A bundled consumer declares that compatible type locally and reads `registrations<Config>("config")` inside its command action, after initialization has committed every provider. The shape stays an implementation detail for bundled plugins; it is not a public export from `@fx/tx/plugin`.
`Config` is what the key carries: `define(key, isValid)`, `read(key)` resolving the persisted value or `undefined`, and `write(key, value)`. A `ConfigValidator<T>` is your own `(value: unknown) => value is T`, which is the whole of what makes a read safe — the capability never infers one, because only the consumer knows what its key means. Every member is documented where it is declared, and the contract is types only: it publishes no way to obtain a store, which stays the registry's job, and nothing behind the import is a file, a path, or a document format.

A consumer reads `registrations<Config>("@fx/tx/config")` inside its command action, after initialization has committed every provider. What a read answering none, or more than one, means is the consumer's own decision: the contract types the values under the key and neither supplies one, selects among them, nor rejects one. The marketplace plugin decides it strictly — `requireConfigCapability` treats both counts as errors naming the count, with no fallback — because the config plugin is composed by default, so a `tx` without it is misconfigured rather than degraded. A consumer with a reason to tolerate an absent provider is free to make the opposite choice.

Call `define` once for each key before reading or writing it in the current process. Keys are opaque and compared exactly: tx does not trim, normalize, parse, namespace, or reserve them. A second definition of the same key is rejected and leaves the first guard in force. An absent property reads as `undefined`; a present value and every value being written must pass that key's guard. A rejected read affects no other key, and a rejected write changes nothing on disk. Values use JSON encoding, so a guard should accept only values that survive a JSON round trip in the form the consumer expects.

Expand Down Expand Up @@ -365,7 +361,7 @@ import type { ThemeOverride } from "@fx/tx/theme-override";

`Theming` is what the key carries: `theme(stream, options?)` resolving a `Theme`, which answers `appearance(variable)` with an `Appearance` of optional `dim`, `bold`, `inverse`, and `hue`. A `ThemeVariable` is one of nine semantic roles — `chrome`, `content`, `cursor`, `marker`, `muted`, `strong`, `positive`, `caution`, and `danger` — and a `Hue` is one of nine colours: the eight ANSI ones plus `gray`. Background hues, 256-colour, and truecolour are deliberately absent. Every member is documented where it is declared, and the contract is types only: it publishes no way to obtain a theme, which stays the registry's job.

A consumer reads `registrations<Theming>("@fx/tx/theme")` inside its command action, after initialization has committed every provider. It must find **exactly one** — as a config consumer already must, through the same rule `requireConfigCapability` applies, and unlike the dialogs capability, where the consumer owns what an absent capability means: none and several are both errors naming the count, and there is deliberately no fallback, because a consumer that fell back would have to carry its own copy of the default theme. The theme plugin is composed by default, so a `tx` without it is misconfigured rather than degraded.
A consumer reads `registrations<Theming>("@fx/tx/theme")` inside its command action, after initialization has committed every provider. It must find **exactly one** — as the marketplace plugin's `requireConfigCapability` lookup already chooses to, and unlike the dialogs capability, where the consumer owns what an absent capability means: none and several are both errors naming the count, and there is deliberately no fallback, because a consumer that fell back would have to carry its own copy of the default theme. The theme plugin is composed by default, so a `tx` without it is misconfigured rather than degraded.

A theme is resolved for the stream a surface draws to rather than handed out ready-made, because whether hues are emitted depends on that stream. Only the stream's TTY-ness is read; the capability never writes to it, retains it, or exposes a terminal or renderer. A resolved theme answers with an appearance alone and never says whether hues were enabled — that decision is already inside every appearance it returns.

Expand Down Expand Up @@ -616,7 +612,7 @@ Commands, child definitions, generic registry entries, and update participants c

Bundled feature plugins live under `plugins/<name>/`, conventionally at `plugins/<name>/index.ts`. Only the root `cli.ts` composition root selects and orders defaults. Modules under `src/` must remain feature-neutral and must not import or name bundled plugins; a bundled plugin's complete module graph must not import private core implementation under `src/`.

Use type-only imports from `@fx/tx/plugin` and from every capability contract the package publishes — `@fx/tx/theme`, `@fx/tx/theme-override`, and `@fx/tx/grid` today — standard Node.js or Bun APIs, and plugin-owned modules. A published specifier is the one way a bundled plugin may name another bundled plugin's vocabulary: it carries types alone and is erased, so it shares no runtime module graph, while a relative path into another plugin's directory is rejected. None of them may be loaded at run time or imported from under `src/`. Plugin-owned nonliteral dynamic imports of configured entry paths are allowed.
Use type-only imports from `@fx/tx/plugin` and from every capability contract the package publishes — `@fx/tx/config`, `@fx/tx/theme`, `@fx/tx/theme-override`, and `@fx/tx/grid` today — standard Node.js or Bun APIs, and plugin-owned modules. A published specifier is the one way a bundled plugin may name another bundled plugin's vocabulary: it carries types alone and is erased, so it shares no runtime module graph, while a relative path into another plugin's directory is rejected. None of them may be loaded at run time or imported from under `src/`. Plugin-owned nonliteral dynamic imports of configured entry paths are allowed.

## Validate changes

Expand Down
2 changes: 1 addition & 1 deletion docs/specs/config/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

[Change 0018](../../changes/0018-add-config-store-and-marketplace-installs.md) specifies the config capability and its first consumer, the marketplace plugin's configured-marketplace list. The bundled provider is implemented under `plugins/config/` and composed only in `cli.ts`; the marketplace plugin consumes it at command time for explicit configured installs and add/remove write-back. The requirements below describe current behavior.

[Change 0031](../../changes/0031-publish-the-dialogs-and-config-contracts.md) publishes this contract at `@fx/tx/config` and renames the capability's registry key to that same specifier, so a consumer imports the contract rather than restating it, under [Plugin System: Published Capability Contracts](../plugin-system/index.md#published-capability-contracts). That change is not yet implemented, so exactly two things below are ahead of the code: the contract is not published today, and the key in use is still the bare `config`. Every other requirement describes current behavior.
[Change 0031](../../changes/0031-publish-the-dialogs-and-config-contracts.md) publishes this contract at `@fx/tx/config` and renames the capability's registry key to that same specifier, so a consumer imports the contract rather than restating it, under [Plugin System: Published Capability Contracts](../plugin-system/index.md#published-capability-contracts). The half of that change covering this capability has landed — publication, the key rename, and the move of the marketplace plugin, the capability tests, and [the plugin guide](../../manual/plugins.md) off their local copies of the shape — so every requirement below describes current behavior. The change stays open for the [Dialogs](../dialogs/) contract.

## Background

Expand Down
2 changes: 1 addition & 1 deletion docs/specs/plugin-system/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ The approved target architecture is implemented: the core is generic, the market

The second registry provider from [Change 0018](../../changes/0018-add-config-store-and-marketplace-installs.md) is implemented outside core under `plugins/config/`: it registers the config capability and follows the separately owned [Config](../config/) contract. The marketplace plugin consumes it for [Configured Marketplaces](#configured-marketplaces), including explicit installation and add/remove write-back.

[Published Capability Contracts](#published-capability-contracts) is implemented for the capabilities [Change 0030](../../changes/0030-publish-bundled-capability-contracts.md) covers. That change established the mechanism and is complete: the [Theming](../theming/) contract is published at `@fx/tx/theme`, its override at `@fx/tx/theme-override`, and the [Grid](../grid/) contract at `@fx/tx/grid`, each registry key renamed to the specifier its contract is published at, and every bundled consumer, the demo, and the capability tests import those contracts rather than restating them. [Change 0031](../../changes/0031-publish-the-dialogs-and-config-contracts.md), which publishes the [Dialogs](../dialogs/) and [Config](../config/) contracts, has not started; both keys remain bare and their consumers still restate those two shapes.
[Published Capability Contracts](#published-capability-contracts) is implemented for the capabilities [Change 0030](../../changes/0030-publish-bundled-capability-contracts.md) covers. That change established the mechanism and is complete: the [Theming](../theming/) contract is published at `@fx/tx/theme`, its override at `@fx/tx/theme-override`, and the [Grid](../grid/) contract at `@fx/tx/grid`, each registry key renamed to the specifier its contract is published at, and every bundled consumer, the demo, and the capability tests import those contracts rather than restating them. [Change 0031](../../changes/0031-publish-the-dialogs-and-config-contracts.md), which publishes the [Dialogs](../dialogs/) and [Config](../config/) contracts, is under way: the [Config](../config/) contract is published at `@fx/tx/config` with its key renamed to it and its consumers importing it, while the dialogs key remains bare and its consumers still restate that shape.

[Minimal Clone Footprint](#minimal-clone-footprint) is implemented as specified in [Change 0019](../../changes/0019-reduce-marketplace-clone-footprint.md) for Git-sourced installs and updates, including the explicit complete-tree option, target-commit footprint re-derivation, complete-tree fallback, and transactional restoration.

Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
"./plugin": {
"types": "./src/plugin.ts"
},
"./config": {
"types": "./plugins/config/contract.ts"
},
"./grid": {
"types": "./plugins/grid/contract.ts"
},
Expand All @@ -33,6 +36,7 @@
"dist/tx",
"src/plugin.ts",
"src/context.ts",
"plugins/config/contract.ts",
"plugins/grid/contract.ts",
"plugins/theme/contract.ts",
"plugins/theme/override-contract.ts",
Expand Down
58 changes: 58 additions & 0 deletions plugins/config/contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* The published config contract: everything a plugin needs to type the value
* it reads from the `@fx/tx/config` registry key, and nothing else.
*
* It is published at that same specifier, so the string a consumer passes to
* the read and the string it imports this file from are one string rather than
* two that have to be kept agreeing. A consumer that imported this instead of
* restating it learns about a contract that moved when it builds rather than
* when its command reaches for a member that is no longer there.
*
* It declares types alone — no imports, no statements, nothing that survives
* compilation — which is what lets it be published under a `types` condition
* with no runtime condition beside it. That is also what makes it reachable on
* its own: the only exported copy of this shape used to sit in
* `plugins/marketplace/configured.ts`, whose module pulls in the marketplace
* manager and, behind it, `node:child_process`, `node:fs`, and the
* marketplace's source and storage modules. Naming what a config value looks
* like never needed any of that.
*
* Publishing the vocabulary does not publish the store behind it: a consumer
* that can name `define`, `read`, and `write` still cannot persist anything
* without the capability. Nothing here reads a file, resolves a path, or knows
* where the document lives.
*/

/**
* A consumer's own answer to whether a persisted value is the shape it
* expected.
*
* The capability never infers one. Values arrive from a document a user may
* hand-edit and a previous release may have written, so the guard is the whole
* of what makes a read safe, and it is the consumer's because only the
* consumer knows what its key means. Values are JSON encoded, so a guard
* should accept only what survives a round trip in the form its consumer
* expects.
*/
export type ConfigValidator<T> = (value: unknown) => value is T;

/**
* The value registered under `@fx/tx/config`: small JSON values persisted
* across invocations, scoped to the user rather than to a working directory.
*
* A key is declared before it is used rather than on first write, so a value
* written by an earlier release, or typed in by hand, is checked by the same
* guard the writer would have been held to. Keys are opaque strings compared
* by exact equality — nothing here trims, normalizes, parses, namespaces, or
* reserves one — and a second definition of the same key is rejected with the
* first guard left in force.
*
* A read of a key with no property answers `undefined`; a read of one whose
* persisted value fails its guard rejects, and affects no other key. A write
* whose value fails its guard rejects and changes nothing on disk.
*/
export type Config = {
define<T>(key: string, isValid: ConfigValidator<T>): void;
read<T>(key: string): Promise<T | undefined>;
write<T>(key: string, value: T): Promise<void>;
};
Loading
Loading