---
name: extends-config
description: How bos.config.json extends chains work, deep merge semantics, resolved config lifecycle, env-specific extends, and canonical field ordering. Use when debugging extends inheritance, configuring per-environment parents, understanding what dev writes vs publish writes, or reasoning about config merging.
metadata:
  sources: "src/merge.ts,src/config.ts,src/shared.ts,src/types.ts"
---

# extends & Config Merging

## extends Field

The `extends` field in `bos.config.json` specifies a parent config to inherit from. Supports two forms:

### String (all environments use same parent)
```json
{ "extends": "bos://dev.everything.near/everything.dev" }
```

### Object (per-environment parent)
```json
{
  "extends": {
    "development": "bos://dev.everything.near/everything.dev",
    "production": "bos://dev.everything.near/everything.dev",
    "staging": "bos://staging.everything.near/everything.dev"
  }
}
```

Fallback chain: requested env → `production` → first defined value.

## Deep Merge Semantics

Uses `defu` (with `createDefu` for custom merge rules):

| Field type | Merge behavior |
|-----------|---------------|
| Scalars (account, domain, repository) | Child overrides parent; parent inherited when child omits |
| `shared.ui.*` dep entries | Deep merged — child overrides specific keys, parent deps preserved |
| `plugins` | Deep merged — child overrides per-key, parent plugins preserved unless removed |
| `secrets` arrays | Unioned (deduplicated) |
| `routes` arrays | Child replaces parent |
| `variables` | Deep merged per-key |

### Null Sentinel Removal

Set a plugin to `null` to explicitly remove an inherited plugin:
```json
{
  "plugins": {
    "template": null
  }
}
```

## Resolved Config: `.bos/bos.resolved-config.json`

**Generated by**: `bos dev`, `bos build`, `syncAndGenerateSharedUi()`  
**Gitignored**: Yes (inside `.bos/`)

When `bos dev` or `bos build` runs:
1. The full extends chain is resolved in memory
2. The merged config is written to `.bos/bos.resolved-config.json`
3. **`bos.config.json` is NOT modified** during dev

Structure:
```json
{
  "_resolved": {
    "env": "development",
    "resolvedAt": "2026-05-11T...",
    "extendsChain": ["bos://dev.everything.near/everything.dev"]
  },
  "account": "me.near",
  "domain": "my.dev",
  "shared": { ... },
  "app": { ... },
  "plugins": { ... }
}
```

### Build configs read resolved config first

All build configs (ui/rsbuild.config.ts, host/rsbuild.config.ts, api/rspack.config.js, plugins/*/rspack.config.js) try `.bos/bos.resolved-config.json` first, falling back to `bos.config.json`.

The `_resolved` metadata is stripped before use.

### When bos.config.json IS written

| Command | Writes bos.config.json? | Why |
|---------|------------------------|-----|
| `bos dev` | No | Uses resolved config |
| `bos build` | No | Uses resolved config |
| `bos publish --deploy` | Yes | Snapshot moment — pins production URLs + versions |
| `bos plugin publish` | Yes | Records production URL + integrity |
| `bos plugin add/remove` | Yes | Changes project's own plugin list |
| `bos sync` | Yes | Merges template updates into local config |

### Remote host mode (bos->catalog)

When host is remote, `syncAndGenerateSharedUi()` reads versions from `bos.config.json` and writes them into `package.json` catalog. No resolved config is written — the remote host reads `bos.config.json` directly.

## Canonical Field Ordering

`BOS_CONFIG_ORDER` enforces consistent key order:

1. `extends` — always first
2. `account`
3. `domain`
4. `testnet`
5. `staging`
6. `repository`
7. `app`
8. `plugins`
9. `shared`

Unknown keys go after known keys. `rebuildOrderedConfig()` is applied before every write.

## API

From `src/config.ts`:
- `writeResolvedConfig(configDir, config, env, extendsChain?)` — writes `.bos/bos.resolved-config.json`
- `loadResolvedConfig(configDir)` — reads resolved config, returns `BosConfig | null`
- `resolveBosConfigPath(configDir)` — returns resolved config path if exists, else `bos.config.json`
- `readBosConfigForBuild(configDir)` — reads resolved config stripping `_resolved`, falls back to `bos.config.json`

From `src/merge.ts`:
- `mergeBosConfigWithExtends(parent, child)` — deep merge for extends chain
- `mergeBosConfigWithTemplate(local, template)` — merge for sync (local wins)
- `resolveExtendsRef(extendsField, env)` — resolve string|object extends for a given env
- `rebuildOrderedConfig(config)` — enforce canonical ordering
- `BOS_CONFIG_ORDER` — ordered field names
