Skip to content
9 changes: 9 additions & 0 deletions .changeset/cli-monorepo-fanout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@shelve/cli": minor
---

From a monorepo root, `push`, `pull`, `diff` and `sync` now run once per package that has its own `shelve.json`, and `--path <dir>` targets a single one. Root `shelve.json` settings reach sub-packages instead of being dropped, though `project` stays per-package.

Three things that were quietly broken now behave: `autoCreateProject: false` stops project creation even under `--yes`, `diff` no longer writes an empty env file or creates projects, and `shelve run` falls back to its encrypted cache when the API call fails instead of exiting.

Errors in `--json` mode now print the documented envelope on stderr rather than a stack trace. When a package fails partway through a fan-out, the error names it and lists the packages that already finished, in the prose hint and in `error.context`.
42 changes: 38 additions & 4 deletions apps/lp/content/docs/3.cli/2.index.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ JSON Schema: [shelve.cloud/schema.json](https://shelve.cloud/schema.json)

### Monorepo support

Shelve detects monorepo roots and **merges** the root `shelve.json` with the local one (shared team `slug`, per-package `project`, etc.):
Shelve detects monorepo roots and **merges** the root `shelve.json` with the local one. Put the settings every package shares in the root file and the `project` in each package:

::code-tree{default-value="shelve.json"}
```json [apps/app/shelve.json]
Expand All @@ -77,14 +77,48 @@ Shelve detects monorepo roots and **merges** the root `shelve.json` with the loc
}
```

```json [apps/api/shelve.json]
{
"project": "@nuxt/api"
}
```

```json [shelve.json]
{
"slug": "nuxtlabs"
"slug": "nuxtlabs",
"defaultEnv": "development"
}
```

::

Commands always run in the **current directory** — they do not automatically execute across every package in the monorepo.
The package config wins wherever the two disagree.

#### Running from the root

`push`, `pull`, `diff` and `sync` run once per package that has its own config file:

```bash [terminal]
shelve pull
```

That writes `apps/app/.env` and `apps/api/.env`. Packages without a config file are skipped, as is the root itself.

Target a single package with `--path`:

```bash [terminal]
shelve pull --path apps/app
```

To treat the root as a project of its own instead, give it a `project` in the root `shelve.json`. A bare command then runs in the root instead of fanning out. `--path` still targets a single package.

Inside a package, commands behave as they always have and only touch that package:

```bash [apps/app/terminal]
shelve pull
```

`run` is the exception: it executes your command in the current directory and never fans out.

## Configuration options

Expand All @@ -94,7 +128,7 @@ Commands always run in the **current directory** — they do not automatically e
| `slug` | `string` | `SHELVE_TEAM_SLUG` | Team slug |
| `token` | `string` | Keychain / `$XDG_CONFIG_HOME/.shelve` / `SHELVE_TOKEN` | API token |
| `url` | `string` | `https://app.shelve.cloud` | Shelve instance URL |
| `defaultEnv` | `string` | — | Default environment for `run`, `push`, `pull` |
| `defaultEnv` | `string` | — | Default environment for `run`, `push`, `pull`, `diff`, `sync` |
| `confirmChanges` | `boolean` | `false` | Confirm before push/pull writes |
| `envFileName` | `string` | `.env` | Local env file name |
| `autoUppercase` | `boolean` | `true` | Uppercase keys on push |
Expand Down
21 changes: 20 additions & 1 deletion apps/lp/content/docs/3.cli/5.push-pull.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ Reads variables from `envFileName` in `shelve.json` (default `.env`).
Target environment. Defaults to `defaultEnv` in `shelve.json` or `SHELVE_DEFAULT_ENV`.
::

::field{name="path" type="string"}
In a monorepo, push only this package instead of every one.
::

::field{name="yes" type="boolean"}
Skip confirmation when `confirmChanges` is `true` in config. Also respects global `--yes`.
::
Expand Down Expand Up @@ -62,12 +66,27 @@ Plaintext `.env` files can be read by AI agents. Run [`shelve init`](/docs/cli/i
Source environment.
::

::field{name="path" type="string"}
In a monorepo, pull only this package instead of every one.
::

::field{name="yes" type="boolean"}
Skip the agent disk-write confirmation and `confirmChanges` prompts.
::
::

JSON output: `{ "env", "variableCount", "file", "keys[]" }` — values are never included.
JSON output: `{ "env", "variableCount", "file", "keys[]", "pullMode", "preservedLocalKeys[]" }` — values are never included.

## Monorepos

Run from the workspace root and both commands visit every package that has its own `shelve.json`:

```bash [terminal]
shelve pull
shelve pull --path apps/web
```

JSON output then becomes `{ "packages": [...] }`, one entry per package with its `path`. See [Monorepo support](/docs/cli#monorepo-support).

## Default environment

Expand Down
2 changes: 1 addition & 1 deletion apps/lp/content/docs/3.cli/7.config.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ The token is always **redacted** as `"***"` in JSON mode.

- Resolved `project`, `slug`, `url`, `defaultEnv`
- Credential metadata (`username`, `email`) when logged in
- Monorepo detection (`isMonoRepo`, `workspaceDir`, `monorepo.paths`)
- Monorepo detection (`isMonoRepo`, `workspaceDir`, `isRoot`, `projectFromConfig`) and `monorepo.paths`, the packages a root-level command fans out to
- Effective flags (`confirmChanges`, `envFileName`, `sync`, …)
- Resolved sync policy per environment when `sync` is configured (see [Sync policies](/docs/cli/sync-policies))

Expand Down
6 changes: 4 additions & 2 deletions apps/lp/skills/shelve/agent-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ Errors go to stderr as JSON when `--json` is set. Check exit code `$?`.
## Monorepo

- Each package can have its own `shelve.json` with a different `project`.
- Root `shelve.json` is **merged** (shared `slug`, etc.).
- Commands run in the **current working directory** only — they do not iterate all packages automatically.
- Root `shelve.json` is **merged** in, and holds the settings every package shares (`slug`, `defaultEnv`, `envFileName`, …). The package config wins on conflicts.
- From the workspace root, `push` / `pull` / `diff` / `sync` run once per package that has its own config. `--path <dir>` targets one. `run` never fans out.
- Give the root `shelve.json` a `project` to opt out and treat the root as a single project.
- With `--json`, a fan-out run returns one entry per package under `data.packages`, each with its `path`.

## Offline / air-gapped

Expand Down
10 changes: 10 additions & 0 deletions apps/lp/skills/shelve/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,16 @@ or `{ "loggedIn": false }`.

Values are **never** included.

Run from a monorepo root and the shape becomes one entry per package:

```json
{
"packages": [
{ "path": "apps/web", "env": "development", "variableCount": 12, "file": ".env", "keys": ["DATABASE_URL"] }
]
}
```

### `init`

```json
Expand Down
188 changes: 110 additions & 78 deletions packages/cli/src/commands/diff.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,115 @@
import { defineCommand } from 'citty'
import type { ResolvedSyncPolicy, ShelveConfig } from '@types'
import { isJson, loadShelveConfig } from '../utils'
import { getResolvedSyncPolicy } from '../utils/sync-policy'
import { cliIntro, cliSuccess } from '../utils/output'
import { runFanOutCommand } from '../utils/workspaces'
import { cliIntro, cliOutro } from '../utils/output'
import { CliError } from '../services/api-error'
import { EnvironmentService, ProjectService, SyncService } from '../services'

type DiffResult = {
env: string
file: string
policy: ResolvedSyncPolicy
onlyLocal: string[]
onlyRemote: string[]
changed: string[]
unchanged: string[]
}

function normalizeKey(key: string, autoUppercase: boolean): string {
return autoUppercase ? key.toUpperCase() : key
}

async function diffProject(
config: ShelveConfig,
envArg: string | undefined,
showValues: boolean,
): Promise<DiffResult> {
const {
project,
slug,
envFileName,
defaultEnv,
autoUppercase,
sync,
} = config

const env = envArg || defaultEnv
if (!env) {
throw new CliError(
'Environment name is required.',
'MISSING_ENV',
undefined,
'Pass --env or set defaultEnv in shelve.json / SHELVE_DEFAULT_ENV.',
)
}

cliIntro(`Diff local ${envFileName} vs ${env}`)

// `diff` is documented as read-only ("no writes"); autoCreateProject would let it
// create a project on the server per package just by running the comparison, so
// this always passes autoCreate: false regardless of the config. promptToCreate:
// false on top of that, since getProjectByName's interactive fallback would
// otherwise still offer to create the project — every mode gets a plain
// PROJECT_NOT_FOUND instead.
const projectData = await ProjectService.getProjectByName(project, slug, false, false)
const environment = await EnvironmentService.getEnvironment(slug, env)
const policy = getResolvedSyncPolicy(environment.name, sync, projectData.syncPolicy)

const syncContext = await SyncService.loadSyncContext({
project: projectData,
environmentId: environment.id,
environmentName: environment.name,
slug,
autoUppercase,
})

const { diff } = syncContext
const result: DiffResult = {
env: environment.name,
file: envFileName,
policy,
onlyLocal: diff.onlyLocal,
onlyRemote: diff.onlyRemote,
changed: diff.changed,
unchanged: diff.unchanged,
}

if (isJson()) return result

const lines = [
`Environment: ${environment.name}`,
`Source of truth (policy): ${policy.sourceOfTruth}`,
`onPushConflict: ${policy.onPushConflict} · pullMode: ${policy.pullMode}`,
`allowPush: ${policy.allowPush} · allowPull: ${policy.allowPull}`,
'',
`Only in ${envFileName} (${diff.onlyLocal.length}): ${diff.onlyLocal.join(', ') || '—'}`,
`Only on Shelve (${diff.onlyRemote.length}): ${diff.onlyRemote.join(', ') || '—'}`,
`Changed (${diff.changed.length}): ${diff.changed.join(', ') || '—'}`,
`Unchanged (${diff.unchanged.length}): ${diff.unchanged.length} key(s)`,
]

if (showValues && diff.changed.length > 0) {
lines.push('', 'Changed values (local → remote):')
const localMap = new Map(
syncContext.local.map(v => [normalizeKey(v.key, autoUppercase), v.value]),
)
const remoteMap = new Map(
syncContext.remote.map(v => [normalizeKey(v.key, autoUppercase), v.value]),
)
for (const key of diff.changed) {
const lookup = normalizeKey(key, autoUppercase)
lines.push(` ${key}: ${localMap.get(lookup) ?? '?'} → ${remoteMap.get(lookup) ?? '?'}`)
}
}

console.log(lines.join('\n'))
cliOutro('Diff complete')

return result
}

export default defineCommand({
meta: {
name: 'diff',
Expand All @@ -20,90 +121,21 @@ export default defineCommand({
description: 'Environment to compare against',
required: false,
},
path: {
type: 'string',
description: 'Diff a single monorepo package instead of every one',
required: false,
},
'show-values': {
type: 'boolean',
description: 'Include secret values in human output (never in JSON)',
required: false,
},
},
async run({ args }) {
const {
project,
slug,
envFileName,
autoCreateProject,
defaultEnv,
autoUppercase,
sync,
} = await loadShelveConfig(true)

const env = args.env || defaultEnv
if (!env) {
throw new CliError(
'Environment name is required.',
'MISSING_ENV',
undefined,
'Pass --env or set defaultEnv in shelve.json / SHELVE_DEFAULT_ENV.',
)
}

cliIntro(`Diff local ${envFileName} vs ${env}`)

const projectData = await ProjectService.getProjectByName(project, slug, autoCreateProject)
const environment = await EnvironmentService.getEnvironment(slug, env)
const policy = getResolvedSyncPolicy(environment.name, sync, projectData.syncPolicy)

const syncContext = await SyncService.loadSyncContext({
project: projectData,
environmentId: environment.id,
environmentName: environment.name,
slug,
autoUppercase,
})

const { diff } = syncContext
const data = {
env: environment.name,
file: envFileName,
policy,
onlyLocal: diff.onlyLocal,
onlyRemote: diff.onlyRemote,
changed: diff.changed,
unchanged: diff.unchanged,
}

if (isJson()) {
cliSuccess(data, undefined, 'diff')
return
}

const lines = [
`Environment: ${environment.name}`,
`Source of truth (policy): ${policy.sourceOfTruth}`,
`onPushConflict: ${policy.onPushConflict} · pullMode: ${policy.pullMode}`,
`allowPush: ${policy.allowPush} · allowPull: ${policy.allowPull}`,
'',
`Only in ${envFileName} (${diff.onlyLocal.length}): ${diff.onlyLocal.join(', ') || '—'}`,
`Only on Shelve (${diff.onlyRemote.length}): ${diff.onlyRemote.join(', ') || '—'}`,
`Changed (${diff.changed.length}): ${diff.changed.join(', ') || '—'}`,
`Unchanged (${diff.unchanged.length}): ${diff.unchanged.length} key(s)`,
]

if (args['show-values'] && diff.changed.length > 0) {
lines.push('', 'Changed values (local → remote):')
const localMap = new Map(
syncContext.local.map(v => [normalizeKey(v.key, autoUppercase), v.value]),
)
const remoteMap = new Map(
syncContext.remote.map(v => [normalizeKey(v.key, autoUppercase), v.value]),
)
for (const key of diff.changed) {
const lookup = normalizeKey(key, autoUppercase)
lines.push(` ${key}: ${localMap.get(lookup) ?? '?'} → ${remoteMap.get(lookup) ?? '?'}`)
}
}
const config = await loadShelveConfig(true)
const showValues = Boolean(args['show-values'])

console.log(lines.join('\n'))
cliSuccess(undefined, 'Diff complete')
await runFanOutCommand('diff', config, args.path, (cfg) => diffProject(cfg, args.env, showValues))
},
})
Loading
Loading