# Mount

`dev-cockpit mount` generates a Docker compose overlay that bind-mounts host directories into container paths, so changes in a local checkout flow live into the running container without rebuilds.

The subsystem is fully generic — it works for any docker-compose project — but profiles can plug in domain logic (package discovery, post-apply restart hooks, IDE-facing symlinks, custom status enrichment) without touching dev-cockpit core.

## Quick start

```yaml
# cockpit.yaml
version: 2
appName: my-app
docker:
  composeFile: docker-compose.yml
  services:
    - name: web

mounts:
  - hostPath: ./packages/api
    containerPath: /srv/api
```

```sh
dev-cockpit mount                # interactive picker (or all when --quiet)
dev-cockpit mount status         # active mounts with branch / dirty / broken-symlink
dev-cockpit mount clear          # remove overlay + manifest + symlinks
```

Apply the generated overlay alongside your existing compose file:

```sh
docker compose -f docker-compose.yml -f docker-compose.dev-cockpit.yml up -d
```

## What gets written

| File | Default path | Configurable via |
|---|---|---|
| Compose overlay | `<workspace>/docker-compose.dev-cockpit.yml` | `config.mount.overlayPath` |
| Manifest | `<stateDir>/mount.manifest.json` | `config.mount.manifestFile` |

`<stateDir>` lives under `$XDG_STATE_HOME/<appName>/<workspace-hash>/`. The manifest tracks what was applied and when, and is the source of truth for `status` and `clear`.

Override the paths when the wrapper has its own conventions:

```yaml
mount:
  overlayPath: docker/compose/web/docker-compose.dev-link.yml
  manifestFile: dev-link.manifest.json
```

## Mount sources

Mounts come from two places, merged on `containerPath` (config wins on collision):

1. **`config.mounts[]`** — explicit, declared in cockpit.yaml:
   ```yaml
   mounts:
     - hostPath: ./packages/api
       containerPath: /srv/api
       meta:                  # optional opaque metadata (read by profile hooks + status)
         name: api
         type: package
   ```
2. **`profile.mountCandidatesProvider?.()`** — discovered programmatically (walk a manifest file, scan a parent directory, etc.).

The `meta` field is opaque to dev-cockpit; profile hooks (status enricher, symlink strategy) read it.

## Subcommands

```
dev-cockpit mount [options]            # write overlay + manifest
  -c, --config <path>                  # default: ./cockpit.yaml
  -s, --service <name>                 # default: first in docker.services
  -q, --quiet                          # skip interactive picker; apply all candidates
  -e, --exclude <name>                 # exclude a candidate by meta.name (repeatable)

dev-cockpit mount status [options]     # branch + dirty + broken-symlink table
dev-cockpit mount clear  [options]     # remove overlay + manifest + managed symlinks
```

### Interactive picker

When `mount` runs with discoverable candidates (from `profile.mountCandidatesProvider`) and no `--quiet`, it opens a checkbox picker. Bare-config consumers (only `config.mounts[]`, no provider) bypass the picker — config entries are taken as-is.

### Status

`mount status` prints a table per active mount with auto-enriched columns:

```
NAME             BRANCH                  DIRTY
----             ------                  -----
api              feat/redis-cache        yes
worker           main                    no   [BROKEN SYMLINK]
```

- **branch / dirty**: auto-detected via `git -C <hostPath> rev-parse --abbrev-ref HEAD` + `git status --porcelain`. Non-git host paths show `?`.
- **broken-symlink**: only when the profile supplies a `mountSymlinks` strategy (see below). Otherwise the column is omitted.
- Extra columns: profile-supplied `mountStatusEnricher` can return arbitrary `extra: { columnName: value }` per row.

### Clear

`mount clear` removes the overlay file, the manifest, and any managed symlinks (if a `mountSymlinks` strategy is defined). Then it calls `profile.onMountClear(ctx)` with the previous mount set — that's where consumers run things like `composer install` or `npm install` to restore whatever the mounts had shadowed.

## Profile hooks

Profiles plug in via five optional fields on the `Profile` interface:

```ts
import type { Profile } from 'dev-cockpit';

export const myProfile: Profile = {
  appName: 'my-app',

  /** Discover mount candidates programmatically. */
  mountCandidatesProvider() { ... },

  /** Run after overlay + manifest are written. Typical: `docker compose restart <svc>`. */
  async onMountApply(ctx) { ... },

  /** Run after overlay + manifest + symlinks are removed. Typical: restore deps. */
  async onMountClear(ctx) { ... },

  /** Optional IDE-facing symlinks applied + removed alongside the overlay. */
  mountSymlinks: { linkPath: (mount, workspaceRoot) => '...' },

  /** Per-row column enrichment for `mount status`. */
  async mountStatusEnricher(mount, ctx) { ... },
};
```

### `mountCandidatesProvider() → Mount[] | Promise<Mount[]>`

Discover candidates from whatever convention the consumer uses (composer.json, package.json workspaces, etc.). Return `Mount[]` with `hostPath` + `containerPath` set; populate `meta` for downstream hooks:

```ts
mountCandidatesProvider() {
  return findLocalCheckouts().map((c) => ({
    hostPath: c.hostPath,
    containerPath: `/srv/${c.name}`,
    meta: { name: c.name, type: c.type },
  }));
}
```

### `onMountApply(ctx) → Promise<void>`

Called after the overlay and manifest are written. Common move: restart the docker service so the new mounts take effect.

```ts
async onMountApply(ctx) {
  await execa('docker', ['compose', 'restart', 'web'], { cwd: ctx.workspaceRoot });
}
```

`ctx` carries `workspaceRoot`, the freshly-applied `mounts[]`, and `log` / `errLog` callbacks that route to the cockpit's Output pane.

### `onMountClear(ctx) → Promise<void>`

Called after the overlay + manifest + symlinks are removed. `ctx.previous` holds the mount set that was just cleared. Typical: re-install whatever the mounts had shadowed.

```ts
async onMountClear(ctx) {
  if (ctx.previous.length === 0) return;
  await execa('npm', ['install'], { cwd: ctx.workspaceRoot });
}
```

### `mountSymlinks: MountSymlinkStrategy`

Some package managers expect the on-disk path to exist even when a bind-mount shadows it (composer installer-paths, npm install-from-disk, etc.). The symlink strategy creates host-side symlinks alongside the overlay so IDE tooling (LSPs, file watchers, debuggers) can follow into the live clone:

```ts
mountSymlinks: {
  linkPath(mount, workspaceRoot) {
    const name = mount.meta?.name;
    if (typeof name !== 'string') return null; // skip mounts without metadata
    return path.join(workspaceRoot, 'vendor', name);
  },
}
```

Return `null` for mounts that shouldn't get a symlink. Symlinks are removed by `mount clear`; `mount status` detects broken ones (target moved or deleted) and surfaces them in the table.

### `mountStatusEnricher(mount, ctx) → MountStatusInfo`

Add custom columns or override the auto-detected `branch` / `dirty` / `brokenSymlink` flags:

```ts
async mountStatusEnricher(mount, { workspaceRoot }) {
  return {
    extra: {
      tests: await runTestsForMount(mount) ? 'pass' : 'fail',
    },
  };
}
```

## Manifest schema

```json
{
  "version": 1,
  "appName": "my-app",
  "workspaceRoot": "/abs/path",
  "service": "web",
  "overlayPath": "/abs/path/docker-compose.dev-cockpit.yml",
  "mounts": [
    {
      "hostPath": "/abs/host/api",
      "containerPath": "/srv/api",
      "meta": { "name": "api", "type": "package" }
    }
  ],
  "appliedAt": "2026-05-11T00:00:00.000Z"
}
```

Re-running `mount` overwrites the manifest with the new applied set. `mount clear` deletes it.

## Notes for wrappers upgrading from older manifest filenames

If your wrapper previously used a different manifest filename (e.g. a profile that pre-dates the mount lift into dev-cockpit core wrote `dev-link.manifest.json`), the new default is `mount.manifest.json`. Two options:

1. **Adopt the new default**: run `mount` once. The new file replaces the legacy one (which is left orphaned in the state dir — safe to delete).
2. **Keep the legacy filename**: set `config.mount.manifestFile: dev-link.manifest.json` in cockpit.yaml.

Same applies to the overlay path — set `config.mount.overlayPath` if your wrapper expects a specific location for its compose stack.
