@williamthorsen/overlay 0.0.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -1
  3. package/bin/overlay.js +11 -0
  4. package/dist/esm/.cache +1 -0
  5. package/dist/esm/bin/overlay.d.ts +1 -0
  6. package/dist/esm/bin/overlay.js +3 -0
  7. package/dist/esm/bin/parseArgs.d.ts +11 -0
  8. package/dist/esm/bin/parseArgs.js +36 -0
  9. package/dist/esm/bin/run.d.ts +2 -0
  10. package/dist/esm/bin/run.js +66 -0
  11. package/dist/esm/chezmoi/parseStatus.d.ts +6 -0
  12. package/dist/esm/chezmoi/parseStatus.js +19 -0
  13. package/dist/esm/chezmoi/readStatus.d.ts +2 -0
  14. package/dist/esm/chezmoi/readStatus.js +12 -0
  15. package/dist/esm/chezmoi/runChezmoi.d.ts +11 -0
  16. package/dist/esm/chezmoi/runChezmoi.js +68 -0
  17. package/dist/esm/chezmoi/version.d.ts +10 -0
  18. package/dist/esm/chezmoi/version.js +36 -0
  19. package/dist/esm/formatJsonError.d.ts +1 -0
  20. package/dist/esm/formatJsonError.js +6 -0
  21. package/dist/esm/formatReport.d.ts +2 -0
  22. package/dist/esm/formatReport.js +47 -0
  23. package/dist/esm/index.d.ts +2 -0
  24. package/dist/esm/index.js +4 -0
  25. package/dist/esm/modes/buildEntries.d.ts +9 -0
  26. package/dist/esm/modes/buildEntries.js +22 -0
  27. package/dist/esm/modes/create.d.ts +3 -0
  28. package/dist/esm/modes/create.js +42 -0
  29. package/dist/esm/modes/force.d.ts +3 -0
  30. package/dist/esm/modes/force.js +29 -0
  31. package/dist/esm/modes/verify.d.ts +3 -0
  32. package/dist/esm/modes/verify.js +20 -0
  33. package/dist/esm/overlay.d.ts +2 -0
  34. package/dist/esm/overlay.js +20 -0
  35. package/dist/esm/types.d.ts +29 -0
  36. package/dist/esm/types.js +0 -0
  37. package/dist/esm/utils/error-handling.d.ts +1 -0
  38. package/dist/esm/utils/error-handling.js +6 -0
  39. package/dist/esm/utils/pluralize.d.ts +2 -0
  40. package/dist/esm/utils/pluralize.js +10 -0
  41. package/dist/esm/version.d.ts +1 -0
  42. package/dist/esm/version.js +4 -0
  43. package/package.json +45 -2
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 William Thorsen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1 +1,114 @@
1
- Placeholder for `@williamthorsen/overlay` — awaiting first release.
1
+ <!-- section:release-notes -->
2
+ ## Release notes — v0.2.0 (2026-06-04)
3
+
4
+ ### 🎉 Features
5
+
6
+ - Add chezmoi-backed overlay CLI with verify/create/force modes (#96)
7
+
8
+ Adds `overlay`, a command-line tool (installable as `@williamthorsen/overlay`) that brings a target directory into line with a directory of canonical scaffolding files — adding what's missing, removing what's been marked for deletion, and running any normalization steps. Three modes set how much it will change: `--verify` (the default) reports what's out of sync without touching anything; `--create` brings everything into line except files you've changed locally, which it will not overwrite; and `--force` brings everything into line and overwrites your local changes too. The same behavior is also available as a typed function for use from other TypeScript code.
9
+ <!-- /section:release-notes -->
10
+
11
+ # @williamthorsen/overlay
12
+
13
+ Overlay a canonical set of scaffolding files onto a target directory you do not control, and idempotently converge it: create what is missing, delete what is declared for removal, run normalization scripts, and refuse to silently clobber content it does not own.
14
+
15
+ Overlay is built on [chezmoi](https://www.chezmoi.io/), whose source-state model fits exactly — `dot_`-encoded filenames, `executable_`/`run_` attributes, and `--source`/`--destination` to drive any source tree into any target. Overlay adds three modes (`--verify`, `--create`, `--force`) computed from a parsed `chezmoi status`, plus one guarantee chezmoi lacks natively: `--create` never overwrites a differing managed file.
16
+
17
+ ## Installation
18
+
19
+ Overlay shells out to the `chezmoi` binary, which must be on your `PATH`:
20
+
21
+ ```bash
22
+ brew install chezmoi
23
+ ```
24
+
25
+ Install overlay for personal use via npm or a local link:
26
+
27
+ ```bash
28
+ pnpm add -g @williamthorsen/overlay
29
+ # or, from a checkout:
30
+ pnpm link --global
31
+ ```
32
+
33
+ chezmoi `2.46.0` or later is required; overlay preflights the version and exits with an actionable error otherwise.
34
+
35
+ ## Usage
36
+
37
+ ```
38
+ overlay <source-dir> [target-dir] [--verify|--create|--force] [--json] [--help]
39
+ ```
40
+
41
+ - `<source-dir>` — a chezmoi source directory describing the files the target should have.
42
+ - `[target-dir]` — the directory to converge (defaults to the current working directory).
43
+ - Modes are mutually exclusive; the default is `--verify`.
44
+ - `--json` prints the structured result instead of the text report.
45
+
46
+ ### Exit codes
47
+
48
+ | Code | Meaning |
49
+ | ---- | ------------------------------------------------------------------------------------------------------- |
50
+ | `0` | Converged / clean. |
51
+ | `1` | Drift (`--verify`) or unresolved conflicts (`--create`). |
52
+ | `2` | Hard error: chezmoi missing or below the minimum version, a `run_` script failed, or invalid arguments. |
53
+
54
+ Exit `2` is deliberately distinct from drift so callers can tell a real failure apart from "the target has drifted".
55
+
56
+ ## Modes
57
+
58
+ Each mode is computed from the **second (apply-side) column** of `chezmoi status`, whose codes overlay reads as:
59
+
60
+ | Status code | Meaning | `--verify` | `--create` | `--force` |
61
+ | ----------- | --------------------------------------------- | ------------------------------------------------------- | -------------------------------- | ----------------- |
62
+ | `A` | addition (missing in target) | report drift | create | create |
63
+ | `D` | native removal (`.chezmoiremove` / `remove_`) | report drift | delete | delete |
64
+ | `M` | differing managed file | report drift | **conflict** (never overwritten) | overwrite |
65
+ | `R` | `run_` script will run | surfaced informationally; **never affects the verdict** | run (live output) | run (live output) |
66
+
67
+ `--create` and `--force` differ only in the differing-file (`M`) column: `--create` refuses to overwrite, `--force` overwrites.
68
+
69
+ ### `--verify`
70
+
71
+ Read-only. Drift is any `A`/`M`/`D` row; overlay exits `1` if any exists, `0` otherwise. Pending `R` scripts are reported as "N script(s) would run" but never make verify fail.
72
+
73
+ `--verify` confirms **file convergence, not script execution.** It cannot know what a `run_` script would do to the target, so it reports the script as pending and moves on.
74
+
75
+ **Verify-enforceability guideline:** if you want `--verify` to enforce a deletion, express it as a chezmoi-native removal (`.chezmoiremove` or a `remove_` entry), which surfaces as a `D` row that verify counts as drift. Reserve `run_` scripts for imperative normalization that has no static target-state for verify to check.
76
+
77
+ ### Why overlay does not use `chezmoi verify` directly
78
+
79
+ Overlay runs every chezmoi invocation with a **throwaway `--persistent-state`** (a temp file discarded after each run). Because chezmoi keeps no memory across runs, it always believes `run_once_` / `run_onchange_` scripts are still pending — so `chezmoi verify` exits non-zero on a fully file-converged target. Overlay therefore parses `chezmoi status` itself and ignores the `R` rows for the verdict, which is the only way to get a clean verify on a converged target.
80
+
81
+ ### `--create`
82
+
83
+ Creates missing entries (`A`), performs native removals (`D`), runs `run_` scripts (`R`), and reports differing files (`M`) as conflicts that are **never written**. The fix-it hint suggests re-running with `--force` to overwrite.
84
+
85
+ Mechanically, overlay applies only the `A`/`D` entries by **absolute target path** (`chezmoi apply --include=files,dirs,remove -- <abs paths>`), skipping that apply entirely when no `A`/`D` entries exist — a bare apply would converge every file and clobber the `M` entries the mode exists to protect. Scripts then run in a separate `--include=scripts` pass.
86
+
87
+ ### `--force`
88
+
89
+ A full `chezmoi apply`: overwrite differing files, perform removals, run scripts. `conflicts` is always `0`.
90
+
91
+ ## How script results are surfaced
92
+
93
+ Under `--create` / `--force`, `run_` script stdout and stderr stream **live to overlay's stderr** — keeping overlay's stdout clean for the text report or `--json`. `OverlayResult.scripts` records `{ ran, ok }`. A script that exits non-zero aborts chezmoi's apply; overlay maps that to exit `2` and surfaces chezmoi's diagnostic. chezmoi provides no per-script structured output, so overlay does not invent any.
94
+
95
+ ## Idempotency contract
96
+
97
+ Because each invocation uses a throwaway persistent-state, chezmoi keeps no cross-run memory: `run_once_` / `run_onchange_` scripts effectively run on **every** `--create` / `--force`. Source `run_` scripts must therefore be **idempotent**. This is intentional — it keeps runs isolated, pollutes no host state, and behaves predictably across targets.
98
+
99
+ ## Source-state filename grammar
100
+
101
+ Overlay uses chezmoi-native source-state filenames: `dot_` (a leading dot), `executable_` (executable bit), and `run_` (scripts, including `run_once_` / `run_onchange_` / `run_before_` / `run_after_`). Symlink overlays (`symlink_`) are deferred to a future `--link`.
102
+
103
+ ## Programmatic use
104
+
105
+ The CLI is a thin shell over an importable core:
106
+
107
+ ```ts
108
+ import { overlay } from '@williamthorsen/overlay';
109
+
110
+ const result = await overlay({ source: './scaffold', target: './repo', mode: 'create' });
111
+ // result: { mode, entries, scripts, counts, exitCode }
112
+ ```
113
+
114
+ `overlay(options)` returns a structured `OverlayResult` — never printed text — so other TypeScript code can compose with it.
package/bin/overlay.js ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ try {
3
+ await import('../dist/esm/bin/overlay.js');
4
+ } catch (error) {
5
+ if (error.code === 'ERR_MODULE_NOT_FOUND') {
6
+ process.stderr.write('overlay: build output not found — run `pnpm run build` first\n');
7
+ } else {
8
+ process.stderr.write(`overlay: failed to load: ${error.message}\n`);
9
+ }
10
+ process.exit(1);
11
+ }
@@ -0,0 +1 @@
1
+ c05d5373bd5fcaeff5cb8ae65a6e653dba2366f75d48de79c6cc6df67b7c9890
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import process from "node:process";
2
+ import { run } from "./run.js";
3
+ process.exit(await run(process.argv.slice(2)));
@@ -0,0 +1,11 @@
1
+ import type { OverlayMode } from '../types.ts';
2
+ export type ParsedCommand = {
3
+ kind: 'help';
4
+ } | {
5
+ kind: 'run';
6
+ source: string;
7
+ target: string | undefined;
8
+ mode: OverlayMode;
9
+ json: boolean;
10
+ };
11
+ export declare function parseArgs(argv: string[]): ParsedCommand;
@@ -0,0 +1,36 @@
1
+ import { parseArgs as nodeParseArgs } from "node:util";
2
+ function parseArgs(argv) {
3
+ const { values, positionals } = nodeParseArgs({
4
+ args: argv,
5
+ allowPositionals: true,
6
+ strict: true,
7
+ options: {
8
+ verify: { type: "boolean" },
9
+ create: { type: "boolean" },
10
+ force: { type: "boolean" },
11
+ json: { type: "boolean" },
12
+ help: { type: "boolean", short: "h" }
13
+ }
14
+ });
15
+ if (values.help === true) {
16
+ return { kind: "help" };
17
+ }
18
+ const mode = resolveMode(values.verify === true, values.create === true, values.force === true);
19
+ const [source, target] = positionals;
20
+ if (source === void 0) {
21
+ throw new Error("missing required argument: <source-dir>");
22
+ }
23
+ return { kind: "run", source, target, mode, json: values.json === true };
24
+ }
25
+ function resolveMode(verify, create, force) {
26
+ const selected = [verify ? "verify" : void 0, create ? "create" : void 0, force ? "force" : void 0].filter(
27
+ (value) => value !== void 0
28
+ );
29
+ if (selected.length > 1) {
30
+ throw new Error("choose only one of --verify, --create, --force");
31
+ }
32
+ return selected[0] ?? "verify";
33
+ }
34
+ export {
35
+ parseArgs
36
+ };
@@ -0,0 +1,2 @@
1
+ export declare const HELP = "overlay \u2014 overlay a chezmoi source tree onto a target directory\n\nUsage:\n overlay <source-dir> [target-dir] [--verify|--create|--force] [--json] [--help]\n\nArguments:\n <source-dir> chezmoi source directory describing the files the target should have.\n [target-dir] Directory to converge (default: current working directory).\n\nModes (mutually exclusive; default --verify):\n --verify Read-only. Report drift (missing, differing, or to-be-removed files)\n and exit non-zero if any exists. Pending run_ scripts are surfaced\n but never affect the verdict; verify confirms file convergence, not\n script execution.\n --create Create missing files, perform native removals, run run_ scripts, and\n report differing files as conflicts without overwriting them.\n --force Full convergence: overwrite differing files, perform removals, run\n run_ scripts.\n\nOptions:\n --json Print the structured result as JSON instead of the text report.\n -h, --help Show this help.\n\nExit codes:\n 0 Converged / clean.\n 1 Drift (verify) or unresolved conflicts (create).\n 2 Hard error: chezmoi missing or below the minimum version, a script failed, or\n invalid arguments.\n";
2
+ export declare function run(argv: string[]): Promise<number>;
@@ -0,0 +1,66 @@
1
+ import process from "node:process";
2
+ import { formatJsonError } from "../formatJsonError.js";
3
+ import { formatReport } from "../formatReport.js";
4
+ import { overlay } from "../overlay.js";
5
+ import { extractMessage } from "../utils/error-handling.js";
6
+ import { parseArgs } from "./parseArgs.js";
7
+ const HELP = `overlay \u2014 overlay a chezmoi source tree onto a target directory
8
+
9
+ Usage:
10
+ overlay <source-dir> [target-dir] [--verify|--create|--force] [--json] [--help]
11
+
12
+ Arguments:
13
+ <source-dir> chezmoi source directory describing the files the target should have.
14
+ [target-dir] Directory to converge (default: current working directory).
15
+
16
+ Modes (mutually exclusive; default --verify):
17
+ --verify Read-only. Report drift (missing, differing, or to-be-removed files)
18
+ and exit non-zero if any exists. Pending run_ scripts are surfaced
19
+ but never affect the verdict; verify confirms file convergence, not
20
+ script execution.
21
+ --create Create missing files, perform native removals, run run_ scripts, and
22
+ report differing files as conflicts without overwriting them.
23
+ --force Full convergence: overwrite differing files, perform removals, run
24
+ run_ scripts.
25
+
26
+ Options:
27
+ --json Print the structured result as JSON instead of the text report.
28
+ -h, --help Show this help.
29
+
30
+ Exit codes:
31
+ 0 Converged / clean.
32
+ 1 Drift (verify) or unresolved conflicts (create).
33
+ 2 Hard error: chezmoi missing or below the minimum version, a script failed, or
34
+ invalid arguments.
35
+ `;
36
+ async function run(argv) {
37
+ try {
38
+ const command = parseArgs(argv);
39
+ if (command.kind === "help") {
40
+ process.stdout.write(HELP);
41
+ return 0;
42
+ }
43
+ const result = await overlay({
44
+ source: command.source,
45
+ mode: command.mode,
46
+ ...command.target === void 0 ? {} : { target: command.target }
47
+ });
48
+ if (command.json) {
49
+ process.stdout.write(`${JSON.stringify(result)}
50
+ `);
51
+ } else {
52
+ process.stdout.write(`${formatReport(result)}
53
+ `);
54
+ }
55
+ return result.exitCode;
56
+ } catch (error) {
57
+ const message = extractMessage(error);
58
+ process.stderr.write(`${formatJsonError(message)}
59
+ `);
60
+ return 2;
61
+ }
62
+ }
63
+ export {
64
+ HELP,
65
+ run
66
+ };
@@ -0,0 +1,6 @@
1
+ export type StatusCode = 'A' | 'M' | 'D' | 'R';
2
+ export interface StatusEntry {
3
+ path: string;
4
+ code: StatusCode;
5
+ }
6
+ export declare function parseStatus(stdout: string): StatusEntry[];
@@ -0,0 +1,19 @@
1
+ const APPLY_CODES = /* @__PURE__ */ new Set(["A", "M", "D", "R"]);
2
+ function parseStatus(stdout) {
3
+ const entries = [];
4
+ for (const line of stdout.split("\n")) {
5
+ if (line.length < 4) continue;
6
+ const code = line[1];
7
+ if (code === void 0 || !isStatusCode(code)) continue;
8
+ const path = line.slice(3).trim();
9
+ if (path === "") continue;
10
+ entries.push({ path, code });
11
+ }
12
+ return entries;
13
+ }
14
+ function isStatusCode(value) {
15
+ return APPLY_CODES.has(value);
16
+ }
17
+ export {
18
+ parseStatus
19
+ };
@@ -0,0 +1,2 @@
1
+ import type { ChezmoiContext } from './runChezmoi.ts';
2
+ export declare function readStatus(context: ChezmoiContext): Promise<string>;
@@ -0,0 +1,12 @@
1
+ import { runChezmoiCaptured } from "./runChezmoi.js";
2
+ async function readStatus(context) {
3
+ const { stdout, stderr, code } = await runChezmoiCaptured(context, ["status"]);
4
+ if (code !== 0) {
5
+ const detail = stderr.trim() === "" ? `exit code ${code}` : stderr.trim();
6
+ throw new Error(`chezmoi status failed: ${detail}`);
7
+ }
8
+ return stdout;
9
+ }
10
+ export {
11
+ readStatus
12
+ };
@@ -0,0 +1,11 @@
1
+ export interface CapturedResult {
2
+ stdout: string;
3
+ stderr: string;
4
+ code: number;
5
+ }
6
+ export interface ChezmoiContext {
7
+ source: string;
8
+ target: string;
9
+ }
10
+ export declare function runChezmoiCaptured(context: ChezmoiContext, args: string[]): Promise<CapturedResult>;
11
+ export declare function runChezmoiStreamed(context: ChezmoiContext, args: string[]): Promise<number>;
@@ -0,0 +1,68 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+ import { promisify } from "node:util";
7
+ const execFileAsync = promisify(execFile);
8
+ function buildArgs(context, persistentStatePath, configPath, args) {
9
+ return [
10
+ `--source=${context.source}`,
11
+ `--destination=${context.target}`,
12
+ `--persistent-state=${persistentStatePath}`,
13
+ `--config=${configPath}`,
14
+ "--no-tty",
15
+ ...args
16
+ ];
17
+ }
18
+ async function withSandbox(context, args, body) {
19
+ const sandboxDir = await mkdtemp(path.join(tmpdir(), "overlay-chezmoi-"));
20
+ const persistentStatePath = path.join(sandboxDir, "state.boltdb");
21
+ const configPath = path.join(sandboxDir, "chezmoi.toml");
22
+ try {
23
+ await writeFile(configPath, "");
24
+ const fullArgs = buildArgs(context, persistentStatePath, configPath, args);
25
+ return await body(fullArgs);
26
+ } finally {
27
+ await rm(sandboxDir, { recursive: true, force: true });
28
+ }
29
+ }
30
+ async function runChezmoiCaptured(context, args) {
31
+ return withSandbox(context, args, async (fullArgs) => {
32
+ try {
33
+ const { stdout, stderr } = await execFileAsync("chezmoi", fullArgs);
34
+ return { stdout, stderr, code: 0 };
35
+ } catch (error) {
36
+ return interpretExecFileError(error);
37
+ }
38
+ });
39
+ }
40
+ async function runChezmoiStreamed(context, args) {
41
+ return withSandbox(context, args, async (fullArgs) => {
42
+ return new Promise((resolve, reject) => {
43
+ const child = spawn("chezmoi", fullArgs, { stdio: ["ignore", process.stderr, process.stderr] });
44
+ child.on("error", reject);
45
+ child.on("close", (code) => resolve(code ?? 1));
46
+ });
47
+ });
48
+ }
49
+ function interpretExecFileError(error) {
50
+ if (isExecFileError(error)) {
51
+ if (error.code === "ENOENT") {
52
+ throw new Error("chezmoi not found on PATH \u2014 install it (e.g. `brew install chezmoi`)");
53
+ }
54
+ return {
55
+ stdout: typeof error.stdout === "string" ? error.stdout : "",
56
+ stderr: typeof error.stderr === "string" ? error.stderr : "",
57
+ code: typeof error.code === "number" ? error.code : 1
58
+ };
59
+ }
60
+ throw error;
61
+ }
62
+ function isExecFileError(error) {
63
+ return typeof error === "object" && error !== null && "code" in error;
64
+ }
65
+ export {
66
+ runChezmoiCaptured,
67
+ runChezmoiStreamed
68
+ };
@@ -0,0 +1,10 @@
1
+ import type { ChezmoiContext } from './runChezmoi.ts';
2
+ interface SemverParts {
3
+ major: number;
4
+ minor: number;
5
+ patch: number;
6
+ }
7
+ export declare const MIN_CHEZMOI_VERSION: string;
8
+ export declare function assertChezmoiVersion(context: ChezmoiContext): Promise<void>;
9
+ export declare function parseVersion(text: string): SemverParts | undefined;
10
+ export {};
@@ -0,0 +1,36 @@
1
+ import { runChezmoiCaptured } from "./runChezmoi.js";
2
+ const MINIMUM = { major: 2, minor: 46, patch: 0 };
3
+ const MIN_CHEZMOI_VERSION = formatVersion(MINIMUM);
4
+ async function assertChezmoiVersion(context) {
5
+ const { stdout, code } = await runChezmoiCaptured(context, ["--version"]);
6
+ if (code !== 0) {
7
+ throw new Error("chezmoi is not available \u2014 install it (e.g. `brew install chezmoi`)");
8
+ }
9
+ const installed = parseVersion(stdout);
10
+ if (installed === void 0) {
11
+ throw new Error(`could not determine chezmoi version from: ${stdout.trim()}`);
12
+ }
13
+ if (compareVersions(installed, MINIMUM) < 0) {
14
+ throw new Error(`chezmoi ${MIN_CHEZMOI_VERSION} or later is required; found ${formatVersion(installed)}`);
15
+ }
16
+ }
17
+ function parseVersion(text) {
18
+ const match = text.match(/(\d+)\.(\d+)\.(\d+)/);
19
+ if (match === null) return void 0;
20
+ const [, major, minor, patch] = match;
21
+ if (major === void 0 || minor === void 0 || patch === void 0) return void 0;
22
+ return { major: Number(major), minor: Number(minor), patch: Number(patch) };
23
+ }
24
+ function compareVersions(a, b) {
25
+ if (a.major !== b.major) return a.major - b.major;
26
+ if (a.minor !== b.minor) return a.minor - b.minor;
27
+ return a.patch - b.patch;
28
+ }
29
+ function formatVersion(parts) {
30
+ return `${parts.major}.${parts.minor}.${parts.patch}`;
31
+ }
32
+ export {
33
+ MIN_CHEZMOI_VERSION,
34
+ assertChezmoiVersion,
35
+ parseVersion
36
+ };
@@ -0,0 +1 @@
1
+ export declare function formatJsonError(message: string): string;
@@ -0,0 +1,6 @@
1
+ function formatJsonError(message) {
2
+ return JSON.stringify({ error: message });
3
+ }
4
+ export {
5
+ formatJsonError
6
+ };
@@ -0,0 +1,2 @@
1
+ import type { OverlayResult } from './types.ts';
2
+ export declare function formatReport(result: OverlayResult): string;
@@ -0,0 +1,47 @@
1
+ import { pluralizeWithCount } from "./utils/pluralize.js";
2
+ function formatReport(result) {
3
+ const lines = [];
4
+ for (const entry of result.entries) {
5
+ lines.push(` ${OUTCOME_LABELS[entry.outcome]} ${entry.path}`);
6
+ }
7
+ if (result.entries.length > 0) {
8
+ lines.push("");
9
+ }
10
+ lines.push(summarizeCounts(result), summarizeScripts(result));
11
+ if (result.counts.conflicts > 0) {
12
+ lines.push("", "Conflicts left untouched. Re-run with `overlay --force` to overwrite differing files.");
13
+ }
14
+ return lines.join("\n");
15
+ }
16
+ const OUTCOME_LABELS = {
17
+ created: "create ",
18
+ deleted: "delete ",
19
+ forced: "force ",
20
+ conflict: "conflict"
21
+ };
22
+ function summarizeCounts(result) {
23
+ if (result.mode === "verify") {
24
+ if (result.counts.pending === 0) {
25
+ return "Target is converged: no drift.";
26
+ }
27
+ return `Drift: ${pluralizeWithCount(result.counts.pending, "entry", "entries")}.`;
28
+ }
29
+ const parts = [
30
+ result.counts.created > 0 ? `${result.counts.created} created` : void 0,
31
+ result.counts.deleted > 0 ? `${result.counts.deleted} deleted` : void 0,
32
+ result.counts.forced > 0 ? `${result.counts.forced} forced` : void 0,
33
+ result.counts.conflicts > 0 ? pluralizeWithCount(result.counts.conflicts, "conflict") : void 0
34
+ ].filter((part) => part !== void 0);
35
+ if (parts.length === 0) {
36
+ return "Nothing to do.";
37
+ }
38
+ return parts.join(", ") + ".";
39
+ }
40
+ function summarizeScripts(result) {
41
+ const verb = result.mode === "verify" ? "would run" : "ran";
42
+ const status = result.scripts.ok ? "" : " (a script failed)";
43
+ return `${pluralizeWithCount(result.scripts.ran, "script")} ${verb}${status}.`;
44
+ }
45
+ export {
46
+ formatReport
47
+ };
@@ -0,0 +1,2 @@
1
+ export { overlay } from './overlay.ts';
2
+ export type { EntryOutcome, OverlayCounts, OverlayEntry, OverlayMode, OverlayOptions, OverlayResult, ScriptsSummary, } from './types.ts';
@@ -0,0 +1,4 @@
1
+ import { overlay } from "./overlay.js";
2
+ export {
3
+ overlay
4
+ };
@@ -0,0 +1,9 @@
1
+ import type { StatusCode, StatusEntry } from '../chezmoi/parseStatus.ts';
2
+ import type { EntryOutcome, OverlayEntry } from '../types.ts';
3
+ export type OutcomeMap = Partial<Record<StatusCode, EntryOutcome>>;
4
+ export interface PartitionedStatus {
5
+ entries: OverlayEntry[];
6
+ pendingScripts: number;
7
+ }
8
+ export declare function partitionStatus(status: StatusEntry[], outcomes: OutcomeMap): PartitionedStatus;
9
+ export declare function countOutcome(entries: OverlayEntry[], outcome: EntryOutcome): number;
@@ -0,0 +1,22 @@
1
+ function partitionStatus(status, outcomes) {
2
+ const entries = [];
3
+ let pendingScripts = 0;
4
+ for (const entry of status) {
5
+ if (entry.code === "R") {
6
+ pendingScripts += 1;
7
+ continue;
8
+ }
9
+ const outcome = outcomes[entry.code];
10
+ if (outcome !== void 0) {
11
+ entries.push({ path: entry.path, outcome });
12
+ }
13
+ }
14
+ return { entries, pendingScripts };
15
+ }
16
+ function countOutcome(entries, outcome) {
17
+ return entries.filter((entry) => entry.outcome === outcome).length;
18
+ }
19
+ export {
20
+ countOutcome,
21
+ partitionStatus
22
+ };
@@ -0,0 +1,3 @@
1
+ import type { ChezmoiContext } from '../chezmoi/runChezmoi.ts';
2
+ import type { OverlayResult } from '../types.ts';
3
+ export declare function runCreate(context: ChezmoiContext): Promise<OverlayResult>;
@@ -0,0 +1,42 @@
1
+ import path from "node:path";
2
+ import { parseStatus } from "../chezmoi/parseStatus.js";
3
+ import { readStatus } from "../chezmoi/readStatus.js";
4
+ import { runChezmoiStreamed } from "../chezmoi/runChezmoi.js";
5
+ import { countOutcome, partitionStatus } from "./buildEntries.js";
6
+ async function runCreate(context) {
7
+ const { entries, pendingScripts } = partitionStatus(parseStatus(await readStatus(context)), {
8
+ A: "created",
9
+ D: "deleted",
10
+ M: "conflict"
11
+ });
12
+ const applyPaths = entries.filter((entry) => entry.outcome === "created" || entry.outcome === "deleted").map((entry) => path.join(context.target, entry.path));
13
+ const conflicts = countOutcome(entries, "conflict");
14
+ const counts = {
15
+ created: countOutcome(entries, "created"),
16
+ deleted: countOutcome(entries, "deleted"),
17
+ forced: 0,
18
+ conflicts,
19
+ pending: 0
20
+ };
21
+ const applyCode = applyPaths.length > 0 ? await runChezmoiStreamed(context, ["apply", "--include=files,dirs,remove", "--", ...applyPaths]) : 0;
22
+ if (applyCode !== 0) {
23
+ return { mode: "create", entries, scripts: { ran: pendingScripts, ok: false }, counts, exitCode: 2 };
24
+ }
25
+ const scriptsCode = pendingScripts > 0 ? await runChezmoiStreamed(context, ["apply", "--include=scripts"]) : 0;
26
+ const ok = scriptsCode === 0;
27
+ return {
28
+ mode: "create",
29
+ entries,
30
+ scripts: { ran: pendingScripts, ok },
31
+ counts,
32
+ exitCode: computeExitCode(ok, conflicts)
33
+ };
34
+ }
35
+ function computeExitCode(ok, conflicts) {
36
+ if (!ok) return 2;
37
+ if (conflicts > 0) return 1;
38
+ return 0;
39
+ }
40
+ export {
41
+ runCreate
42
+ };
@@ -0,0 +1,3 @@
1
+ import type { ChezmoiContext } from '../chezmoi/runChezmoi.ts';
2
+ import type { OverlayResult } from '../types.ts';
3
+ export declare function runForce(context: ChezmoiContext): Promise<OverlayResult>;
@@ -0,0 +1,29 @@
1
+ import { parseStatus } from "../chezmoi/parseStatus.js";
2
+ import { readStatus } from "../chezmoi/readStatus.js";
3
+ import { runChezmoiStreamed } from "../chezmoi/runChezmoi.js";
4
+ import { countOutcome, partitionStatus } from "./buildEntries.js";
5
+ async function runForce(context) {
6
+ const { entries, pendingScripts } = partitionStatus(parseStatus(await readStatus(context)), {
7
+ A: "created",
8
+ D: "deleted",
9
+ M: "forced"
10
+ });
11
+ const applyCode = await runChezmoiStreamed(context, ["apply"]);
12
+ const ok = applyCode === 0;
13
+ return {
14
+ mode: "force",
15
+ entries,
16
+ scripts: { ran: pendingScripts, ok },
17
+ counts: {
18
+ created: countOutcome(entries, "created"),
19
+ deleted: countOutcome(entries, "deleted"),
20
+ forced: countOutcome(entries, "forced"),
21
+ conflicts: 0,
22
+ pending: 0
23
+ },
24
+ exitCode: ok ? 0 : 2
25
+ };
26
+ }
27
+ export {
28
+ runForce
29
+ };
@@ -0,0 +1,3 @@
1
+ import type { ChezmoiContext } from '../chezmoi/runChezmoi.ts';
2
+ import type { OverlayResult } from '../types.ts';
3
+ export declare function runVerify(context: ChezmoiContext): Promise<OverlayResult>;
@@ -0,0 +1,20 @@
1
+ import { parseStatus } from "../chezmoi/parseStatus.js";
2
+ import { readStatus } from "../chezmoi/readStatus.js";
3
+ import { partitionStatus } from "./buildEntries.js";
4
+ async function runVerify(context) {
5
+ const { entries, pendingScripts } = partitionStatus(parseStatus(await readStatus(context)), {
6
+ A: "created",
7
+ D: "deleted",
8
+ M: "conflict"
9
+ });
10
+ return {
11
+ mode: "verify",
12
+ entries,
13
+ scripts: { ran: pendingScripts, ok: true },
14
+ counts: { created: 0, deleted: 0, forced: 0, conflicts: 0, pending: entries.length },
15
+ exitCode: entries.length > 0 ? 1 : 0
16
+ };
17
+ }
18
+ export {
19
+ runVerify
20
+ };
@@ -0,0 +1,2 @@
1
+ import type { OverlayOptions, OverlayResult } from './types.ts';
2
+ export declare function overlay(options: OverlayOptions): Promise<OverlayResult>;
@@ -0,0 +1,20 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { assertChezmoiVersion } from "./chezmoi/version.js";
4
+ import { runCreate } from "./modes/create.js";
5
+ import { runForce } from "./modes/force.js";
6
+ import { runVerify } from "./modes/verify.js";
7
+ async function overlay(options) {
8
+ const mode = options.mode ?? "verify";
9
+ const context = {
10
+ source: path.resolve(options.source),
11
+ target: path.resolve(options.target ?? process.cwd())
12
+ };
13
+ await assertChezmoiVersion(context);
14
+ if (mode === "create") return runCreate(context);
15
+ if (mode === "force") return runForce(context);
16
+ return runVerify(context);
17
+ }
18
+ export {
19
+ overlay
20
+ };
@@ -0,0 +1,29 @@
1
+ export type OverlayMode = 'verify' | 'create' | 'force';
2
+ export interface OverlayOptions {
3
+ source: string;
4
+ target?: string;
5
+ mode?: OverlayMode;
6
+ }
7
+ export type EntryOutcome = 'created' | 'deleted' | 'forced' | 'conflict';
8
+ export interface OverlayEntry {
9
+ path: string;
10
+ outcome: EntryOutcome;
11
+ }
12
+ export interface ScriptsSummary {
13
+ ran: number;
14
+ ok: boolean;
15
+ }
16
+ export interface OverlayCounts {
17
+ created: number;
18
+ deleted: number;
19
+ forced: number;
20
+ conflicts: number;
21
+ pending: number;
22
+ }
23
+ export interface OverlayResult {
24
+ mode: OverlayMode;
25
+ entries: OverlayEntry[];
26
+ scripts: ScriptsSummary;
27
+ counts: OverlayCounts;
28
+ exitCode: 0 | 1 | 2;
29
+ }
File without changes
@@ -0,0 +1 @@
1
+ export declare function extractMessage(error: unknown): string;
@@ -0,0 +1,6 @@
1
+ function extractMessage(error) {
2
+ return error instanceof Error ? error.message : String(error);
3
+ }
4
+ export {
5
+ extractMessage
6
+ };
@@ -0,0 +1,2 @@
1
+ export declare function pluralize(count: number, singular: string, plural?: string): string;
2
+ export declare function pluralizeWithCount(count: number, singular: string, plural?: string): string;
@@ -0,0 +1,10 @@
1
+ function pluralize(count, singular, plural = `${singular}s`) {
2
+ return Math.abs(count) === 1 ? singular : plural;
3
+ }
4
+ function pluralizeWithCount(count, singular, plural = `${singular}s`) {
5
+ return `${count} ${pluralize(count, singular, plural)}`;
6
+ }
7
+ export {
8
+ pluralize,
9
+ pluralizeWithCount
10
+ };
@@ -0,0 +1 @@
1
+ export declare const VERSION = "0.2.0";
@@ -0,0 +1,4 @@
1
+ const VERSION = "0.2.0";
2
+ export {
3
+ VERSION
4
+ };
package/package.json CHANGED
@@ -1,4 +1,47 @@
1
1
  {
2
2
  "name": "@williamthorsen/overlay",
3
- "version": "0.0.0"
4
- }
3
+ "version": "0.2.0",
4
+ "private": false,
5
+ "description": "Idempotently overlay a canonical set of scaffolding files onto a target directory, backed by chezmoi",
6
+ "keywords": [
7
+ "chezmoi",
8
+ "overlay",
9
+ "scaffold",
10
+ "verify"
11
+ ],
12
+ "homepage": "https://github.com/williamthorsen/workshop/tree/main/packages/overlay#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/williamthorsen/workshop/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/williamthorsen/workshop.git",
19
+ "directory": "packages/overlay"
20
+ },
21
+ "license": "MIT",
22
+ "author": "William Thorsen <william@thorsen.dev> (https://github.com/williamthorsen)",
23
+ "sideEffects": [
24
+ "./dist/esm/bin/overlay.js"
25
+ ],
26
+ "type": "module",
27
+ "exports": {
28
+ ".": {
29
+ "import": "./dist/esm/index.js",
30
+ "types": "./dist/esm/index.d.ts"
31
+ }
32
+ },
33
+ "bin": {
34
+ "overlay": "bin/overlay.js"
35
+ },
36
+ "files": [
37
+ "bin",
38
+ "dist"
39
+ ],
40
+ "engines": {
41
+ "node": ">=20.6.0"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "scripts": {}
47
+ }