@cldmv/git-embedded 1.0.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 +201 -0
- package/README.md +145 -0
- package/bin/git-embedded.mjs +185 -0
- package/docs/design.md +122 -0
- package/docs/use-case-private-tests.md +97 -0
- package/hooks/_dispatch.template +67 -0
- package/hooks/reference-transaction +54 -0
- package/hooks/update-embedded-repos +56 -0
- package/messages/setup-bare-githooks.md +25 -0
- package/messages/setup-dispatcher-canonical-complete.md +30 -0
- package/messages/setup-dispatcher-missing-symlinks.md +56 -0
- package/messages/setup-dispatcher-non-conforming.md +35 -0
- package/messages/setup-husky.md +35 -0
- package/messages/setup-init-templatedir.md +21 -0
- package/messages/setup-lefthook.md +48 -0
- package/messages/setup-none.md +27 -0
- package/messages/setup-pre-commit.md +47 -0
- package/messages/setup-simple-git-hooks.md +40 -0
- package/messages/setup-system-hookspath.md +25 -0
- package/package.json +96 -0
- package/src/api/cli/doctor.mjs +15 -0
- package/src/api/cli/init.mjs +26 -0
- package/src/api/cli/install-hooks.mjs +124 -0
- package/src/api/cli/install-template.mjs +43 -0
- package/src/api/cli/link.mjs +42 -0
- package/src/api/cli/print-hook-script.mjs +35 -0
- package/src/api/cli/uninstall-hooks.mjs +21 -0
- package/src/api/cli/version.mjs +17 -0
- package/src/api/commander/custom-help.mjs +249 -0
- package/src/api/detect/dispatcher.mjs +218 -0
- package/src/api/detect/husky.mjs +23 -0
- package/src/api/detect/lefthook.mjs +37 -0
- package/src/api/detect/pre-commit.mjs +35 -0
- package/src/api/detect/run.mjs +71 -0
- package/src/api/detect/simple-git-hooks.mjs +26 -0
- package/src/api/git.mjs +59 -0
- package/src/api/install/dispatcher.mjs +51 -0
- package/src/api/install/hooks.mjs +80 -0
- package/src/api/install/template.mjs +15 -0
- package/src/api/link/batch.mjs +130 -0
- package/src/api/link/copy-executable.mjs +27 -0
- package/src/api/link/elevate-windows.mjs +52 -0
- package/src/api/log.mjs +38 -0
- package/src/api/messages/load.mjs +27 -0
- package/src/api/paths.mjs +43 -0
- package/src/api/prompt.mjs +26 -0
- package/src/api/report.mjs +83 -0
- package/src/lib/elevate-windows-child.mjs +43 -0
package/src/api/git.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { context } from "@cldmv/slothlet/runtime";
|
|
2
|
+
|
|
3
|
+
function run(args, opts = {}) {
|
|
4
|
+
const res = context.spawnSync("git", args, { encoding: "utf8", ...opts });
|
|
5
|
+
return { code: res.status ?? 1, stdout: (res.stdout || "").trim(), stderr: (res.stderr || "").trim() };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Git inspection helpers — config lookup at each scope, repo-root / git-dir
|
|
10
|
+
* discovery, effective core.hooksPath resolution. All read-only.
|
|
11
|
+
*
|
|
12
|
+
* @namespace api.git
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export function getConfig(key, scope) {
|
|
16
|
+
const args = ["config"];
|
|
17
|
+
if (scope) args.push(`--${scope}`);
|
|
18
|
+
args.push("--get", key);
|
|
19
|
+
const res = run(args);
|
|
20
|
+
return res.code === 0 ? res.stdout : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getRepoRoot(cwd = process.cwd()) {
|
|
24
|
+
const res = run(["rev-parse", "--show-toplevel"], { cwd });
|
|
25
|
+
return res.code === 0 ? res.stdout : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getGitDir(cwd = process.cwd()) {
|
|
29
|
+
const res = run(["rev-parse", "--absolute-git-dir"], { cwd });
|
|
30
|
+
return res.code === 0 ? res.stdout : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getEffectiveHooksPath(cwd = process.cwd()) {
|
|
34
|
+
const { path, os } = context;
|
|
35
|
+
const res = context.spawnSync("git", ["config", "--get", "core.hooksPath"], { cwd, encoding: "utf8" });
|
|
36
|
+
if (res.status !== 0) return null;
|
|
37
|
+
const raw = (res.stdout || "").trim();
|
|
38
|
+
if (!raw) return null;
|
|
39
|
+
const expanded = raw.startsWith("~") ? path.join(os.homedir(), raw.slice(1)) : raw;
|
|
40
|
+
if (path.isAbsolute(expanded)) return expanded;
|
|
41
|
+
const root = getRepoRoot(cwd);
|
|
42
|
+
return root ? path.resolve(root, expanded) : path.resolve(cwd, expanded);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getAllHooksPathScopes(cwd = process.cwd()) {
|
|
46
|
+
void cwd;
|
|
47
|
+
return {
|
|
48
|
+
system: getConfig("core.hooksPath", "system"),
|
|
49
|
+
global: getConfig("core.hooksPath", "global"),
|
|
50
|
+
local: getConfig("core.hooksPath", "local")
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getInitTemplateDir() {
|
|
55
|
+
const raw = getConfig("init.templateDir", "global");
|
|
56
|
+
if (!raw) return null;
|
|
57
|
+
const { path, os } = context;
|
|
58
|
+
return raw.startsWith("~") ? path.join(os.homedir(), raw.slice(1)) : raw;
|
|
59
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { self, context } from "@cldmv/slothlet/runtime";
|
|
2
|
+
import { STANDARD_HOOK_NAMES } from "../detect/dispatcher.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Install or heal a canonical dispatcher.
|
|
6
|
+
*
|
|
7
|
+
* `op === "bootstrap"` writes `hooks/_dispatch.template` into the target
|
|
8
|
+
* directory and links every standard hook name to it. `op === "heal"` only
|
|
9
|
+
* adds missing entries, leaving the existing dispatcher script alone.
|
|
10
|
+
*
|
|
11
|
+
* @param {"bootstrap"|"heal"} op
|
|
12
|
+
* @param {object} target
|
|
13
|
+
* @param {string} [target.dir] destination directory (for bootstrap)
|
|
14
|
+
* @param {string} [target.dispatcherPath] existing dispatcher path (for heal)
|
|
15
|
+
* @param {string[]} [target.missing] hook names to add (for heal)
|
|
16
|
+
* @param {object} [opts]
|
|
17
|
+
* @param {boolean} [opts.noSymlinks]
|
|
18
|
+
* @param {string[]} [opts.hookNames] override for bootstrap (defaults to all standard hook names)
|
|
19
|
+
*/
|
|
20
|
+
export default function dispatcher(op, target, opts = {}) {
|
|
21
|
+
if (op === "bootstrap") return bootstrap(target.dir, opts);
|
|
22
|
+
if (op === "heal") return heal(target.dispatcherPath, target.missing, opts);
|
|
23
|
+
throw new Error(`install.dispatcher: unknown op "${op}" (expected "bootstrap" or "heal")`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function bootstrap(dir, { noSymlinks = false, hookNames = STANDARD_HOOK_NAMES } = {}) {
|
|
27
|
+
const { fs, path } = context;
|
|
28
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
29
|
+
const source = path.join(self.paths.hooksSourceDir(), "_dispatch.template");
|
|
30
|
+
const dest = path.join(dir, "_dispatch");
|
|
31
|
+
self.link.copyExecutable(source, dest, { overwrite: true });
|
|
32
|
+
self.log.append({ op: "install-dispatcher", path: dest });
|
|
33
|
+
|
|
34
|
+
const sources = hookNames.map((n) => path.join(dir, n));
|
|
35
|
+
const result = self.link.batch(dest, sources, { noSymlinks, overwrite: true });
|
|
36
|
+
for (const entry of result.created) {
|
|
37
|
+
self.log.append({ op: "install-dispatcher-link", path: entry.source, mechanism: entry.mechanism, target: dest });
|
|
38
|
+
}
|
|
39
|
+
return { dispatcherPath: dest, created: result.created, fallbackToCopy: result.fallbackToCopy };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function heal(dispatcherPath, missing, { noSymlinks = false } = {}) {
|
|
43
|
+
const { path } = context;
|
|
44
|
+
const dir = path.dirname(dispatcherPath);
|
|
45
|
+
const sources = (missing || []).map((n) => path.join(dir, n));
|
|
46
|
+
const result = self.link.batch(dispatcherPath, sources, { noSymlinks, overwrite: false });
|
|
47
|
+
for (const entry of result.created) {
|
|
48
|
+
self.log.append({ op: "heal-dispatcher-link", path: entry.source, mechanism: entry.mechanism, target: dispatcherPath });
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { self, context } from "@cldmv/slothlet/runtime";
|
|
2
|
+
|
|
3
|
+
export const PACKAGE_HOOK_MAP = {
|
|
4
|
+
"post-checkout": "update-embedded-repos",
|
|
5
|
+
"post-merge": "update-embedded-repos",
|
|
6
|
+
"post-rewrite": "update-embedded-repos",
|
|
7
|
+
"reference-transaction": "reference-transaction"
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Install or uninstall the package's per-repo hook scripts.
|
|
12
|
+
*
|
|
13
|
+
* `op === "install"` copies the four hooks; `op === "uninstall"` removes only
|
|
14
|
+
* the ones recognizably owned by git-embedded (any file whose content includes
|
|
15
|
+
* the string "git-embedded").
|
|
16
|
+
*
|
|
17
|
+
* @param {"install"|"uninstall"} op
|
|
18
|
+
* @param {string} gitDir
|
|
19
|
+
* @param {object} [opts]
|
|
20
|
+
* @param {boolean} [opts.force]
|
|
21
|
+
*/
|
|
22
|
+
export default function hooks(op, gitDir, opts = {}) {
|
|
23
|
+
if (op === "install") return doInstall(gitDir, opts);
|
|
24
|
+
if (op === "uninstall") return doUninstall(gitDir);
|
|
25
|
+
throw new Error(`install.hooks: unknown op "${op}" (expected "install" or "uninstall")`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function doInstall(gitDir, { force = false } = {}) {
|
|
29
|
+
const { fs, path } = context;
|
|
30
|
+
const hooksDir = path.join(gitDir, "hooks");
|
|
31
|
+
fs.mkdirSync(hooksDir, { recursive: true });
|
|
32
|
+
const sourceDir = self.paths.hooksSourceDir();
|
|
33
|
+
const installed = [];
|
|
34
|
+
const skipped = [];
|
|
35
|
+
for (const [hookName, sourceName] of Object.entries(PACKAGE_HOOK_MAP)) {
|
|
36
|
+
const dest = path.join(hooksDir, hookName);
|
|
37
|
+
const source = path.join(sourceDir, sourceName);
|
|
38
|
+
if (!force && fs.existsSync(dest)) {
|
|
39
|
+
let existing;
|
|
40
|
+
try {
|
|
41
|
+
existing = fs.readFileSync(dest, "utf8");
|
|
42
|
+
} catch {
|
|
43
|
+
existing = "";
|
|
44
|
+
}
|
|
45
|
+
if (!existing.includes("git-embedded")) {
|
|
46
|
+
skipped.push({ name: hookName, reason: "existing hook is not owned by git-embedded; pass --force to overwrite" });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
self.link.copyExecutable(source, dest, { overwrite: true });
|
|
51
|
+
installed.push(hookName);
|
|
52
|
+
self.log.append({ op: "install-repo-hook", path: dest, source });
|
|
53
|
+
}
|
|
54
|
+
return { installed, skipped };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function doUninstall(gitDir) {
|
|
58
|
+
const { fs, path } = context;
|
|
59
|
+
const hooksDir = path.join(gitDir, "hooks");
|
|
60
|
+
const removed = [];
|
|
61
|
+
const kept = [];
|
|
62
|
+
for (const hookName of Object.keys(PACKAGE_HOOK_MAP)) {
|
|
63
|
+
const dest = path.join(hooksDir, hookName);
|
|
64
|
+
if (!fs.existsSync(dest)) continue;
|
|
65
|
+
let body;
|
|
66
|
+
try {
|
|
67
|
+
body = fs.readFileSync(dest, "utf8");
|
|
68
|
+
} catch {
|
|
69
|
+
body = "";
|
|
70
|
+
}
|
|
71
|
+
if (body.includes("git-embedded")) {
|
|
72
|
+
fs.unlinkSync(dest);
|
|
73
|
+
removed.push(hookName);
|
|
74
|
+
self.log.append({ op: "uninstall-repo-hook", path: dest });
|
|
75
|
+
} else {
|
|
76
|
+
kept.push({ name: hookName, reason: "file is not owned by git-embedded" });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { removed, kept };
|
|
80
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { self, context } from "@cldmv/slothlet/runtime";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Install the package's hook scripts into a `git init.templateDir/hooks`
|
|
5
|
+
* directory so new repos start with them.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} templateDir
|
|
8
|
+
* @param {object} [opts]
|
|
9
|
+
* @param {boolean} [opts.force]
|
|
10
|
+
*/
|
|
11
|
+
export default function template(templateDir, opts = {}) {
|
|
12
|
+
const { fs, path } = context;
|
|
13
|
+
fs.mkdirSync(path.join(templateDir, "hooks"), { recursive: true });
|
|
14
|
+
return self.install.hooks("install", templateDir, opts);
|
|
15
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { self, context } from "@cldmv/slothlet/runtime";
|
|
2
|
+
|
|
3
|
+
const WIN_PRIV_NOT_HELD = "EPERM";
|
|
4
|
+
|
|
5
|
+
export class CancelledByUser extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "CancelledByUser";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function removeIfExists(p) {
|
|
13
|
+
const { fs } = context;
|
|
14
|
+
try {
|
|
15
|
+
fs.lstatSync(p);
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
fs.unlinkSync(p);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isPrivilegeError(err) {
|
|
24
|
+
if (!err) return false;
|
|
25
|
+
if (err.code === WIN_PRIV_NOT_HELD || err.code === "EACCES") return true;
|
|
26
|
+
if (typeof err.errno === "number" && err.errno === -4048) return true;
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function trySymlink(source, target) {
|
|
31
|
+
try {
|
|
32
|
+
context.fs.symlinkSync(target, source, "file");
|
|
33
|
+
return { ok: true };
|
|
34
|
+
} catch (err) {
|
|
35
|
+
return { ok: false, error: err };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function tryHardlink(source, target) {
|
|
40
|
+
try {
|
|
41
|
+
context.fs.linkSync(target, source);
|
|
42
|
+
return { ok: true };
|
|
43
|
+
} catch (err) {
|
|
44
|
+
return { ok: false, error: err };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function tryCopy(source, target) {
|
|
49
|
+
const { fs } = context;
|
|
50
|
+
try {
|
|
51
|
+
fs.copyFileSync(target, source);
|
|
52
|
+
if (process.platform !== "win32") {
|
|
53
|
+
try {
|
|
54
|
+
const st = fs.statSync(source);
|
|
55
|
+
fs.chmodSync(source, st.mode | 0o111);
|
|
56
|
+
} catch {
|
|
57
|
+
// best-effort
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { ok: true };
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return { ok: false, error: err };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create links from many sources to one target. Default mechanism is symlink;
|
|
68
|
+
* `opts.noSymlinks` switches to hardlink (falling back to copy on cross-volume
|
|
69
|
+
* or non-NTFS). On Windows, missing symlink privilege triggers a single UAC
|
|
70
|
+
* batch via `self.link.elevateWindows`.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} target absolute path of the file being linked-to
|
|
73
|
+
* @param {string[]} sources absolute paths of the new links to create
|
|
74
|
+
* @param {object} [opts]
|
|
75
|
+
* @param {boolean} [opts.noSymlinks]
|
|
76
|
+
* @param {boolean} [opts.overwrite]
|
|
77
|
+
*/
|
|
78
|
+
export default function batch(target, sources, opts = {}) {
|
|
79
|
+
const { noSymlinks = false, overwrite = false } = opts;
|
|
80
|
+
const { fs, path } = context;
|
|
81
|
+
const created = [];
|
|
82
|
+
const fallbackToCopy = [];
|
|
83
|
+
|
|
84
|
+
const ensureDir = (p) => fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
85
|
+
|
|
86
|
+
if (noSymlinks) {
|
|
87
|
+
for (const source of sources) {
|
|
88
|
+
ensureDir(source);
|
|
89
|
+
if (overwrite) removeIfExists(source);
|
|
90
|
+
const hl = tryHardlink(source, target);
|
|
91
|
+
if (hl.ok) {
|
|
92
|
+
created.push({ source, mechanism: "hardlink" });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const cp = tryCopy(source, target);
|
|
96
|
+
if (!cp.ok) throw cp.error;
|
|
97
|
+
created.push({ source, mechanism: "copy" });
|
|
98
|
+
fallbackToCopy.push(source);
|
|
99
|
+
}
|
|
100
|
+
return { created, fallbackToCopy };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const deferred = [];
|
|
104
|
+
for (const source of sources) {
|
|
105
|
+
ensureDir(source);
|
|
106
|
+
if (overwrite) removeIfExists(source);
|
|
107
|
+
const ln = trySymlink(source, target);
|
|
108
|
+
if (ln.ok) {
|
|
109
|
+
created.push({ source, mechanism: "symlink" });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (process.platform === "win32" && isPrivilegeError(ln.error)) {
|
|
113
|
+
deferred.push(source);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const cp = tryCopy(source, target);
|
|
117
|
+
if (!cp.ok) throw ln.error;
|
|
118
|
+
created.push({ source, mechanism: "copy" });
|
|
119
|
+
fallbackToCopy.push(source);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (deferred.length > 0) {
|
|
123
|
+
const result = self.link.elevateWindows(deferred.map((source) => ({ source, target })));
|
|
124
|
+
if (result.cancelled) throw new CancelledByUser(result.message || "UAC elevation cancelled");
|
|
125
|
+
if (!result.ok) throw new Error(result.message || `elevated symlink batch failed (exit ${result.exitCode})`);
|
|
126
|
+
for (const source of deferred) created.push({ source, mechanism: "symlink-elevated" });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { created, fallbackToCopy };
|
|
130
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { context } from "@cldmv/slothlet/runtime";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Copy a file to a destination, preserving the +x bit on Unix.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} source
|
|
7
|
+
* @param {string} dest
|
|
8
|
+
* @param {object} [opts]
|
|
9
|
+
* @param {boolean} [opts.overwrite=true]
|
|
10
|
+
*/
|
|
11
|
+
export default function copyExecutable(source, dest, { overwrite = true } = {}) {
|
|
12
|
+
const { fs, path } = context;
|
|
13
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
14
|
+
if (overwrite) {
|
|
15
|
+
try {
|
|
16
|
+
fs.lstatSync(dest);
|
|
17
|
+
fs.unlinkSync(dest);
|
|
18
|
+
} catch {
|
|
19
|
+
// nothing to remove
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
fs.copyFileSync(source, dest);
|
|
23
|
+
if (process.platform !== "win32") {
|
|
24
|
+
const st = fs.statSync(dest);
|
|
25
|
+
fs.chmodSync(dest, st.mode | 0o111);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { self, context } from "@cldmv/slothlet/runtime";
|
|
2
|
+
|
|
3
|
+
const ERROR_CANCELLED = 1223;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Re-launch the current Node binary as Administrator via PowerShell's
|
|
7
|
+
* `Start-Process -Verb RunAs` and have it create a batch of symlinks. Each
|
|
8
|
+
* entry is `{ source, target }` — `source` is the link to create, `target` is
|
|
9
|
+
* what it points at.
|
|
10
|
+
*
|
|
11
|
+
* Cancellation of the UAC prompt is detected via the spawned process's exit
|
|
12
|
+
* code (ERROR_CANCELLED = 1223). All other non-zero exits are surfaced as
|
|
13
|
+
* failures.
|
|
14
|
+
*
|
|
15
|
+
* @param {Array<{source:string,target:string}>} plan
|
|
16
|
+
*/
|
|
17
|
+
export default function elevateWindows(plan) {
|
|
18
|
+
if (process.platform !== "win32") {
|
|
19
|
+
throw new Error("link.elevateWindows is Windows-only");
|
|
20
|
+
}
|
|
21
|
+
if (!Array.isArray(plan) || plan.length === 0) {
|
|
22
|
+
return { ok: true, cancelled: false, exitCode: 0 };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { fs, path, os, spawnSync } = context;
|
|
26
|
+
const tmpPath = path.join(os.tmpdir(), `git-embedded-symlink-batch-${process.pid}-${Date.now()}.json`);
|
|
27
|
+
fs.writeFileSync(tmpPath, JSON.stringify(plan, null, 2));
|
|
28
|
+
|
|
29
|
+
const childScript = path.join(self.paths.packageRoot(), "src", "lib", "elevate-windows-child.mjs");
|
|
30
|
+
const node = process.execPath;
|
|
31
|
+
|
|
32
|
+
const argList = [childScript, tmpPath].map((s) => `'${s.replace(/'/g, "''")}'`).join(",");
|
|
33
|
+
const psCommand = `try { $p = Start-Process -FilePath '${node.replace(/'/g, "''")}' -ArgumentList @(${argList}) -Verb RunAs -Wait -PassThru; exit $p.ExitCode } catch { if ($_.Exception.NativeErrorCode -eq ${ERROR_CANCELLED} -or $_.Exception.HResult -eq -2147467260) { exit ${ERROR_CANCELLED} } else { Write-Error $_; exit 1 } }`;
|
|
34
|
+
|
|
35
|
+
const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", psCommand], {
|
|
36
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
fs.unlinkSync(tmpPath);
|
|
41
|
+
} catch {
|
|
42
|
+
// ignore cleanup failure
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const exitCode = result.status ?? 1;
|
|
46
|
+
if (exitCode === 0) return { ok: true, cancelled: false, exitCode };
|
|
47
|
+
if (exitCode === ERROR_CANCELLED) {
|
|
48
|
+
return { ok: false, cancelled: true, exitCode, message: "UAC elevation cancelled by user" };
|
|
49
|
+
}
|
|
50
|
+
const stderr = (result.stderr || Buffer.alloc(0)).toString("utf8").trim();
|
|
51
|
+
return { ok: false, cancelled: false, exitCode, message: stderr || `elevated symlink batch exited ${exitCode}` };
|
|
52
|
+
}
|
package/src/api/log.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { self, context } from "@cldmv/slothlet/runtime";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Append-only JSONL transaction log. Each install/uninstall operation appends
|
|
5
|
+
* one line; uninstall replays the log to know what to remove.
|
|
6
|
+
*
|
|
7
|
+
* @namespace api.log
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export function append(entry) {
|
|
11
|
+
const { fs, path } = context;
|
|
12
|
+
const log = self.paths.transactionLogPath();
|
|
13
|
+
fs.mkdirSync(path.dirname(log), { recursive: true });
|
|
14
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n";
|
|
15
|
+
fs.appendFileSync(log, line);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function read() {
|
|
19
|
+
const { fs } = context;
|
|
20
|
+
const log = self.paths.transactionLogPath();
|
|
21
|
+
if (!fs.existsSync(log)) return [];
|
|
22
|
+
return fs
|
|
23
|
+
.readFileSync(log, "utf8")
|
|
24
|
+
.split(/\r?\n/)
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.map((line) => {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(line);
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function path() {
|
|
37
|
+
return self.paths.transactionLogPath();
|
|
38
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { self, context } from "@cldmv/slothlet/runtime";
|
|
2
|
+
|
|
3
|
+
const KIND_TO_FILE = {
|
|
4
|
+
none: "setup-none.md",
|
|
5
|
+
"dispatcher-canonical-complete": "setup-dispatcher-canonical-complete.md",
|
|
6
|
+
"dispatcher-missing-symlinks": "setup-dispatcher-missing-symlinks.md",
|
|
7
|
+
"dispatcher-non-conforming": "setup-dispatcher-non-conforming.md",
|
|
8
|
+
husky: "setup-husky.md",
|
|
9
|
+
lefthook: "setup-lefthook.md",
|
|
10
|
+
"simple-git-hooks": "setup-simple-git-hooks.md",
|
|
11
|
+
"pre-commit": "setup-pre-commit.md",
|
|
12
|
+
"bare-githooks": "setup-bare-githooks.md",
|
|
13
|
+
"system-hookspath": "setup-system-hookspath.md",
|
|
14
|
+
"init-templatedir": "setup-init-templatedir.md"
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Read the markdown body for a detection classification kind, verbatim.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} kind one of the keys in `KIND_TO_FILE`
|
|
21
|
+
* @returns {string}
|
|
22
|
+
*/
|
|
23
|
+
export default function load(kind) {
|
|
24
|
+
const file = KIND_TO_FILE[kind];
|
|
25
|
+
if (!file) throw new Error(`Unknown message kind: ${kind}`);
|
|
26
|
+
return context.fs.readFileSync(context.path.join(self.paths.messagesDir(), file), "utf8");
|
|
27
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { context } from "@cldmv/slothlet/runtime";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Path constants and platform-aware path computations. All values are derived
|
|
5
|
+
* from the host's `context.packageRoot` (set by bin/git-embedded.mjs) and the
|
|
6
|
+
* standard XDG environment variables.
|
|
7
|
+
*
|
|
8
|
+
* @namespace api.paths
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export function packageRoot() {
|
|
12
|
+
return context.packageRoot;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function hooksSourceDir() {
|
|
16
|
+
return context.path.join(context.packageRoot, "hooks");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function messagesDir() {
|
|
20
|
+
return context.path.join(context.packageRoot, "messages");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function stateDir() {
|
|
24
|
+
const { path, os } = context;
|
|
25
|
+
if (process.platform === "win32") {
|
|
26
|
+
const base = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
|
27
|
+
return path.join(base, "git-embedded");
|
|
28
|
+
}
|
|
29
|
+
const xdg = process.env.XDG_STATE_HOME;
|
|
30
|
+
const base = xdg && xdg.length > 0 ? xdg : path.join(os.homedir(), ".local", "state");
|
|
31
|
+
return path.join(base, "git-embedded");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function transactionLogPath() {
|
|
35
|
+
return context.path.join(stateDir(), "install.log");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function defaultGlobalDispatcherDir() {
|
|
39
|
+
const { path, os } = context;
|
|
40
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
41
|
+
const base = xdg && xdg.length > 0 ? xdg : path.join(os.homedir(), ".config");
|
|
42
|
+
return path.join(base, "git", "hooks");
|
|
43
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { context } from "@cldmv/slothlet/runtime";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal interactive prompts. Honors `--yes` via `opts.yes` and
|
|
5
|
+
* non-TTY input by returning the default.
|
|
6
|
+
*
|
|
7
|
+
* @namespace api.prompt
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export function confirm(question, opts = {}) {
|
|
11
|
+
const { defaultYes = false, yes = false } = opts;
|
|
12
|
+
if (yes) return Promise.resolve(true);
|
|
13
|
+
if (!process.stdin.isTTY) return Promise.resolve(defaultYes);
|
|
14
|
+
|
|
15
|
+
const suffix = defaultYes ? "[Y/n]" : "[y/N]";
|
|
16
|
+
const rl = context.readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
rl.question(`${question} ${suffix} `, (answer) => {
|
|
19
|
+
rl.close();
|
|
20
|
+
const trimmed = (answer || "").trim().toLowerCase();
|
|
21
|
+
if (trimmed === "") resolve(defaultYes);
|
|
22
|
+
else if (trimmed === "y" || trimmed === "yes") resolve(true);
|
|
23
|
+
else resolve(false);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { self, context } from "@cldmv/slothlet/runtime";
|
|
2
|
+
|
|
3
|
+
const KIND_LABELS = {
|
|
4
|
+
none: "No hook setup detected",
|
|
5
|
+
"dispatcher-canonical-complete": "Canonical dispatcher (complete)",
|
|
6
|
+
"dispatcher-missing-symlinks": "Canonical dispatcher (missing required entries)",
|
|
7
|
+
"dispatcher-non-conforming": "Non-conforming dispatcher",
|
|
8
|
+
husky: "Husky",
|
|
9
|
+
lefthook: "Lefthook",
|
|
10
|
+
"simple-git-hooks": "simple-git-hooks",
|
|
11
|
+
"pre-commit": "pre-commit (Python)",
|
|
12
|
+
"bare-githooks": "Bare hooks directory",
|
|
13
|
+
"system-hookspath": "System-scope core.hooksPath",
|
|
14
|
+
"init-templatedir": "init.templateDir set globally"
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function fmtKv(label, value) {
|
|
18
|
+
if (value == null || value === "") return null;
|
|
19
|
+
return ` ${context.chalk.bold(label)}: ${value}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* User-facing output helpers — colorized status lines + the "Detected: …"
|
|
24
|
+
* header rendered before each message file.
|
|
25
|
+
*
|
|
26
|
+
* @namespace api.report
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export function detectionHeader(result) {
|
|
30
|
+
const { chalk } = context;
|
|
31
|
+
const label = KIND_LABELS[result.kind] || result.kind;
|
|
32
|
+
console.log(chalk.cyan.bold(`\nDetected: ${label}`));
|
|
33
|
+
|
|
34
|
+
const { paths, signals, dispatcher, foreign, bare, subClassification } = result;
|
|
35
|
+
const lines = [];
|
|
36
|
+
if (paths) {
|
|
37
|
+
if (paths.repoRoot) lines.push(fmtKv("Repo root", paths.repoRoot));
|
|
38
|
+
if (paths.gitDir) lines.push(fmtKv("Git dir", paths.gitDir));
|
|
39
|
+
if (paths.effectiveHooksPath) lines.push(fmtKv("Effective core.hooksPath", paths.effectiveHooksPath));
|
|
40
|
+
}
|
|
41
|
+
if (signals && signals.hooksPathScopes) {
|
|
42
|
+
const s = signals.hooksPathScopes;
|
|
43
|
+
const scope = (k, v) => (v ? `${k}=${v}` : null);
|
|
44
|
+
const parts = [scope("system", s.system), scope("global", s.global), scope("local", s.local)].filter(Boolean);
|
|
45
|
+
if (parts.length) lines.push(fmtKv("core.hooksPath scopes", parts.join(" ")));
|
|
46
|
+
}
|
|
47
|
+
if (signals && signals.initTemplateDir) lines.push(fmtKv("init.templateDir", signals.initTemplateDir));
|
|
48
|
+
if (dispatcher && dispatcher.dispatcherPath) lines.push(fmtKv("Dispatcher", dispatcher.dispatcherPath));
|
|
49
|
+
if (dispatcher && dispatcher.missing && dispatcher.missing.length) {
|
|
50
|
+
lines.push(fmtKv("Missing entries", dispatcher.missing.join(", ")));
|
|
51
|
+
}
|
|
52
|
+
if (foreign && foreign.dir) lines.push(fmtKv("Tool directory", foreign.dir));
|
|
53
|
+
if (foreign && foreign.configFile) lines.push(fmtKv("Config file", foreign.configFile));
|
|
54
|
+
if (bare && bare.dir) lines.push(fmtKv("Hooks directory", bare.dir));
|
|
55
|
+
if (subClassification && subClassification.dispatcherPath) {
|
|
56
|
+
lines.push(fmtKv("System-path dispatcher", subClassification.dispatcherPath));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const l of lines.filter(Boolean)) console.log(l);
|
|
60
|
+
console.log("");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function message(kind) {
|
|
64
|
+
const body = self.messages.load(kind);
|
|
65
|
+
const rendered = context.renderMarkdown(body);
|
|
66
|
+
process.stdout.write(rendered.endsWith("\n") ? rendered : rendered + "\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function success(line) {
|
|
70
|
+
console.log(context.chalk.green(`✓ ${line}`));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function warn(line) {
|
|
74
|
+
console.log(context.chalk.yellow(`! ${line}`));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function error(line) {
|
|
78
|
+
console.error(context.chalk.red(`✗ ${line}`));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function plain(line = "") {
|
|
82
|
+
console.log(line);
|
|
83
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//
|
|
3
|
+
// Elevated child process: reads a JSON file with an array of
|
|
4
|
+
// { source, target } entries and creates symbolic links for each.
|
|
5
|
+
// Spawned by elevate-windows.mjs via PowerShell's `Start-Process -Verb RunAs`.
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
|
|
9
|
+
const planPath = process.argv[2];
|
|
10
|
+
if (!planPath) {
|
|
11
|
+
console.error("elevate-windows-child: missing plan file argument");
|
|
12
|
+
process.exit(2);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let plan;
|
|
16
|
+
try {
|
|
17
|
+
plan = JSON.parse(fs.readFileSync(planPath, "utf8"));
|
|
18
|
+
} catch (err) {
|
|
19
|
+
console.error("elevate-windows-child: failed to read plan:", err.message);
|
|
20
|
+
process.exit(3);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let failures = 0;
|
|
24
|
+
for (const entry of plan) {
|
|
25
|
+
if (!entry || !entry.source || !entry.target) {
|
|
26
|
+
failures++;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
// Remove any existing entry so the link can be (re-)created cleanly.
|
|
31
|
+
try {
|
|
32
|
+
fs.unlinkSync(entry.source);
|
|
33
|
+
} catch {
|
|
34
|
+
// ignore — most likely doesn't exist yet
|
|
35
|
+
}
|
|
36
|
+
fs.symlinkSync(entry.target, entry.source, "file");
|
|
37
|
+
} catch (err) {
|
|
38
|
+
failures++;
|
|
39
|
+
console.error(`failed to symlink ${entry.source} -> ${entry.target}: ${err.message}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
process.exit(failures === 0 ? 0 : 4);
|