Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/setup-convergent-steps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@desselbane/setup': minor
---

feat: setup steps converge instead of assuming a virgin host (ADR-002) — the dot-config step clones only when `~/.dotCfg` is absent (otherwise fetches), shows a diff and prompts before stashing a dirty work tree and force-checking-out, probes known 1Password locations for `op-ssh-sign` (failing hard when absent), and diffs/prompts before overwriting an existing `local.gitconfig` (printing the generated content on keep); the Linux step applies the same behavior to symlinks and its generated config
5 changes: 5 additions & 0 deletions .changeset/setup-linux-dot-config-step.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@desselbane/setup': minor
---

feat: Linux/WSL dot-config support — platform dispatch in the CLI entry point, symlinks into the Windows checkout driven by the tracked `wsl-links.json` link manifest (incl. `{windows, linux}` mapped entries), a generated Linux `local.gitconfig` with the OS include chain, and pwsh as login shell
5 changes: 5 additions & 0 deletions .changeset/setup-provision-wsl-step.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@desselbane/setup': minor
---

feat: new provision-WSL setup step — installs the Ubuntu distro, enables systemd via `/etc/wsl.conf`, installs Volta with Node and pnpm (setting `VOLTA_FEATURE_PNPM=1` inline and persisting it in `~/.profile`), and launches the setup package inside WSL
5 changes: 5 additions & 0 deletions .changeset/setup-unified-program-catalog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@desselbane/setup': minor
---

feat: unified cross-platform program catalog — `program.config.json` entries now carry `WingetId`/`AptId`/`aptPrereq`/`linuxInstall`, and the install step installs via winget on Windows and apt (with prereq handling and custom install commands) on Linux
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ coverage
!**/_screenshots_/**/*.CI.png
/.idea/gbrowser_project.xml
**/*.bundled_*
.plans
8 changes: 8 additions & 0 deletions doc/CONTEXT-MAP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Context Map

Context Maps can be nested offering different levels of detail.
This map lists the documented contexts of the monorepo; packages without an entry are not yet documented.

## Contexts

- [Machine Setup](../packages/setup/doc/CONTEXT.md) — personal workstation provisioning for Windows and WSL
17 changes: 17 additions & 0 deletions packages/setup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,20 @@ pnpm dlx @desselbane/setup -y
After the script you might want to

- Restore PowerToys Settings

## WSL

The Windows run offers an optional "provision WSL" step.
It installs an Ubuntu distro if none is present yet.
It enables systemd via `/etc/wsl.conf`.
It installs Volta and Node inside the distro.
It then launches the setup package inside WSL.

To bootstrap an existing WSL distro directly, run this one-liner inside it.

```bash
curl https://get.volta.sh | bash && volta install node && npx --yes @desselbane/setup
```

The Linux run sets up the dot-config symlinks, pointing into the Windows checkout (see [ADR-001](./doc/adr/ADR-001-windows-dotcfg-checkout-as-single-source-of-truth.md)).
It generates `local.gitconfig`, sets `pwsh` as the login shell, and installs programs via `apt`.
35 changes: 35 additions & 0 deletions packages/setup/doc/CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Machine Setup

Personal workstation provisioning: one interactive CLI that sets up Windows and WSL with the same shell ergonomics, backed by a config-file repo.

## Language

**Dot-config repo**:
The bare git repo at `~/.dotCfg` whose work tree is the home directory; it persists config files and nothing else — no scripts, no logic.
_Avoid_: dotfiles, config repo

**Setup package**:
The TypeScript CLI (`@desselbane/setup`) holding all provisioning scripts for every OS; the only place imperative setup logic lives.
_Avoid_: bootstrap scripts, install scripts

**Shared config**:
A config file tracked in the dot-config repo that is byte-identical across operating systems (e.g. the pwsh profile, starship config).

**OS config**:
A config file tracked in the dot-config repo that applies to every machine of one operating system (e.g. `windows.gitconfig`, `linux.gitconfig`).
_Avoid_: platform config

**Local config**:
A file generated by the setup package for exactly one machine instance, never tracked (e.g. `local.gitconfig`); two machines with the same OS still have different local configs.
_Avoid_: machine config

**Setup step**:
One prompted, independently selectable action of the setup package.
Every step converges the host from its current state to the desired state — it detects divergence, surfaces it, and prompts before destructive changes; it never assumes a virgin host.
_Avoid_: install step, migration step

**Link manifest**:
A file tracked in the dot-config repo listing which shared configs the setup package symlinks into a WSL home.

**Provision WSL step**:
The optional Windows-side setup step that installs the WSL distro, installs Node inside it, and launches the setup package there.
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
| Status | Date |
| :------: | :--------: |
| Accepted | 2026-08-28 |

<!-- Possible Status Values: Proposed, Accepted, Partially Superseded, Superseded, Revoked -->

# ADR-001: Windows dot-config checkout as single source of truth

## Context

The dot-config repo (a bare git repo at `~/.dotCfg` with the home directory as work tree) persists personal config files on Windows.
WSL should offer the same shell ergonomics (git, 1Password SSH agent, starship, pwsh) using the same config files.
Some config values cannot be identical across operating systems (e.g. the 1Password ssh-sign binary path), and some values differ per machine even on the same OS.
Git config supports `include`, silently skips missing include targets, and offers no OS-based conditional include (`includeIf` supports only `gitdir`, `gitdir/i`, `onbranch`, and `hasconfig:remote.*.url`, and `gitdir` is unreliable outside a repository).

## Decision

The Windows checkout of the dot-config repo is the only checkout; WSL consumes it via symlinks into `/mnt/c`, and per-OS/per-machine divergence is handled by a setup-generated `local.gitconfig` include chain.
Operational detail lives in [Consumption rules](#consumption-rules) below.

| Aspect | Rule |
| ------------------ | ---------------------------------------------------------------------------------------------------------------- |
| Checkout | Exactly one dot-config checkout per machine, in the Windows home; WSL never clones the repo. |
| WSL consumption | Setup creates symlinks in the WSL home pointing at the Windows checkout, driven by a link manifest tracked in the dot-config repo. |
| Config layering | Shared config (tracked) → OS config (tracked, `windows.gitconfig` / `linux.gitconfig`) → local config (generated, untracked). |
| OS selection | The setup script selects the OS layer at generation time; no runtime OS detection inside config files. |
| Non-includable formats | Formats without an include mechanism (unlike git config) use a single shared file with runtime OS branching (e.g. the pwsh profile) or a per-OS solution decided case by case. |

## Consumption rules

- The link manifest lists home-relative paths of shared config files; the setup script resolves the Windows home from within WSL (e.g. `wslpath` on `%USERPROFILE%`) and creates the symlinks.
- `~/.gitconfig` (shared, tracked) contains exactly one include: `local.gitconfig`.
- `local.gitconfig` is generated by the setup script and starts with an include of the matching OS config (`windows.gitconfig` or `linux.gitconfig`), followed by machine-specific values (e.g. the resolved 1Password ssh-sign path).
- Include order yields the intended precedence: shared < OS < machine-local.

## Consequences

### Positive

- **POS-001**: Config edits are visible in both worlds instantly; no sync or double-commit workflow.
- **POS-002**: The dot-config repo stays a pure config store; all imperative logic lives in the setup package.
- **POS-003**: Adding a shared config file to WSL is a manifest entry, not a code change.

### Negative

- **NEG-001**: WSL config access crosses the 9p filesystem boundary (`/mnt/c`), which is slower and depends on Windows drive mounts being available.
- **NEG-002**: Symlinked files inherit Windows line endings and permissions semantics; tools that are strict about either may need per-case handling.
- **NEG-003**: A WSL instance is not self-sufficient; without the Windows host checkout it has no config.

## Alternatives Considered

### Second checkout of the dot-config repo inside WSL

- **Description**: Clone the bare repo again in the Linux home and check out natively.
- **Rejection Reason**: Two checkouts drift; every edit needs commit/push/pull to reach the other OS, defeating the "still where they should be" ergonomics.

### OS detection inside git config via `includeIf`

- **Description**: Use `includeIf "gitdir:C:/"`-style path-prefix hacks to select OS config at git runtime.
- **Rejection Reason**: No real OS condition exists; drive-letter patterns require one entry per drive and silently fail outside a repository.

### Symlinked `os.gitconfig` shim

- **Description**: Setup creates `~/os.gitconfig` as a symlink to the OS variant, included by the shared `.gitconfig`.
- **Rejection Reason**: Redundant — the generated `local.gitconfig` can carry the OS include itself, avoiding one file and all symlink semantics in the Windows home.

## References

- **REF-001**: [git-config conditional includes](https://git-scm.com/docs/git-config#_conditional_includes)
- **REF-002**: [1Password SSH agent in WSL](https://developer.1password.com/docs/ssh/integrations/wsl/)
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
| Status | Date |
| :------: | :--------: |
| Accepted | 2026-08-28 |

<!-- Possible Status Values: Proposed, Accepted, Partially Superseded, Superseded, Revoked -->

# ADR-002: Setup steps converge, never assume a virgin host

## Context

The setup package was written for virgin hosts: the dot-config step clones unconditionally, overwrites `~/.gitconfig` and `local.gitconfig` blindly, and force-checks-out the work tree.
Existing hosts on the old layout (pre-[ADR-001](./ADR-001-windows-dotcfg-checkout-as-single-source-of-truth.md) config layering) must be brought up to date, and already-migrated hosts must survive re-runs (e.g. after a manifest change).
Machine-local files like `local.gitconfig` are hand-tunable by definition, and real hosts diverge (e.g. the 1Password `op-ssh-sign` binary lives in `%LOCALAPPDATA%/1Password/app/8/` on some machines and under WindowsApps on others), so blind regeneration destroys valid machine state.
A force checkout on a dirty work tree silently discards uncommitted local changes to tracked config files.

## Decision

Every setup step converges the host from its current state to the desired state — it detects divergence, surfaces it, and prompts before destructive changes — instead of assuming a virgin host; there is no dedicated migration step or layout-version marker.
Operational detail lives in [Convergence rules](#convergence-rules) below.

| Aspect | Rule |
| ------------------- | -------------------------------------------------------------------------------------------------------- |
| Entry point | Migration is an ordinary re-run of setup; each prompted step performs its own detection. |
| Repo acquisition | Clone the dot-config repo only if absent, otherwise fetch. |
| Dirty work tree | Detect dirty tracked files, print the diff, prompt; on confirm `git stash` then force checkout, else abort. |
| Generated local config | Probe the machine for actual values; on differing existing file, diff and prompt overwrite/keep. |
| Kept local config | On "keep", print the full generated content so the user can hand-merge selectively. |
| Probe failures | Fail hard when a probed prerequisite is absent (e.g. no `op-ssh-sign` binary found); never write placeholders. |

## Convergence rules

- The dirty-file check runs in the bare-repo context (`git --work-tree ~ --git-dir ~/.dotCfg status --porcelain` on tracked files); the stash keeps discarded state recoverable through git rather than an ad-hoc backup folder.
- The `op-ssh-sign` probe checks the known install locations (`%LOCALAPPDATA%/1Password/app/8/` and the WindowsApps store path) and generates `local.gitconfig` with the found path.
- The Linux dot-config step applies the same diff-and-prompt behavior to its generated `local.gitconfig` and existing symlinks.
- Promoting old machine-local values into tracked layers (`windows.gitconfig`, shared `.gitconfig`) stays manual, informed by the printed diffs; no inspect/absorb tooling.

## Consequences

### Positive

- **POS-001**: One code path serves first-time setup, migration, and repair; migration-only code never rots.
- **POS-002**: No silent data loss — every destructive action shows a diff and requires confirmation, and stashed state is recoverable.
- **POS-003**: Hand-tuned machine-local values survive re-runs instead of being regenerated away.

### Negative

- **NEG-001**: Every step carries detection logic, making steps more complex than one-shot scripts.
- **NEG-002**: Re-runs are interactive; prompts prevent fully unattended execution on diverged hosts.

## Alternatives Considered

### Dedicated migrate step

- **Description**: A one-off `migrate` step encoding the old-layout → new-layout transition.
- **Rejection Reason**: Dead code after the ~2 existing machines migrate; does not help already-new hosts on re-runs.

### Layout-version marker

- **Description**: A marker file or version field letting setup detect "old layout" and self-migrate, possibly auto-answering prompts.
- **Rejection Reason**: State to maintain for exactly one transition on a two-machine fleet; the diff prompts already surface exactly what changes.

### Unconditional force overwrite (status quo)

- **Description**: Keep overwriting `~/.gitconfig`/`local.gitconfig` and force-checking-out without inspection.
- **Rejection Reason**: Silently destroys machine-local divergence such as a corrected `gpg.ssh.program` path.

## References

- **REF-001**: [ADR-001: Windows dot-config checkout as single source of truth](./ADR-001-windows-dotcfg-checkout-as-single-source-of-truth.md)
71 changes: 71 additions & 0 deletions packages/setup/src/helper.l1.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import type { MockInstance } from 'vitest'
import {
execSync as execSyncHelper,
cleanExit,
contentDiffers,
existsIncludingAppAliases,
findExistingPath,
isAdmin,
wingetInstall,
} from './helper'
Expand Down Expand Up @@ -141,3 +144,71 @@ describe(wingetInstall, () => {
}).not.toThrow()
})
})

function existsOnlyB(candidate: string) {
return candidate === 'b'
}

function existsNever() {
return false
}

describe(findExistingPath, () => {
it('should return the first candidate that exists', () => {
expect(findExistingPath(['a', 'b', 'c'], existsOnlyB)).toBe('b')
})

it('should return undefined when no candidate exists', () => {
expect(findExistingPath(['a', 'b'], existsNever)).toBeUndefined()
})
})

function lstatSucceeding() {
return {}
}

/**
Simulates lstat on a Windows app-execution alias, where lstat succeeds but stat throws EACCES.
*/
function lstatOnAppAlias() {
return { isSymbolicLink: () => true }
}

function lstatThrowing(): unknown {
throw new Error('ENOENT')
}

describe(existsIncludingAppAliases, () => {
it('should report existing when lstat succeeds', () => {
expect(
existsIncludingAppAliases('C:/foo/op-ssh-sign.exe', lstatSucceeding),
).toBe(true)
})

it('should report existing for an app-execution alias (stat-based existsSync would fail)', () => {
expect(
existsIncludingAppAliases(
'C:/Users/foo/AppData/Local/Microsoft/WindowsApps/op-ssh-sign.exe',
lstatOnAppAlias,
),
).toBe(true)
})

it('should report missing when lstat throws', () => {
expect(existsIncludingAppAliases('C:/missing', lstatThrowing)).toBe(false)
})
})

describe(contentDiffers, () => {
it('should report differing when the existing content is undefined', () => {
expect(contentDiffers(undefined, 'generated')).toBe(true)
})

it('should report differing when the contents do not match', () => {
expect(contentDiffers('current', 'generated')).toBe(true)
})

it('should report not differing when the contents match', () => {
expect(contentDiffers('same', 'same')).toBe(false)
})
})
44 changes: 44 additions & 0 deletions packages/setup/src/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,47 @@ export function execSync(command: string) {
stdio: 'inherit',
})
}

/**
Runs {@link command} and returns its trimmed stdout instead of inheriting it.
Use this when the caller needs the command's output (e.g. resolving a path).
*/
export function execSyncCapture(command: string) {
return execSyncNode(command).toString().trim()
}

/**
Returns the first {@link candidates} entry that {@link exists} reports as present, or `undefined`
if none exist. Used to probe machine-specific install locations that vary across hosts.
*/
export function findExistingPath(
candidates: string[],
exists: (candidate: string) => boolean,
) {
return candidates.find((candidate) => exists(candidate))
}

/**
Whether {@link candidate} exists on disk, including Windows app-execution aliases
(e.g. the WindowsApps `op-ssh-sign.exe`), where `stat`-based checks like `existsSync`
fail with EACCES because they follow the alias reparse point — {@link lstat} does not.
*/
export function existsIncludingAppAliases(
candidate: string,
lstat: (candidate: string) => unknown,
) {
const [error] = safeTry(() => lstat(candidate))

return error == undefined
}

/**
Whether {@link generated} content differs from the {@link existing} file content.
`undefined` existing content (file absent) always counts as differing.
*/
export function contentDiffers(
existing: string | undefined,
generated: string,
) {
return existing !== generated
}
Loading
Loading