From 6fa725ff0defea0fe9cf6ec77baf47655b5e4e0d Mon Sep 17 00:00:00 2001 From: Dessel Bane Date: Fri, 28 Aug 2026 11:52:08 +0200 Subject: [PATCH] add wsl setup --- .changeset/setup-convergent-steps.md | 5 + .changeset/setup-linux-dot-config-step.md | 5 + .changeset/setup-provision-wsl-step.md | 5 + .changeset/setup-unified-program-catalog.md | 5 + .gitignore | 1 + doc/CONTEXT-MAP.md | 8 + packages/setup/README.md | 17 ++ packages/setup/doc/CONTEXT.md | 35 +++ ...tcfg-checkout-as-single-source-of-truth.md | 70 ++++++ ...steps-converge-never-assume-virgin-host.md | 69 ++++++ packages/setup/src/helper.l1.spec.ts | 71 ++++++ packages/setup/src/helper.ts | 44 ++++ .../setup/src/install-programs.l1.spec.ts | 146 +++++++++++ packages/setup/src/install-programs.ts | 151 +++++++++--- packages/setup/src/main.ts | 42 +++- packages/setup/src/program.config.json | 11 + packages/setup/src/provision-wsl.l1.spec.ts | 82 +++++++ packages/setup/src/provision-wsl.ts | 112 +++++++++ .../setup/src/setup-dot-config.l1.spec.ts | 70 ++++++ .../src/setup-dot-config.linux.l1.spec.ts | 104 ++++++++ packages/setup/src/setup-dot-config.linux.ts | 231 ++++++++++++++++++ packages/setup/src/setup-dot-config.ts | 217 +++++++++++++--- 22 files changed, 1423 insertions(+), 78 deletions(-) create mode 100644 .changeset/setup-convergent-steps.md create mode 100644 .changeset/setup-linux-dot-config-step.md create mode 100644 .changeset/setup-provision-wsl-step.md create mode 100644 .changeset/setup-unified-program-catalog.md create mode 100644 doc/CONTEXT-MAP.md create mode 100644 packages/setup/doc/CONTEXT.md create mode 100644 packages/setup/doc/adr/ADR-001-windows-dotcfg-checkout-as-single-source-of-truth.md create mode 100644 packages/setup/doc/adr/ADR-002-setup-steps-converge-never-assume-virgin-host.md create mode 100644 packages/setup/src/install-programs.l1.spec.ts create mode 100644 packages/setup/src/provision-wsl.l1.spec.ts create mode 100644 packages/setup/src/provision-wsl.ts create mode 100644 packages/setup/src/setup-dot-config.l1.spec.ts create mode 100644 packages/setup/src/setup-dot-config.linux.l1.spec.ts create mode 100644 packages/setup/src/setup-dot-config.linux.ts diff --git a/.changeset/setup-convergent-steps.md b/.changeset/setup-convergent-steps.md new file mode 100644 index 00000000..eac396c7 --- /dev/null +++ b/.changeset/setup-convergent-steps.md @@ -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 diff --git a/.changeset/setup-linux-dot-config-step.md b/.changeset/setup-linux-dot-config-step.md new file mode 100644 index 00000000..de307a05 --- /dev/null +++ b/.changeset/setup-linux-dot-config-step.md @@ -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 diff --git a/.changeset/setup-provision-wsl-step.md b/.changeset/setup-provision-wsl-step.md new file mode 100644 index 00000000..ecea9f19 --- /dev/null +++ b/.changeset/setup-provision-wsl-step.md @@ -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 diff --git a/.changeset/setup-unified-program-catalog.md b/.changeset/setup-unified-program-catalog.md new file mode 100644 index 00000000..25dc3053 --- /dev/null +++ b/.changeset/setup-unified-program-catalog.md @@ -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 diff --git a/.gitignore b/.gitignore index cfdd33b1..8cf3f9a3 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ coverage !**/_screenshots_/**/*.CI.png /.idea/gbrowser_project.xml **/*.bundled_* +.plans diff --git a/doc/CONTEXT-MAP.md b/doc/CONTEXT-MAP.md new file mode 100644 index 00000000..9b9f926e --- /dev/null +++ b/doc/CONTEXT-MAP.md @@ -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 diff --git a/packages/setup/README.md b/packages/setup/README.md index 991ab2f4..75d8ce4b 100644 --- a/packages/setup/README.md +++ b/packages/setup/README.md @@ -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`. diff --git a/packages/setup/doc/CONTEXT.md b/packages/setup/doc/CONTEXT.md new file mode 100644 index 00000000..ea297d94 --- /dev/null +++ b/packages/setup/doc/CONTEXT.md @@ -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. diff --git a/packages/setup/doc/adr/ADR-001-windows-dotcfg-checkout-as-single-source-of-truth.md b/packages/setup/doc/adr/ADR-001-windows-dotcfg-checkout-as-single-source-of-truth.md new file mode 100644 index 00000000..99aae740 --- /dev/null +++ b/packages/setup/doc/adr/ADR-001-windows-dotcfg-checkout-as-single-source-of-truth.md @@ -0,0 +1,70 @@ +| Status | Date | +| :------: | :--------: | +| Accepted | 2026-08-28 | + + + +# 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/) diff --git a/packages/setup/doc/adr/ADR-002-setup-steps-converge-never-assume-virgin-host.md b/packages/setup/doc/adr/ADR-002-setup-steps-converge-never-assume-virgin-host.md new file mode 100644 index 00000000..345b5705 --- /dev/null +++ b/packages/setup/doc/adr/ADR-002-setup-steps-converge-never-assume-virgin-host.md @@ -0,0 +1,69 @@ +| Status | Date | +| :------: | :--------: | +| Accepted | 2026-08-28 | + + + +# 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) diff --git a/packages/setup/src/helper.l1.spec.ts b/packages/setup/src/helper.l1.spec.ts index b8e385ad..b43ecefb 100644 --- a/packages/setup/src/helper.l1.spec.ts +++ b/packages/setup/src/helper.l1.spec.ts @@ -4,6 +4,9 @@ import type { MockInstance } from 'vitest' import { execSync as execSyncHelper, cleanExit, + contentDiffers, + existsIncludingAppAliases, + findExistingPath, isAdmin, wingetInstall, } from './helper' @@ -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) + }) +}) diff --git a/packages/setup/src/helper.ts b/packages/setup/src/helper.ts index 251ab11e..721d70bf 100644 --- a/packages/setup/src/helper.ts +++ b/packages/setup/src/helper.ts @@ -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 +} diff --git a/packages/setup/src/install-programs.l1.spec.ts b/packages/setup/src/install-programs.l1.spec.ts new file mode 100644 index 00000000..daf881a4 --- /dev/null +++ b/packages/setup/src/install-programs.l1.spec.ts @@ -0,0 +1,146 @@ +import { Separator } from '@inquirer/prompts' +import { + buildChoicesForPlatform, + buildLinuxInstallPlan, + isInstallableOnPlatform, +} from './install-programs' +import type { ConfigItem } from './install-programs' + +function makeItem(overrides: Partial = {}): ConfigItem { + return { + Name: 'Test', + WingetId: 'Test.Test', + installDefault: false, + group: 'Core', + ...overrides, + } +} + +describe(isInstallableOnPlatform, () => { + it('should be installable on win32 when a WingetId is present', () => { + const item = makeItem({ WingetId: 'Foo.Bar' }) + + expect(isInstallableOnPlatform(item, 'win32')).toBe(true) + }) + + it('should not be installable on win32 without a WingetId', () => { + const item = makeItem({ WingetId: undefined }) + + expect(isInstallableOnPlatform(item, 'win32')).toBe(false) + }) + + it('should be installable on linux when an AptId is present', () => { + const item = makeItem({ WingetId: undefined, AptId: 'foo' }) + + expect(isInstallableOnPlatform(item, 'linux')).toBe(true) + }) + + it('should be installable on linux when only a linuxInstall script is present', () => { + const item = makeItem({ WingetId: undefined, linuxInstall: 'curl foo' }) + + expect(isInstallableOnPlatform(item, 'linux')).toBe(true) + }) + + it('should not be installable on linux without AptId or linuxInstall', () => { + const item = makeItem({ WingetId: 'Foo.Bar' }) + + expect(isInstallableOnPlatform(item, 'linux')).toBe(false) + }) +}) + +describe(buildChoicesForPlatform, () => { + it('should disable entries without an install method for the platform', () => { + const items = [makeItem({ Name: 'WinOnly', WingetId: 'Foo.Bar' })] + + const choices = buildChoicesForPlatform(items, 'linux') + const choice = choices.find( + (x): x is Exclude<(typeof choices)[number], Separator> => + !(x instanceof Separator) && x.name === 'WinOnly', + ) + + expect(choice?.disabled).toBeTruthy() + }) + + it('should leave installable entries enabled', () => { + const items = [ + makeItem({ Name: 'Both', WingetId: 'Foo.Bar', AptId: 'foo' }), + ] + + const choices = buildChoicesForPlatform(items, 'linux') + const choice = choices.find( + (x): x is Exclude<(typeof choices)[number], Separator> => + !(x instanceof Separator) && x.name === 'Both', + ) + + expect(choice?.disabled).toBe(false) + }) + + it('should check entries that are installable and default on', () => { + const items = [ + makeItem({ + Name: 'Default', + WingetId: 'Foo.Bar', + AptId: 'foo', + installDefault: true, + }), + ] + + const choices = buildChoicesForPlatform(items, 'linux') + const choice = choices.find( + (x): x is Exclude<(typeof choices)[number], Separator> => + !(x instanceof Separator) && x.name === 'Default', + ) + + expect(choice?.checked).toBe(true) + }) + + it('should not check a default entry that is not installable on the platform', () => { + const items = [ + makeItem({ + Name: 'Default', + WingetId: 'Foo.Bar', + installDefault: true, + }), + ] + + const choices = buildChoicesForPlatform(items, 'linux') + const choice = choices.find( + (x): x is Exclude<(typeof choices)[number], Separator> => + !(x instanceof Separator) && x.name === 'Default', + ) + + expect(choice?.checked).toBe(false) + }) +}) + +describe(buildLinuxInstallPlan, () => { + it('should collect deduplicated aptPrereqs', () => { + const items = [ + makeItem({ AptId: 'a', aptPrereq: 'prereq' }), + makeItem({ AptId: 'b', aptPrereq: 'prereq' }), + ] + + const plan = buildLinuxInstallPlan(items) + + expect(plan.aptPrereqs).toStrictEqual(['prereq']) + }) + + it('should collect all AptIds', () => { + const items = [makeItem({ AptId: 'a' }), makeItem({ AptId: 'b' })] + + const plan = buildLinuxInstallPlan(items) + + expect(plan.aptIds).toStrictEqual(['a', 'b']) + }) + + it('should only use linuxInstall as a fallback when no AptId is present', () => { + const items = [ + makeItem({ AptId: 'a', linuxInstall: 'curl a' }), + makeItem({ AptId: undefined, linuxInstall: 'curl b' }), + ] + + const plan = buildLinuxInstallPlan(items) + + expect(plan.linuxInstalls).toStrictEqual(['curl b']) + }) +}) diff --git a/packages/setup/src/install-programs.ts b/packages/setup/src/install-programs.ts index 58203f92..cac36dc5 100644 --- a/packages/setup/src/install-programs.ts +++ b/packages/setup/src/install-programs.ts @@ -13,45 +13,57 @@ const groupPrio = [ 'Peripherals', ] as const -export type Choices = (Choice<(typeof config)[number]> | Separator)[] - -export async function installPrograms() { - const updateInstalledProgramsPrompt = await safeTryAsync( - confirm({ - message: 'Do you want to update all currently installed Programs?', - default: false, - }), - ) - cleanExit(updateInstalledProgramsPrompt) +export type ConfigItem = { + Name: string + WingetId?: string + AptId?: string + aptPrereq?: string + linuxInstall?: string + installDefault: boolean + group: string +} - if (updateInstalledProgramsPrompt.data) { - execSync('winget update -r') +export type Choices = (Choice | Separator)[] + +/** +Whether {@link item} declares an install method for {@link platform}. +*/ +export function isInstallableOnPlatform( + item: ConfigItem, + platform: NodeJS.Platform, +) { + if (platform === 'win32') { + return item.WingetId != undefined } - const installProgramsPrompt = await safeTryAsync( - confirm({ - message: 'Do you want to install additional programs?', - default: false, - }), - ) - cleanExit(installProgramsPrompt) - if (!installProgramsPrompt.data) { - return - } + return item.AptId != undefined || item.linuxInstall != undefined +} +/** +Builds the checkbox choices for {@link platform}, disabling entries without an install method. +*/ +export function buildChoicesForPlatform( + items: readonly ConfigItem[], + platform: NodeJS.Platform, +) { const choices: Choices = [] - function mapConfigItem(item: (typeof config)[number]): Choices[number] { + function mapConfigItem(item: ConfigItem): Choices[number] { + const installable = isInstallableOnPlatform(item, platform) + return { value: item, name: item.Name, - checked: item.installDefault, + checked: installable && item.installDefault, + disabled: installable + ? false + : `not available for ${platform === 'win32' ? 'Windows' : 'Linux'}`, } as const } for (const group of groupPrio) { choices.push(new Separator(group)) - const groupMembers = config.filter((x) => x.group === group) + const groupMembers = items.filter((x) => x.group === group) choices.push( ...groupMembers @@ -63,6 +75,87 @@ export async function installPrograms() { ) } + return choices +} + +export type LinuxInstallPlan = { + aptPrereqs: string[] + aptIds: string[] + linuxInstalls: string[] +} + +/** +Splits selected {@link items} into the apt prerequisite/batch/curl-fallback steps needed to install them on Linux. +*/ +export function buildLinuxInstallPlan( + items: readonly ConfigItem[], +): LinuxInstallPlan { + const aptPrereqs = [ + ...new Set( + items.map((x) => x.aptPrereq).filter((x): x is string => x != undefined), + ), + ] + + const aptIds = items + .map((x) => x.AptId) + .filter((x): x is string => x != undefined) + + const linuxInstalls = items + .filter((x) => x.AptId == undefined) + .map((x) => x.linuxInstall) + .filter((x): x is string => x != undefined) + + return { aptPrereqs, aptIds, linuxInstalls } +} + +function installOnWindows(items: readonly ConfigItem[]) { + wingetInstall(items.map((x) => `"${x.WingetId}"`).join(' ')) +} + +function installOnLinux(items: readonly ConfigItem[]) { + const { aptPrereqs, aptIds, linuxInstalls } = buildLinuxInstallPlan(items) + + for (const aptPrereq of aptPrereqs) { + execSync(aptPrereq) + } + + if (aptIds.length > 0) { + execSync(`sudo apt-get install -y ${aptIds.join(' ')}`) + } + + for (const linuxInstall of linuxInstalls) { + execSync(linuxInstall) + } +} + +export async function installPrograms() { + if (process.platform === 'win32') { + const updateInstalledProgramsPrompt = await safeTryAsync( + confirm({ + message: 'Do you want to update all currently installed Programs?', + default: false, + }), + ) + cleanExit(updateInstalledProgramsPrompt) + + if (updateInstalledProgramsPrompt.data) { + execSync('winget update -r') + } + } + + const installProgramsPrompt = await safeTryAsync( + confirm({ + message: 'Do you want to install additional programs?', + default: false, + }), + ) + cleanExit(installProgramsPrompt) + if (!installProgramsPrompt.data) { + return + } + + const choices = buildChoicesForPlatform(config, process.platform) + const whichProgramsPrompt = await safeTryAsync( checkbox({ message: 'Which apps should be installed?', @@ -94,7 +187,9 @@ export async function installPrograms() { cleanExit() } - wingetInstall( - whichProgramsPrompt.data.map((x) => `"${x.WingetId}"`).join(' '), - ) + if (process.platform === 'win32') { + installOnWindows(whichProgramsPrompt.data) + } else { + installOnLinux(whichProgramsPrompt.data) + } } diff --git a/packages/setup/src/main.ts b/packages/setup/src/main.ts index 16c590cc..290f9e86 100644 --- a/packages/setup/src/main.ts +++ b/packages/setup/src/main.ts @@ -1,22 +1,38 @@ #!/usr/bin/env node import { execSync, isAdmin } from './helper' -import { setupDotConfig } from './setup-dot-config' +import { setupDotConfigLinux } from './setup-dot-config.linux' import { installPrograms } from './install-programs' import { runDeveloperDSC } from './dsc' -import { installVSCodeExtensions } from './install-vscode-extensions' +import { provisionWsl } from './provision-wsl' -console.log('🧙 Checking admin 🧙‍♂️') +if (process.platform === 'win32') { + /* + Windows-only modules assert Windows env vars (USERPROFILE, LOCALAPPDATA) at module top level, + so they must not be imported statically — that would crash on Linux before the platform check. + */ + const { setupDotConfig } = await import('./setup-dot-config') + const { installVSCodeExtensions } = + await import('./install-vscode-extensions') -if (!isAdmin()) { - console.log('elevating... 🧙 => 🧙‍♂️') - execSync(`sudo ${process.argv.join(' ')}`) - process.exit(0) -} + console.log('🧙 Checking admin 🧙‍♂️') + + if (!isAdmin()) { + console.log('elevating... 🧙 => 🧙‍♂️') + execSync(`sudo ${process.argv.join(' ')}`) + process.exit(0) + } -console.log('Bootstraping Windows, hold on to your socks') + console.log('Bootstraping Windows, hold on to your socks') -await setupDotConfig() -await installPrograms() -await installVSCodeExtensions() -await runDeveloperDSC() + await setupDotConfig() + await installPrograms() + await installVSCodeExtensions() + await runDeveloperDSC() + await provisionWsl() +} else { + console.log('Bootstraping Linux, hold on to your socks') + + await setupDotConfigLinux() + await installPrograms() +} diff --git a/packages/setup/src/program.config.json b/packages/setup/src/program.config.json index 880f12c3..5426e35b 100644 --- a/packages/setup/src/program.config.json +++ b/packages/setup/src/program.config.json @@ -14,12 +14,15 @@ { "Name": "Powershell 7", "WingetId": "Microsoft.PowerShell", + "AptId": "powershell", + "aptPrereq": "curl -sSL https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/packages-microsoft-prod.deb -o /tmp/packages-microsoft-prod.deb && sudo dpkg -i /tmp/packages-microsoft-prod.deb && sudo apt-get update", "installDefault": false, "group": "Core" }, { "Name": "Starship", "WingetId": "Starship.Starship", + "linuxInstall": "curl -sS https://starship.rs/install.sh | sh -s -- -y", "installDefault": true, "group": "Core" }, @@ -29,9 +32,17 @@ "installDefault": true, "group": "Programming" }, + { + "Name": "Git", + "WingetId": "Git.Git", + "AptId": "git", + "installDefault": true, + "group": "Core" + }, { "Name": "Volta", "WingetId": "Volta.Volta", + "linuxInstall": "curl https://get.volta.sh | bash", "installDefault": false, "group": "Programming" }, diff --git a/packages/setup/src/provision-wsl.l1.spec.ts b/packages/setup/src/provision-wsl.l1.spec.ts new file mode 100644 index 00000000..406a0d55 --- /dev/null +++ b/packages/setup/src/provision-wsl.l1.spec.ts @@ -0,0 +1,82 @@ +import { + buildWslConfContent, + hasDistro, + profileNeedsVoltaPnpmExport, + shouldWriteWslConf, + toPrintfLiteral, +} from './provision-wsl' + +describe(profileNeedsVoltaPnpmExport, () => { + it('should append when no ~/.profile exists yet', () => { + // Arrange + const existingContent = undefined + + // Act + const result = profileNeedsVoltaPnpmExport(existingContent) + + // Assert + expect(result).toBe(true) + }) + + it('should append when the export is missing', () => { + // Arrange + const existingContent = 'export PATH="$HOME/.volta/bin:$PATH"\n' + + // Act + const result = profileNeedsVoltaPnpmExport(existingContent) + + // Assert + expect(result).toBe(true) + }) + + it('should not append when the export is already present', () => { + // Arrange + const existingContent = 'export VOLTA_FEATURE_PNPM=1\n' + + // Act + const result = profileNeedsVoltaPnpmExport(existingContent) + + // Assert + expect(result).toBe(false) + }) +}) + +describe(hasDistro, () => { + it('should be false for empty output', () => { + expect(hasDistro('')).toBe(false) + }) + + it('should be false for whitespace-only output', () => { + expect(hasDistro('\r\n\r\n')).toBe(false) + }) + + it('should be true when a distro name is present', () => { + expect(hasDistro('Ubuntu\r\n')).toBe(true) + }) +}) + +describe(toPrintfLiteral, () => { + it('should replace real newlines with the printf escape sequence', () => { + expect(toPrintfLiteral('[boot]\nsystemd=true\n')).toBe( + String.raw`[boot]\nsystemd=true\n`, + ) + }) + + it('should keep the wsl.conf content free of literal newlines', () => { + expect(toPrintfLiteral(buildWslConfContent())).not.toContain('\n') + }) +}) + +describe(shouldWriteWslConf, () => { + it('should write when no wsl.conf exists yet', () => { + expect(shouldWriteWslConf(undefined)).toBe(true) + }) + + it('should write when the existing content differs', () => { + expect(shouldWriteWslConf('[boot]\nsystemd=false\n')).toBe(true) + }) + + it('should not write when the existing content already matches', () => { + expect(shouldWriteWslConf(buildWslConfContent())).toBe(false) + }) +}) diff --git a/packages/setup/src/provision-wsl.ts b/packages/setup/src/provision-wsl.ts new file mode 100644 index 00000000..2141c21d --- /dev/null +++ b/packages/setup/src/provision-wsl.ts @@ -0,0 +1,112 @@ +import { safeTry, safeTryAsync } from '@desselbane/ts-helpers' +import { confirm } from '@inquirer/prompts' +import { cleanExit, execSync, execSyncCapture } from './helper' + +const distroName = 'Ubuntu' + +/** +The desired contents of `/etc/wsl.conf` inside the provisioned distro. +*/ +export function buildWslConfContent() { + return '[boot]\nsystemd=true\n' +} + +/** +Encodes {@link content} for use inside a single-quoted `printf` argument: real newlines become +the `\n` escape sequence, since a literal newline would split the shell command line mid-quote. +*/ +export function toPrintfLiteral(content: string) { + return content.replaceAll('\n', String.raw`\n`) +} + +/** +Whether `wsl -l -q` output ({@link listOutput}) already contains an installed distro. +*/ +export function hasDistro(listOutput: string) { + return listOutput + .split('\n') + .map((line) => line.replaceAll(/[^\u{20}-\u{7E}]/gu, '').trim()) + .some((line) => line.length > 0) +} + +const voltaPnpmExport = 'export VOLTA_FEATURE_PNPM=1' + +/** +Whether the volta pnpm feature flag export is missing from `~/.profile` +({@link existingContent} is `undefined` when the file doesn't exist yet). +*/ +export function profileNeedsVoltaPnpmExport( + existingContent: string | undefined, +) { + return !(existingContent?.includes(voltaPnpmExport) ?? false) +} + +/** +Whether `/etc/wsl.conf` needs to be (re)written: `undefined` means it doesn't exist yet, +otherwise it is compared against the desired content. +*/ +export function shouldWriteWslConf(existingContent: string | undefined) { + return existingContent?.trim() !== buildWslConfContent().trim() +} + +export async function provisionWsl() { + if (process.platform !== 'win32') { + return + } + + const shouldPrompt = await safeTryAsync( + confirm({ + message: 'Do you want to provision WSL?', + default: false, + }), + ) + cleanExit(shouldPrompt) + if (!shouldPrompt.data) { + return + } + + const [listError, listOutput] = safeTry(() => execSyncCapture('wsl -l -q')) + + if (listError != undefined || !hasDistro(listOutput)) { + console.log(`Installing ${distroName}`) + execSync(`wsl --install -d ${distroName}`) + } + + console.log('Checking /etc/wsl.conf') + const [readError, existingWslConf] = safeTry(() => + execSyncCapture(`wsl -d ${distroName} -- cat /etc/wsl.conf`), + ) + + if ( + shouldWriteWslConf(readError == undefined ? existingWslConf : undefined) + ) { + console.log('Writing /etc/wsl.conf') + execSync( + `wsl -d ${distroName} -- sh -c "printf '${toPrintfLiteral(buildWslConfContent())}' | sudo tee /etc/wsl.conf"`, + ) + } + + console.log('Checking VOLTA_FEATURE_PNPM in ~/.profile') + const [profileError, existingProfile] = safeTry(() => + execSyncCapture(`wsl -d ${distroName} -- bash -lc "cat ~/.profile"`), + ) + + if ( + profileNeedsVoltaPnpmExport( + profileError == undefined ? existingProfile : undefined, + ) + ) { + console.log('Adding VOLTA_FEATURE_PNPM to ~/.profile') + execSync( + `wsl -d ${distroName} -- bash -lc "echo '${voltaPnpmExport}' >> ~/.profile"`, + ) + } + + console.log('Installing Volta, Node and pnpm inside WSL') + execSync( + `wsl -d ${distroName} -- bash -lc "curl https://get.volta.sh | bash && VOLTA_FEATURE_PNPM=1 ~/.volta/bin/volta install node pnpm"`, + ) + + console.log('To run setup inside WSL, execute:') + console.log(` wsl -d ${distroName} -- bash -lc "pnpm dlx @desselbane/setup"`) +} diff --git a/packages/setup/src/setup-dot-config.l1.spec.ts b/packages/setup/src/setup-dot-config.l1.spec.ts new file mode 100644 index 00000000..ff8c1f2e --- /dev/null +++ b/packages/setup/src/setup-dot-config.l1.spec.ts @@ -0,0 +1,70 @@ +import { + buildLocalGitconfigWindows, + isDotCfgDirty, + resolveOpSshSignCandidatesWindows, +} from './setup-dot-config' + +describe(resolveOpSshSignCandidatesWindows, () => { + it('should list the direct 1Password install path first', () => { + const [firstCandidate] = resolveOpSshSignCandidatesWindows( + 'C:/Users/foo/AppData/Local', + ) + + expect(firstCandidate).toBe( + 'C:/Users/foo/AppData/Local/1Password/app/8/op-ssh-sign.exe', + ) + }) + + it('should list the WindowsApps store path second', () => { + const candidates = resolveOpSshSignCandidatesWindows( + 'C:/Users/foo/AppData/Local', + ) + + expect(candidates[1]).toBe( + 'C:/Users/foo/AppData/Local/Microsoft/WindowsApps/op-ssh-sign.exe', + ) + }) +}) + +describe(isDotCfgDirty, () => { + it('should be dirty when porcelain output has entries', () => { + expect(isDotCfgDirty(' M .gitconfig\n')).toBe(true) + }) + + it('should not be dirty when porcelain output is empty', () => { + expect(isDotCfgDirty('')).toBe(false) + }) + + it('should not be dirty when porcelain output is only whitespace', () => { + expect(isDotCfgDirty(' \n')).toBe(false) + }) +}) + +describe(buildLocalGitconfigWindows, () => { + it('should include windows.gitconfig before the machine-specific values', () => { + const config = buildLocalGitconfigWindows('C:/foo/op-ssh-sign.exe') + + const includeIndex = config.indexOf('path = windows.gitconfig') + const gpgIndex = config.indexOf('C:/foo/op-ssh-sign.exe') + + expect(includeIndex).toBeGreaterThanOrEqual(0) + expect(gpgIndex).toBeGreaterThan(includeIndex) + }) + + it('should indent every entry with a tab, matching how git config writes files', () => { + const config = buildLocalGitconfigWindows('C:/foo/op-ssh-sign.exe') + + const entryLines = config.split('\n').filter((line) => line.includes(' = ')) + + expect(entryLines).not.toHaveLength(0) + for (const line of entryLines) { + expect(line).toMatch(/^\t\S/) + } + }) + + it('should keep the sshCommand pointing at the Windows OpenSSH client', () => { + const config = buildLocalGitconfigWindows('C:/foo/op-ssh-sign.exe') + + expect(config).toContain('sshCommand = C:/Windows/System32/OpenSSH/ssh.exe') + }) +}) diff --git a/packages/setup/src/setup-dot-config.linux.l1.spec.ts b/packages/setup/src/setup-dot-config.linux.l1.spec.ts new file mode 100644 index 00000000..1137f397 --- /dev/null +++ b/packages/setup/src/setup-dot-config.linux.l1.spec.ts @@ -0,0 +1,104 @@ +import { + buildLocalGitconfigLinux, + needsSymlinkUpdate, + parseLinkManifest, + resolveOpSshSignCandidatesLinux, +} from './setup-dot-config.linux' + +describe(parseLinkManifest, () => { + it('should parse an array of home-relative paths', () => { + const raw = JSON.stringify(['.config/starship.toml', '.gitconfig']) + + expect(parseLinkManifest(raw)).toStrictEqual([ + '.config/starship.toml', + '.gitconfig', + ]) + }) + + it('should parse object entries mapping a Windows path to a different Linux path', () => { + const raw = JSON.stringify([ + { + windows: 'Documents/PowerShell/Microsoft.PowerShell_profile.ps1', + linux: '.config/powershell/Microsoft.PowerShell_profile.ps1', + }, + ]) + + expect(parseLinkManifest(raw)).toStrictEqual([ + { + windows: 'Documents/PowerShell/Microsoft.PowerShell_profile.ps1', + linux: '.config/powershell/Microsoft.PowerShell_profile.ps1', + }, + ]) + }) + + it('should throw for a manifest that is not an array of link entries', () => { + const raw = JSON.stringify({ foo: 'bar' }) + + expect(() => parseLinkManifest(raw)).toThrow() + }) +}) + +describe(needsSymlinkUpdate, () => { + it('should need an update when no symlink exists yet', () => { + expect(needsSymlinkUpdate(undefined, '/mnt/c/Users/foo/bar')).toBe(true) + }) + + it('should need an update when the symlink points elsewhere', () => { + expect( + needsSymlinkUpdate('/mnt/c/Users/foo/old', '/mnt/c/Users/foo/bar'), + ).toBe(true) + }) + + it('should not need an update when the symlink already points at the desired target', () => { + expect( + needsSymlinkUpdate('/mnt/c/Users/foo/bar', '/mnt/c/Users/foo/bar'), + ).toBe(false) + }) +}) + +describe(resolveOpSshSignCandidatesLinux, () => { + it('should list the direct 1Password install path first', () => { + const [firstCandidate] = resolveOpSshSignCandidatesLinux('/mnt/c/Users/foo') + + expect(firstCandidate).toBe( + '/mnt/c/Users/foo/AppData/Local/1Password/app/8/op-ssh-sign-wsl', + ) + }) + + it('should list the WindowsApps store path (with .exe extension) second', () => { + const candidates = resolveOpSshSignCandidatesLinux('/mnt/c/Users/foo') + + expect(candidates[1]).toBe( + '/mnt/c/Users/foo/AppData/Local/Microsoft/WindowsApps/op-ssh-sign-wsl.exe', + ) + }) +}) + +describe(buildLocalGitconfigLinux, () => { + it('should include linux.gitconfig before the machine-specific ssh-sign path', () => { + const config = buildLocalGitconfigLinux('/mnt/c/foo/op-ssh-sign-wsl') + + const includeIndex = config.indexOf('path = linux.gitconfig') + const sshSignIndex = config.indexOf('/mnt/c/foo/op-ssh-sign-wsl') + + expect(includeIndex).toBeGreaterThanOrEqual(0) + expect(sshSignIndex).toBeGreaterThan(includeIndex) + }) + + it('should indent every entry with a tab, matching how git config writes files', () => { + const config = buildLocalGitconfigLinux('/mnt/c/foo/op-ssh-sign-wsl') + + const entryLines = config.split('\n').filter((line) => line.includes(' = ')) + + expect(entryLines).not.toHaveLength(0) + for (const line of entryLines) { + expect(line).toMatch(/^\t\S/) + } + }) + + it('should not set core.sshCommand (that belongs in the tracked linux.gitconfig)', () => { + const config = buildLocalGitconfigLinux('/mnt/c/foo/op-ssh-sign-wsl') + + expect(config).not.toContain('sshCommand') + }) +}) diff --git a/packages/setup/src/setup-dot-config.linux.ts b/packages/setup/src/setup-dot-config.linux.ts new file mode 100644 index 00000000..298eb90f --- /dev/null +++ b/packages/setup/src/setup-dot-config.linux.ts @@ -0,0 +1,231 @@ +import path from 'node:path' +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { assertNotNil, safeTryAsync } from '@desselbane/ts-helpers' +import { confirm } from '@inquirer/prompts' +import { z } from 'zod' +import { + cleanExit, + contentDiffers, + execSync, + execSyncCapture, + findExistingPath, +} from './helper' + +function getHome() { + assertNotNil(process.env.HOME) + + return process.env.HOME +} + +const linkManifestSchema = z.array( + z.union([z.string(), z.object({ windows: z.string(), linux: z.string() })]), +) + +export type LinkManifestEntry = z.infer[number] + +/** +Resolves the Windows home directory as a WSL path (e.g. `/mnt/c/Users/foo`). +*/ +export function resolveWindowsHome() { + return execSyncCapture(`wslpath "$(cmd.exe /c 'echo %USERPROFILE%')"`) +} + +/** +Parses the `wsl-links.json` link manifest into home-relative link entries. +A string entry uses the same path on both sides; an object entry maps a Windows source path to a different Linux link path. +*/ +export function parseLinkManifest(raw: string) { + return linkManifestSchema.parse(JSON.parse(raw)) +} + +/** +Whether the symlink at {@link currentLinkTarget} (`undefined` if it doesn't exist as a symlink) +needs to be (re)created to point at {@link desiredTarget}. +*/ +export function needsSymlinkUpdate( + currentLinkTarget: string | undefined, + desiredTarget: string, +) { + return currentLinkTarget !== desiredTarget +} + +/** +Creates symlinks in the WSL home for every entry of {@link entries}, pointing into {@link winHome}. +Converges with any existing path: leaves already-correct symlinks alone, and prompts before +replacing an existing file or a symlink pointing elsewhere. +*/ +export async function createSymlinks( + winHome: string, + entries: LinkManifestEntry[], +) { + const home = getHome() + + for (const entry of entries) { + const windowsPath = typeof entry === 'string' ? entry : entry.windows + const linuxPath = typeof entry === 'string' ? entry : entry.linux + const target = path.join(winHome, windowsPath) + const linkPath = path.join(home, linuxPath) + + const linkExists = existsSync(linkPath) + const isSymlink = linkExists && lstatSync(linkPath).isSymbolicLink() + const currentLinkTarget = isSymlink ? readlinkSync(linkPath) : undefined + + if (!needsSymlinkUpdate(currentLinkTarget, target)) { + continue + } + + if (linkExists) { + console.log( + `${linkPath} already exists${isSymlink ? ` -> ${currentLinkTarget}` : ' (not a symlink)'}, desired target: ${target}`, + ) + + const replacePrompt = await safeTryAsync( + confirm({ + message: `Replace ${linkPath}?`, + default: false, + }), + ) + cleanExit(replacePrompt) + + if (!replacePrompt.data) { + continue + } + + unlinkSync(linkPath) + } + + mkdirSync(path.dirname(linkPath), { recursive: true }) + symlinkSync(target, linkPath) + } +} + +/** +Candidate paths for the 1Password ssh-sign binary used from WSL, resolved from the Windows home +and checked in order: the direct 1Password install path, then the WindowsApps store path +(where the binary carries an `.exe` extension). +*/ +export function resolveOpSshSignCandidatesLinux(winHome: string) { + const localAppData = path.posix.join(winHome, 'AppData', 'Local') + + return [ + path.posix.join(localAppData, '1Password', 'app', '8', 'op-ssh-sign-wsl'), + path.posix.join( + localAppData, + 'Microsoft', + 'WindowsApps', + 'op-ssh-sign-wsl.exe', + ), + ] +} + +/** +Builds the generated `local.gitconfig` for Linux: OS include first, then the machine-specific +1Password ssh-sign path (the `core.sshCommand` value is portable and lives in the tracked +`linux.gitconfig` instead). +*/ +export function buildLocalGitconfigLinux(opSshSignProgram: string) { + return `[include] +\tpath = linux.gitconfig +[gpg "ssh"] +\tprogram = "${opSshSignProgram}" +` +} + +/** +Regenerates the Linux `local.gitconfig` from {@link generatedContent}, converging with any +existing file: writes directly if absent, otherwise diffs and prompts overwrite/keep, printing +the full generated content on "keep" so the user can hand-merge selectively. +*/ +async function regenerateLocalGitconfigLinux(generatedContent: string) { + const localGitconfigPath = path.join(getHome(), 'local.gitconfig') + const existingContent = existsSync(localGitconfigPath) + ? readFileSync(localGitconfigPath).toString() + : undefined + + if (!contentDiffers(existingContent, generatedContent)) { + return + } + + if (existingContent == undefined) { + writeFileSync(localGitconfigPath, generatedContent) + return + } + + console.log('local.gitconfig differs from the generated content:') + console.log('--- current ---') + console.log(existingContent) + console.log('--- generated ---') + console.log(generatedContent) + + const overwritePrompt = await safeTryAsync( + confirm({ + message: 'Overwrite local.gitconfig with the generated content?', + default: false, + }), + ) + cleanExit(overwritePrompt) + + if (!overwritePrompt.data) { + console.log( + 'Keeping existing local.gitconfig. Generated content for hand-merging:', + ) + console.log(generatedContent) + return + } + + writeFileSync(localGitconfigPath, generatedContent) +} + +export async function setupDotConfigLinux() { + const shouldPrompt = await safeTryAsync( + confirm({ + message: 'Do you want to setup dotConfig?', + default: false, + }), + ) + cleanExit(shouldPrompt) + if (!shouldPrompt.data) { + return + } + + console.log('Resolving Windows home') + const winHome = resolveWindowsHome() + + console.log('Reading link manifest') + const manifestPath = path.join(winHome, '.config', 'wsl-links.json') + const entries = parseLinkManifest(readFileSync(manifestPath).toString()) + + console.log('Creating symlinks') + await createSymlinks(winHome, entries) + + console.log('Probing for op-ssh-sign-wsl') + const opSshSignProgram = findExistingPath( + resolveOpSshSignCandidatesLinux(winHome), + existsSync, + ) + + if (opSshSignProgram == undefined) { + console.log( + 'Could not find the op-ssh-sign-wsl binary in any known 1Password install location. Aborting.', + ) + cleanExit() + return + } + + console.log('Generating local config') + await regenerateLocalGitconfigLinux( + buildLocalGitconfigLinux(opSshSignProgram), + ) + + console.log('Setting pwsh as login shell') + execSync('chsh -s "$(which pwsh)"') +} diff --git a/packages/setup/src/setup-dot-config.ts b/packages/setup/src/setup-dot-config.ts index 9af413e4..66d9eae2 100644 --- a/packages/setup/src/setup-dot-config.ts +++ b/packages/setup/src/setup-dot-config.ts @@ -1,8 +1,16 @@ import path from 'node:path' -import { writeFileSync } from 'node:fs' +import { existsSync, lstatSync, readFileSync, writeFileSync } from 'node:fs' import { assertNotNil, safeTryAsync } from '@desselbane/ts-helpers' import { confirm } from '@inquirer/prompts' -import { cleanExit, execSync, wingetInstall } from './helper' +import { + cleanExit, + contentDiffers, + execSync, + execSyncCapture, + existsIncludingAppAliases, + findExistingPath, + wingetInstall, +} from './helper' assertNotNil(process.env.LOCALAPPDATA) const LOCALAPPDATA = process.env.LOCALAPPDATA @@ -20,9 +28,149 @@ process.env.PATH = [ const gitconfigPath = path.join(USERPROFILE, '.gitconfig') const localGitconfigPath = path.join(USERPROFILE, 'local.gitconfig') -const gpgSshProgram = path - .join(LOCALAPPDATA, '1Password', 'app', '8', 'op-ssh-sign.exe') - .replaceAll('\\', '/') +const dotCfgGitDir = path.join(USERPROFILE, '.dotCfg') + +/** +Builds the generated `local.gitconfig` for Windows: OS include first, then machine-specific values. +*/ +export function buildLocalGitconfigWindows(gpgSshProgram: string) { + return `[include] +\tpath = windows.gitconfig +[gpg "ssh"] +\tprogram = "${gpgSshProgram}" +[core] +\tsshCommand = C:/Windows/System32/OpenSSH/ssh.exe +` +} + +/** +Candidate install locations for the `op-ssh-sign` binary on Windows, checked in order: +the direct 1Password install path, then the WindowsApps store path. +*/ +export function resolveOpSshSignCandidatesWindows(localAppData: string) { + return [ + path + .join(localAppData, '1Password', 'app', '8', 'op-ssh-sign.exe') + .replaceAll('\\', '/'), + path + .join(localAppData, 'Microsoft', 'WindowsApps', 'op-ssh-sign.exe') + .replaceAll('\\', '/'), + ] +} + +/** +Whether `git status --porcelain` output (tracked files only, `status.showUntrackedFiles no`) +reports a dirty work tree. +*/ +export function isDotCfgDirty(porcelainOutput: string) { + return porcelainOutput.trim().length > 0 +} + +function gitDotCfgCommand(subcommand: string) { + return `git --work-tree ${USERPROFILE} --git-dir=${dotCfgGitDir} ${subcommand}` +} + +/** +Regenerates `local.gitconfig` from {@link generatedContent}, converging with any existing file: +writes directly if absent, otherwise diffs and prompts overwrite/keep, printing the full generated +content on "keep" so the user can hand-merge selectively. +*/ +async function regenerateLocalGitconfig(generatedContent: string) { + const existingContent = existsSync(localGitconfigPath) + ? readFileSync(localGitconfigPath).toString() + : undefined + + if (!contentDiffers(existingContent, generatedContent)) { + return + } + + if (existingContent == undefined) { + writeFileSync(localGitconfigPath, generatedContent) + return + } + + console.log('local.gitconfig differs from the generated content:') + console.log('--- current ---') + console.log(existingContent) + console.log('--- generated ---') + console.log(generatedContent) + + const overwritePrompt = await safeTryAsync( + confirm({ + message: 'Overwrite local.gitconfig with the generated content?', + default: false, + }), + ) + cleanExit(overwritePrompt) + + if (!overwritePrompt.data) { + console.log( + 'Keeping existing local.gitconfig. Generated content for hand-merging:', + ) + console.log(generatedContent) + return + } + + writeFileSync(localGitconfigPath, generatedContent) +} + +/** +Converges the `.dotCfg` bare-repo checkout: clones if absent, otherwise fetches; on a dirty +tracked work tree, shows the diff and prompts before stashing and force-checking-out. +Returns `false` if the user aborts on a dirty tree. +*/ +async function convergeDotCfgCheckout() { + const dotCfgExists = existsSync(dotCfgGitDir) + + if (!dotCfgExists) { + console.log('Checking out git repo') + execSync( + `git clone --bare git@github.com:DesselBane/config.git ${dotCfgGitDir}`, + ) + console.log('Done: Cloning') + + execSync(gitDotCfgCommand('checkout master -f')) + console.log('Done: Checkout') + + execSync(gitDotCfgCommand('config --local status.showUntrackedFiles no')) + console.log('Done: Config') + return true + } + + console.log('Fetching git repo') + execSync(gitDotCfgCommand('fetch')) + + const porcelainOutput = execSyncCapture( + gitDotCfgCommand('status --porcelain'), + ) + + if (!isDotCfgDirty(porcelainOutput)) { + execSync(gitDotCfgCommand('checkout master -f')) + console.log('Done: Checkout') + return true + } + + console.log('The dotCfg work tree has uncommitted changes to tracked files:') + execSync(gitDotCfgCommand('diff')) + + const stashPrompt = await safeTryAsync( + confirm({ + message: 'Stash local changes and check out master?', + default: false, + }), + ) + cleanExit(stashPrompt) + + if (!stashPrompt.data) { + console.log('Aborting dotConfig step, local changes kept.') + return false + } + + execSync(gitDotCfgCommand('stash')) + execSync(gitDotCfgCommand('checkout master -f')) + console.log('Done: Checkout') + return true +} export async function setupDotConfig() { const shouldPrompt = await safeTryAsync( @@ -71,42 +219,37 @@ export async function setupDotConfig() { console.log('Installing git') wingetInstall('Git.Git') - console.log('Generating local config') - writeFileSync( - localGitconfigPath, - `[gpg "ssh"] - program = "${gpgSshProgram}" -[core] - sshCommand = C:/Windows/System32/OpenSSH/ssh.exe -`, - ) - - console.log('Setting temporary .gitconfig') - - writeFileSync( - gitconfigPath, - `[include] - path = local.gitconfig - `, - ) - - console.log('Checking out git repo') - - execSync( - `git clone --bare git@github.com:DesselBane/config.git ${USERPROFILE}/.dotCfg`, + console.log('Probing for op-ssh-sign') + const gpgSshProgram = findExistingPath( + resolveOpSshSignCandidatesWindows(LOCALAPPDATA), + (candidate) => existsIncludingAppAliases(candidate, lstatSync), ) - console.log('Done: Cloning') + if (gpgSshProgram == undefined) { + console.log( + 'Could not find op-ssh-sign.exe in any known 1Password install location. Aborting.', + ) + cleanExit() + return + } - execSync( - `git --work-tree ${USERPROFILE} --git-dir=${USERPROFILE}/.dotCfg checkout master -f`, - ) + console.log('Generating local config') + await regenerateLocalGitconfig(buildLocalGitconfigWindows(gpgSshProgram)) - console.log('Done: Checkout') + const dotCfgExists = existsSync(dotCfgGitDir) - execSync( - `git --work-tree ${USERPROFILE} --git-dir=${USERPROFILE}/.dotCfg config --local status.showUntrackedFiles no`, - ) + if (!dotCfgExists) { + console.log('Setting temporary .gitconfig') + writeFileSync( + gitconfigPath, + `[include] + path = local.gitconfig + `, + ) + } - console.log('Done: Config') + const converged = await convergeDotCfgCheckout() + if (!converged) { + return + } }