# Configuration storage contract Where Ntilde keeps user state, what survives an update or an uninstall, and the constraints any feature that reads or writes that state has to respect. Written for anyone building backup/restore, export/import, profile sync, or a settings migration. Every claim below cites its source so it can be re-checked rather than trusted. ## Two roots, deliberately different | | Path | Owner | Deleted by uninstall? | |---|---|---|---| | **Config root** | `%LocalAppData%\Ntilde` | the app (`AppPaths`) | **No** | | **Install root** | `%LocalAppData%\NtildeApp` | Velopack (`--packId`) | **Yes — entirely** | Velopack installs to `%LocalAppData%\` and its uninstall routine deletes that whole directory: *"any data stored within it, such as settings or logs, will be lost"* ([Velopack uninstalling docs](https://docs.velopack.io/integrating/uninstalling)). Updates are narrower — only the `current\` subdirectory is replaced — so **uninstall is the destructive path, not update.** One exception reaches into the config root, by design: on Windows the local multiplexer daemon runs from its own copy under `bin\` (see the inventory), outside the install root, so an update leaves it running. Uninstall therefore stops it in Velopack's uninstall hook (`MuxUninstall`, as `ntilde mux kill-server --force` would: it is asked to shut down, and terminated by pid if it cannot be asked or has not exited within 5 s; its shells end with it), then deletes the daemon's copies in `bin\` and `bin\` itself once empty. Only folders that are copies are ever deleted - one named for a version that holds the `.complete` list, or a staging folder named `..<32-hex guid>.tmp` - so anything else found in `bin\` (which could be a junction into a shared folder) is left alone, as is any junction or link. A copy a daemon still runs from is kept, and one whose delete fails right after a forced stop is tried once more. Nothing else in the config root is touched. This is why `--packId` in [`.github/workflows/release.yml`](../.github/workflows/release.yml) is `NtildeApp` and not `Ntilde`. Aligning it with the app name would make the install root and the config root the same folder, and uninstalling would silently destroy every setting, SSH profile, known-host entry, command history and workspace. **Do not "tidy" the packId to match the app name.** `--packTitle` is what the user sees in shortcuts and Add/Remove Programs; only nupkg file names and the install directory derive from `--packId`. ### Changing the packId orphans installed clients, and does it silently A packId is the app's identity to the updater, so it can only be changed before a release ships. What makes this dangerous is *how* it fails — observed for real when 0.5.0 shipped under a new packId while a tester had a 0.4.1 build installed under the old one: - **The old client is still offered the new release.** `UpdateManager.CheckForUpdatesAsync` is a bare `feed.Where(Full).MaxBy(x => x.Version)` — it does **not** compare the feed's `PackageId` against the installed app's id. The client fetches the latest non-prerelease release's `releases.win.json`, sees a higher version, and raises an update toast across the packId boundary. - **The update is not a no-op it can recover from.** Applying it leaves the client running new code out of the *old* install root — which, for a packId that matched the app name, is the config root. That machine is then permanently in the state this whole document exists to prevent, and no later update moves it out. - **There is no in-band fix.** Deleting the old release does not help: the client reads the latest release's feed regardless of which release it was installed from. The only clean path is manual — back up the config root, uninstall, reinstall from the new `Setup.exe`, restore. So a packId change is a **breaking, un-migratable** change for anyone already installed. Before making one, confirm nobody has the old id installed — including from throwaway test releases, which is exactly the case that got missed. ## Secrets live outside the config root Passwords and passphrases are **not** in `%LocalAppData%\Ntilde`. They go to the OS credential store, selected per platform in [`SecretStore.cs`](../src/Ntilde.App/Shell/Secrets/SecretStore.cs): - Windows — Win32 Credential Manager, per-user and DPAPI-protected ([`WindowsCredentialStore.cs`](../src/Ntilde.App/Shell/Secrets/WindowsCredentialStore.cs)) - macOS — Keychain (`MacKeychainStore`) - Linux — Secret Service (`LinuxSecretStore`) Consequences for anything that copies the config folder: - **A folder copy silently omits every credential.** Restoring it elsewhere yields SSH profiles that look complete but cannot authenticate — a silent partial failure, not an error. - **The Windows store cannot be enumerated.** [`Win32CredentialManager`](../src/Ntilde.App/Shell/Native/Win32CredentialManager.cs) binds only `CredReadW`, `CredWriteW` and `CredDeleteW` — there is no `CredEnumerateW`. You cannot ask "what secrets does Ntilde hold?"; you can only derive the key set from `ssh\profiles.json` and read each key by name. - **Keys are namespaced** `Ntilde:...`, in practice `Ntilde:SSH:User@Host` or `Ntilde:SSH:ProfileName:User@Host` (`WindowsCredentialStore.ToTarget` / `ExtractUsername`). - **DPAPI blobs are not portable** across users or machines, so a credential cannot be moved by copying ciphertext. Exporting secrets means reading plaintext out of the store and taking on responsibility for encrypting the export. ## Inventory All paths are properties of [`AppPaths`](../src/Ntilde.App/Shell/AppPaths.cs), relative to the config root: | Path | Contents | |---|---| | `settings.json` | all app settings | | `command-palette-usage.json` | command-palette ranking data | | `mux-close-choice` | `keep` or `close`: the first-close dialog's "Don't ask again" answer (`AppPaths.MuxCloseChoiceFilePath`, written by `MuxCloseChoiceStore`). Deleted when Settings saves, imports or restores a changed session persistence; excluded from backups (`BackupCatalog.ExcludedRelativePaths`); safe to delete (the next close with live shells asks again) | | `themes\` | user-installed themes | | `sessions\last_session.json` | restored tab/pane layout | | `workspaces\`, `workspace_templates\` | saved and templated workspaces | | `policy\workspace_policy.json` | workspace trust policy | | `ssh\profiles.json` | SSH connection profiles (no secrets) | | `ssh\native_known_hosts.json` | native-backend known hosts | | `command-assist\history.jsonl` | append-only command history | | `command-assist\snippets.json` | saved snippets | | `backups\` | automatic configuration snapshots written by the backup subsystem | | `recordings\` | terminal recordings | | `logs\` | `debug.log`, `startup_error.txt`, `workspace_audit.log`, … | | `bin\\` | Windows installs only: the local multiplexer daemon's own copy of `Ntilde.exe`, the DLLs beside it and `\OpenConsole.exe` (`MuxDaemonImage`, not an `AppPaths` member). A Velopack update kills every process whose image is under the install root, so the daemon runs from here instead. One folder per version (`-` after another build under the same version), each with a `.complete` list written last: its files' sizes and the executable's SHA-256; `...tmp\` folders are copies being staged. About 75 MB per version (0.11.0's AOT bundle). A launch deletes other versions' folders once no daemon runs from them; uninstall stops the daemon and deletes them all. Only copy-shaped folders are ever deleted (a version's name plus `.complete`, or `..<32-hex guid>.tmp`); anything else here, and any junction or link, is left alone. Not backed up; safe to delete when no daemon runs | ## Environment variables Two variables change where Ntilde reads and writes. Both are read from the process environment only, never from `settings.json` or any other file. | Variable | Effect | |---|---| | `NTILDE_APPDATA_ROOT` | Replaces the config root (see Constraints). Tests, portable setups and sandboxed verification runs use it to point the app, its multiplexer daemon and the MCP server at a scratch folder. Every process of such a run must see it, including the ones Velopack starts. On Windows, Velopack's install, update and uninstall hooks and the restart after an apply all inherit the environment of whatever started `Setup.exe`, `Update.exe` or the app; on Linux the restart after an apply does too (both measured with Velopack 1.2.0 in Phase 5 Task 24). **Not on macOS:** Velopack 1.2.0 restarts the app there with `/usr/bin/open -n` (`src/bins/src/shared/util_osx.rs`, `start_package`), and LaunchServices starts it without the caller's environment, so a restarted app runs against the real config root. A run on macOS must never let Velopack restart the app (`scripts/mux-update-survival.sh` applies with `UpdateMac apply --norestart` and starts the new build itself). | | `NTILDE_UPDATE_SOURCE_DIR` | **A verification hook.** When it names a directory and the running install was packed under the verification id `NtildeSurvival` (`UpdateSourceOverride.VerificationAppId`, compared ordinally), the updater reads that local Velopack feed (`releases..json` and the packages `vpk pack` writes beside it) instead of this repository's GitHub releases (`UpdateSourceOverride`, `VelopackUpdateService`). Every other install ignores it - the released `NtildeApp` included, and a process that is not a Velopack install - so for users the feed's origin stays the compiled-in repository. It is an allow-list on purpose: a list of ids to refuse would fail open the next time the packId is renamed, as it was once (NovaTerminalApp to NtildeApp). For the verification install, a value that is blank or names no directory is ignored too, and updates come from GitHub. When the variable is set, the app logs which source it uses and why, at startup and again when the updater is built; unset, it logs nothing. `scripts/mux-update-survival.ps1` and `.sh` pack under that id and use it to apply a real update to a sandboxed install; a test pins that both scripts use the id. | ## Constraints **Resolve paths through `AppPaths`, never a hardcoded `%LocalAppData%\Ntilde`.** `AppPaths.RootDirectory` honours the `NTILDE_APPDATA_ROOT` environment variable as a root override; tests and portable setups depend on it. Hardcoding the path makes a feature untestable and breaks portable installs. **Never write `vault.dat` back.** `AppPaths.LegacyVaultFilePath` is documented in source as *"the pre-#100 weakly-encrypted vault file, kept only so it can be deleted."* A restore that recreates it resurrects crypto that was deliberately removed. **Never write `command-assist\history.json` back.** It is a one-time migration source only; the store converts it to `history.jsonl` and renames it to `history.json.bak`. Restoring it can re-trigger migration over newer data. **Treat `command-assist\history.jsonl` and `recordings\` as sensitive.** Command lines sometimes contain secrets, and recordings capture arbitrary terminal output. Any export intended to be shareable should exclude them or say plainly that it does not. `logs\` is also bulk and noise — usually not worth carrying. **Anything written for recovery belongs under the config root.** Automatic snapshots, exported archives and rollback state must live under `AppPaths.RootDirectory` (or a user-chosen path), never under the install root, which uninstall deletes. `backups\` is the concrete case: those snapshots are only safe because the install root is a separate directory. Under a packId matching the app name they would sit inside the folder uninstall deletes, so the rollback safety net would be destroyed by the exact event you would want to roll back from. **Expect files to be locked on Windows.** A running instance holds `logs\debug.log` open, so a naive recursive copy of the config root fails partway. Either exclude `logs\`, or require the app be closed.