@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.
- package/LICENSE +21 -0
- package/README.md +114 -1
- package/bin/overlay.js +11 -0
- package/dist/esm/.cache +1 -0
- package/dist/esm/bin/overlay.d.ts +1 -0
- package/dist/esm/bin/overlay.js +3 -0
- package/dist/esm/bin/parseArgs.d.ts +11 -0
- package/dist/esm/bin/parseArgs.js +36 -0
- package/dist/esm/bin/run.d.ts +2 -0
- package/dist/esm/bin/run.js +66 -0
- package/dist/esm/chezmoi/parseStatus.d.ts +6 -0
- package/dist/esm/chezmoi/parseStatus.js +19 -0
- package/dist/esm/chezmoi/readStatus.d.ts +2 -0
- package/dist/esm/chezmoi/readStatus.js +12 -0
- package/dist/esm/chezmoi/runChezmoi.d.ts +11 -0
- package/dist/esm/chezmoi/runChezmoi.js +68 -0
- package/dist/esm/chezmoi/version.d.ts +10 -0
- package/dist/esm/chezmoi/version.js +36 -0
- package/dist/esm/formatJsonError.d.ts +1 -0
- package/dist/esm/formatJsonError.js +6 -0
- package/dist/esm/formatReport.d.ts +2 -0
- package/dist/esm/formatReport.js +47 -0
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/modes/buildEntries.d.ts +9 -0
- package/dist/esm/modes/buildEntries.js +22 -0
- package/dist/esm/modes/create.d.ts +3 -0
- package/dist/esm/modes/create.js +42 -0
- package/dist/esm/modes/force.d.ts +3 -0
- package/dist/esm/modes/force.js +29 -0
- package/dist/esm/modes/verify.d.ts +3 -0
- package/dist/esm/modes/verify.js +20 -0
- package/dist/esm/overlay.d.ts +2 -0
- package/dist/esm/overlay.js +20 -0
- package/dist/esm/types.d.ts +29 -0
- package/dist/esm/types.js +0 -0
- package/dist/esm/utils/error-handling.d.ts +1 -0
- package/dist/esm/utils/error-handling.js +6 -0
- package/dist/esm/utils/pluralize.d.ts +2 -0
- package/dist/esm/utils/pluralize.js +10 -0
- package/dist/esm/version.d.ts +1 -0
- package/dist/esm/version.js +4 -0
- 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
|
-
|
|
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
|
+
}
|
package/dist/esm/.cache
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
c05d5373bd5fcaeff5cb8ae65a6e653dba2366f75d48de79c6cc6df67b7c9890
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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,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,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,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,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,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,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,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,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,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";
|
package/package.json
CHANGED
|
@@ -1,4 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@williamthorsen/overlay",
|
|
3
|
-
"version": "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
|
+
}
|