backdot 1.6.1 → 1.8.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/README.md +20 -14
- package/dist/cli.js +36 -80
- package/dist/commands/backup.js +66 -4
- package/dist/commands/history.js +9 -6
- package/dist/commands/init.js +6 -5
- package/dist/commands/restore.d.ts +4 -4
- package/dist/commands/restore.js +54 -33
- package/dist/commands/schedule.js +14 -0
- package/dist/commands/status.js +47 -12
- package/dist/commitUrl.js +5 -4
- package/dist/config.d.ts +2 -1
- package/dist/config.js +15 -13
- package/dist/crypto/encryption.d.ts +8 -0
- package/dist/crypto/encryption.js +54 -0
- package/dist/crypto/password.d.ts +11 -0
- package/dist/crypto/password.js +74 -0
- package/dist/crypto.d.ts +13 -0
- package/dist/crypto.js +129 -0
- package/dist/encryption.d.ts +2 -0
- package/dist/encryption.js +39 -0
- package/dist/git.d.ts +1 -1
- package/dist/git.js +38 -35
- package/dist/launchd.js +12 -9
- package/dist/password.d.ts +11 -0
- package/dist/password.js +74 -0
- package/dist/repoVisibility.d.ts +15 -0
- package/dist/repoVisibility.js +58 -0
- package/dist/resolveFiles.js +2 -2
- package/dist/staging.d.ts +9 -3
- package/dist/staging.js +86 -38
- package/package.json +2 -9
package/dist/password.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { password as passwordPrompt, confirm } from "@inquirer/prompts";
|
|
5
|
+
export const KEY_FILE_PATH = path.join(os.homedir(), ".backdot.key");
|
|
6
|
+
export const ENC_SUFFIX = ".encrypted";
|
|
7
|
+
export function checkKeyFilePermissions() {
|
|
8
|
+
if (process.platform === "win32") {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
if (!fs.existsSync(KEY_FILE_PATH)) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const stat = fs.statSync(KEY_FILE_PATH);
|
|
15
|
+
const mode = stat.mode & 0o777;
|
|
16
|
+
const isAccessibleByGroupOrOthers = (mode & 0o077) !== 0;
|
|
17
|
+
if (isAccessibleByGroupOrOthers) {
|
|
18
|
+
throw new Error(`Key file ${KEY_FILE_PATH} has overly permissive permissions (${mode.toString(8)}).\n` +
|
|
19
|
+
` Run: chmod 600 ${KEY_FILE_PATH}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function saveKeyFile(password) {
|
|
23
|
+
fs.writeFileSync(KEY_FILE_PATH, password + "\n", { mode: 0o600 });
|
|
24
|
+
}
|
|
25
|
+
function readKeyFile() {
|
|
26
|
+
if (!fs.existsSync(KEY_FILE_PATH)) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
checkKeyFilePermissions();
|
|
30
|
+
return fs.readFileSync(KEY_FILE_PATH, "utf-8").trimEnd();
|
|
31
|
+
}
|
|
32
|
+
export async function resolvePassword() {
|
|
33
|
+
const envPassword = process.env.BACKDOT_PASSWORD;
|
|
34
|
+
if (envPassword) {
|
|
35
|
+
return { password: envPassword, interactive: false };
|
|
36
|
+
}
|
|
37
|
+
const filePassword = readKeyFile();
|
|
38
|
+
if (filePassword) {
|
|
39
|
+
return { password: filePassword, interactive: false };
|
|
40
|
+
}
|
|
41
|
+
if (!process.stdin.isTTY) {
|
|
42
|
+
throw new Error('Encryption is enabled but no password found.\n Run "backdot backup" interactively to create ~/.backdot.key, or set BACKDOT_PASSWORD.');
|
|
43
|
+
}
|
|
44
|
+
const enteredPassword = await passwordPrompt({ message: "Enter encryption password:" });
|
|
45
|
+
if (!enteredPassword) {
|
|
46
|
+
throw new Error("No password provided.");
|
|
47
|
+
}
|
|
48
|
+
return { password: enteredPassword, interactive: true };
|
|
49
|
+
}
|
|
50
|
+
export async function confirmPassword(password) {
|
|
51
|
+
const confirmedPassword = await passwordPrompt({ message: "Confirm password:" });
|
|
52
|
+
if (confirmedPassword !== password) {
|
|
53
|
+
throw new Error("Passwords do not match.");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export async function offerToSaveKeyFile(password) {
|
|
57
|
+
if (!process.stdin.isTTY) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (fs.existsSync(KEY_FILE_PATH)) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (process.env.BACKDOT_PASSWORD) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const save = await confirm({
|
|
67
|
+
message: "Save password to ~/.backdot.key for automated backups?",
|
|
68
|
+
default: true,
|
|
69
|
+
});
|
|
70
|
+
if (save) {
|
|
71
|
+
saveKeyFile(password);
|
|
72
|
+
console.log(` Created ${KEY_FILE_PATH} (permissions: 600)`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type RepoVisibility = "public" | "private" | "unknown";
|
|
2
|
+
/**
|
|
3
|
+
* Converts an SSH or HTTPS repo URL to a credential-free HTTPS URL
|
|
4
|
+
* for known hosts. Returns `null` for unrecognized hosts.
|
|
5
|
+
*
|
|
6
|
+
* Examples:
|
|
7
|
+
* git@github.com:user/repo.git → https://github.com/user/repo.git
|
|
8
|
+
* https://github.com/user/repo → https://github.com/user/repo.git
|
|
9
|
+
*/
|
|
10
|
+
export declare function toHttpsUrl(repository: string): string | null;
|
|
11
|
+
/**
|
|
12
|
+
* Checks whether `repository` is publicly readable by attempting an
|
|
13
|
+
* anonymous `git ls-remote` over HTTPS with all credential helpers disabled.
|
|
14
|
+
*/
|
|
15
|
+
export declare function checkRepoVisibility(repository: string): Promise<RepoVisibility>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
const KNOWN_HOSTS = ["github.com", "gitlab.com", "bitbucket.org"];
|
|
3
|
+
/**
|
|
4
|
+
* Converts an SSH or HTTPS repo URL to a credential-free HTTPS URL
|
|
5
|
+
* for known hosts. Returns `null` for unrecognized hosts.
|
|
6
|
+
*
|
|
7
|
+
* Examples:
|
|
8
|
+
* git@github.com:user/repo.git → https://github.com/user/repo.git
|
|
9
|
+
* https://github.com/user/repo → https://github.com/user/repo.git
|
|
10
|
+
*/
|
|
11
|
+
export function toHttpsUrl(repository) {
|
|
12
|
+
for (const host of KNOWN_HOSTS) {
|
|
13
|
+
const idx = repository.indexOf(host);
|
|
14
|
+
if (idx === -1) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
let repoPath = repository.slice(idx + host.length + 1).trim();
|
|
18
|
+
if (!repoPath.endsWith(".git")) {
|
|
19
|
+
repoPath += ".git";
|
|
20
|
+
}
|
|
21
|
+
return `https://${host}/${repoPath}`;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Checks whether `repository` is publicly readable by attempting an
|
|
27
|
+
* anonymous `git ls-remote` over HTTPS with all credential helpers disabled.
|
|
28
|
+
*/
|
|
29
|
+
export async function checkRepoVisibility(repository) {
|
|
30
|
+
const httpsUrl = toHttpsUrl(repository);
|
|
31
|
+
if (!httpsUrl) {
|
|
32
|
+
return "unknown";
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
await execGitLsRemote(httpsUrl);
|
|
36
|
+
return "public";
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return "private";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function execGitLsRemote(url) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const child = execFile("git", ["-c", "credential.helper=", "ls-remote", "--quiet", url], {
|
|
45
|
+
timeout: 10_000,
|
|
46
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
47
|
+
}, (error) => {
|
|
48
|
+
if (error) {
|
|
49
|
+
reject(error);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
resolve();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
// Don't let stdin keep the process alive
|
|
56
|
+
child.stdin?.end();
|
|
57
|
+
});
|
|
58
|
+
}
|
package/dist/resolveFiles.js
CHANGED
|
@@ -20,8 +20,8 @@ const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024; // 10 MB
|
|
|
20
20
|
* Skips entries that fail resolution and logs warnings.
|
|
21
21
|
*/
|
|
22
22
|
export function resolveFiles(config) {
|
|
23
|
-
const
|
|
24
|
-
return
|
|
23
|
+
const deduplicatedPaths = uniq(resolveGlobs(config.paths));
|
|
24
|
+
return deduplicatedPaths.filter((filePath) => {
|
|
25
25
|
try {
|
|
26
26
|
fs.accessSync(filePath, fs.constants.R_OK);
|
|
27
27
|
const stat = fs.statSync(filePath);
|
package/dist/staging.d.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import { STAGING_DIR, STAGING_GIT_DIR, machineDir } from "./paths.js";
|
|
2
|
+
import { type DerivedKey } from "./crypto/encryption.js";
|
|
2
3
|
export { STAGING_DIR, STAGING_GIT_DIR, machineDir };
|
|
3
4
|
export declare function getStagedPath(filePath: string, machine: string): string;
|
|
4
5
|
export declare function cleanStaging(machine: string): void;
|
|
5
|
-
export declare function copyToStaging(files: string[], machine: string): void;
|
|
6
|
+
export declare function copyToStaging(files: string[], machine: string, derivedKey?: DerivedKey): void;
|
|
6
7
|
export interface ComparisonResult {
|
|
7
8
|
backedUp: string[];
|
|
8
9
|
modified: string[];
|
|
9
10
|
notBackedUp: string[];
|
|
10
11
|
error?: string;
|
|
11
12
|
}
|
|
12
|
-
export declare function compareFiles(
|
|
13
|
-
|
|
13
|
+
export declare function compareFiles(opts: {
|
|
14
|
+
files: string[];
|
|
15
|
+
machine: string;
|
|
16
|
+
repository: string;
|
|
17
|
+
derivedKey?: DerivedKey;
|
|
18
|
+
}): Promise<ComparisonResult>;
|
|
19
|
+
export declare function writeRepoReadme(repository: string, encrypted?: boolean): void;
|
package/dist/staging.js
CHANGED
|
@@ -7,47 +7,60 @@ import { logger } from "./log.js";
|
|
|
7
7
|
import { errorMessage, pluralize } from "./utils.js";
|
|
8
8
|
import { ensureRemoteUrl, getCurrentBranch, gitError } from "./git.js";
|
|
9
9
|
import { STAGING_DIR, STAGING_GIT_DIR, machineDir } from "./paths.js";
|
|
10
|
+
import { encrypt, decrypt } from "./crypto/encryption.js";
|
|
11
|
+
import { KEY_FILE_PATH, ENC_SUFFIX } from "./crypto/password.js";
|
|
10
12
|
export { STAGING_DIR, STAGING_GIT_DIR, machineDir };
|
|
11
13
|
const HOME = os.homedir();
|
|
12
14
|
export function getStagedPath(filePath, machine) {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
const relativePath = path.relative(HOME, filePath);
|
|
16
|
+
// Files outside HOME (e.g. /etc/foo) produce a relative path starting with "..",
|
|
17
|
+
// which would escape the machine dir. Use the absolute path minus the leading "/" instead.
|
|
18
|
+
const pathWithinMachineDir = relativePath.startsWith("..") ? filePath.slice(1) : relativePath;
|
|
19
|
+
return path.join(machineDir(machine), pathWithinMachineDir);
|
|
16
20
|
}
|
|
17
21
|
export function cleanStaging(machine) {
|
|
18
|
-
const
|
|
19
|
-
if (!fs.existsSync(
|
|
22
|
+
const machineStagingDir = machineDir(machine);
|
|
23
|
+
if (!fs.existsSync(machineStagingDir)) {
|
|
20
24
|
return;
|
|
21
25
|
}
|
|
22
|
-
fs.rmSync(
|
|
26
|
+
fs.rmSync(machineStagingDir, { recursive: true, force: true });
|
|
23
27
|
logger.info(`Cleaned staging directory for machine "${machine}"`);
|
|
24
28
|
}
|
|
25
|
-
export function copyToStaging(files, machine) {
|
|
26
|
-
const
|
|
27
|
-
fs.mkdirSync(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
export function copyToStaging(files, machine, derivedKey) {
|
|
30
|
+
const machineStagingDir = machineDir(machine);
|
|
31
|
+
fs.mkdirSync(machineStagingDir, { recursive: true });
|
|
32
|
+
const filesExcludingKeyFile = files.filter((f) => path.resolve(f) !== path.resolve(KEY_FILE_PATH));
|
|
33
|
+
let copiedCount = 0;
|
|
34
|
+
for (const filePath of filesExcludingKeyFile) {
|
|
35
|
+
const stagedPath = getStagedPath(filePath, machine);
|
|
36
|
+
const destinationPath = derivedKey ? stagedPath + ENC_SUFFIX : stagedPath;
|
|
31
37
|
try {
|
|
32
|
-
fs.mkdirSync(path.dirname(
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
|
|
39
|
+
if (derivedKey) {
|
|
40
|
+
const plaintext = fs.readFileSync(filePath);
|
|
41
|
+
fs.writeFileSync(destinationPath, encrypt(plaintext, derivedKey));
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
fs.copyFileSync(filePath, destinationPath);
|
|
45
|
+
}
|
|
46
|
+
copiedCount++;
|
|
35
47
|
}
|
|
36
48
|
catch {
|
|
37
|
-
logger.warn(`Failed to copy: ${filePath} -> ${
|
|
49
|
+
logger.warn(`Failed to copy: ${filePath} -> ${destinationPath}`);
|
|
38
50
|
}
|
|
39
51
|
}
|
|
40
|
-
logger.info(`Copied ${pluralize(
|
|
52
|
+
logger.info(`Copied ${pluralize(copiedCount, "file")} to staging`);
|
|
41
53
|
}
|
|
42
54
|
function failedComparisonResult(err) {
|
|
43
55
|
return { backedUp: [], modified: [], notBackedUp: [], error: errorMessage(err) };
|
|
44
56
|
}
|
|
45
|
-
export async function compareFiles(
|
|
57
|
+
export async function compareFiles(opts) {
|
|
58
|
+
const { files, machine, repository, derivedKey } = opts;
|
|
46
59
|
if (files.length === 0) {
|
|
47
60
|
return { backedUp: [], modified: [], notBackedUp: [] };
|
|
48
61
|
}
|
|
49
62
|
if (!fs.existsSync(STAGING_GIT_DIR)) {
|
|
50
|
-
return failedComparisonResult(new Error(
|
|
63
|
+
return failedComparisonResult(new Error('Backup repository not found. Run "backdot backup" first.'));
|
|
51
64
|
}
|
|
52
65
|
const git = simpleGit(STAGING_DIR);
|
|
53
66
|
try {
|
|
@@ -64,62 +77,97 @@ export async function compareFiles(files, machine, repository) {
|
|
|
64
77
|
catch (err) {
|
|
65
78
|
return failedComparisonResult(err);
|
|
66
79
|
}
|
|
67
|
-
let
|
|
80
|
+
let remoteBlobHashes;
|
|
68
81
|
try {
|
|
69
82
|
const treeOutput = execFileSync("git", ["ls-tree", "-r", `origin/${branch}`, `${machine}/`], {
|
|
70
83
|
encoding: "utf-8",
|
|
71
84
|
cwd: STAGING_DIR,
|
|
72
85
|
});
|
|
73
|
-
|
|
86
|
+
remoteBlobHashes = new Map(treeOutput
|
|
74
87
|
.split("\n")
|
|
75
88
|
.map((line) => line.match(/^\d+ blob ([0-9a-f]+)\t(.+)$/))
|
|
76
|
-
.filter((
|
|
77
|
-
.map((
|
|
89
|
+
.filter((match) => match !== null)
|
|
90
|
+
.map((match) => [match[2], match[1]]));
|
|
78
91
|
}
|
|
79
92
|
catch (err) {
|
|
80
93
|
return failedComparisonResult(err);
|
|
81
94
|
}
|
|
82
|
-
|
|
95
|
+
if (derivedKey) {
|
|
96
|
+
return compareFilesEncrypted(files, machine, remoteBlobHashes, derivedKey);
|
|
97
|
+
}
|
|
98
|
+
let localFileHashes;
|
|
83
99
|
try {
|
|
84
100
|
const hashOutput = execFileSync("git", ["hash-object", "--stdin-paths"], {
|
|
85
101
|
encoding: "utf-8",
|
|
86
102
|
input: files.join("\n") + "\n",
|
|
87
103
|
});
|
|
88
|
-
|
|
104
|
+
localFileHashes = hashOutput.trim().split("\n");
|
|
89
105
|
}
|
|
90
106
|
catch (err) {
|
|
91
107
|
return failedComparisonResult(err);
|
|
92
108
|
}
|
|
93
|
-
return files.reduce((
|
|
109
|
+
return files.reduce((result, file, i) => {
|
|
94
110
|
const repoRelPath = path.relative(STAGING_DIR, getStagedPath(file, machine));
|
|
95
|
-
const
|
|
96
|
-
if (!
|
|
97
|
-
|
|
111
|
+
const remoteBlobHash = remoteBlobHashes.get(repoRelPath);
|
|
112
|
+
if (!remoteBlobHash) {
|
|
113
|
+
result.notBackedUp.push(file);
|
|
98
114
|
}
|
|
99
|
-
else if (
|
|
100
|
-
|
|
115
|
+
else if (remoteBlobHash === localFileHashes[i]) {
|
|
116
|
+
result.backedUp.push(file);
|
|
101
117
|
}
|
|
102
118
|
else {
|
|
103
|
-
|
|
119
|
+
result.modified.push(file);
|
|
104
120
|
}
|
|
105
|
-
return
|
|
121
|
+
return result;
|
|
106
122
|
}, { backedUp: [], modified: [], notBackedUp: [] });
|
|
107
123
|
}
|
|
108
|
-
function
|
|
124
|
+
function compareFilesEncrypted(files, machine, remoteBlobHashes, derivedKey) {
|
|
125
|
+
const result = { backedUp: [], modified: [], notBackedUp: [] };
|
|
126
|
+
for (const file of files) {
|
|
127
|
+
const repoRelPath = path.relative(STAGING_DIR, getStagedPath(file, machine)) + ENC_SUFFIX;
|
|
128
|
+
const remoteBlobHash = remoteBlobHashes.get(repoRelPath);
|
|
129
|
+
if (!remoteBlobHash) {
|
|
130
|
+
result.notBackedUp.push(file);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
const blobContent = execFileSync("git", ["cat-file", "blob", remoteBlobHash], {
|
|
135
|
+
cwd: STAGING_DIR,
|
|
136
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
137
|
+
});
|
|
138
|
+
const decrypted = decrypt(blobContent, derivedKey);
|
|
139
|
+
const localContent = fs.readFileSync(file);
|
|
140
|
+
if (decrypted.equals(localContent)) {
|
|
141
|
+
result.backedUp.push(file);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
result.modified.push(file);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
result.modified.push(file);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
function generateReadmeContent(repository, encrypted) {
|
|
154
|
+
const encryptionNote = encrypted
|
|
155
|
+
? "\n> **Note:** Files in this repository are encrypted. You will need the backup password to restore.\n"
|
|
156
|
+
: "";
|
|
109
157
|
return `# Backdot Backup
|
|
110
158
|
|
|
111
159
|
This repository contains files backed up automatically using [backdot](https://github.com/sorenlouv/backdot).
|
|
112
|
-
|
|
160
|
+
${encryptionNote}
|
|
113
161
|
## Restore
|
|
114
162
|
|
|
115
163
|
\`\`\`bash
|
|
116
|
-
npx backdot
|
|
164
|
+
npx backdot restore ${repository}
|
|
117
165
|
\`\`\`
|
|
118
166
|
|
|
119
167
|
For full documentation, configuration options, and scheduling, see the [official README](https://github.com/sorenlouv/backdot).
|
|
120
168
|
`;
|
|
121
169
|
}
|
|
122
|
-
export function writeRepoReadme(repository) {
|
|
123
|
-
fs.writeFileSync(path.join(STAGING_DIR, "README.md"),
|
|
170
|
+
export function writeRepoReadme(repository, encrypted = false) {
|
|
171
|
+
fs.writeFileSync(path.join(STAGING_DIR, "README.md"), generateReadmeContent(repository, encrypted));
|
|
124
172
|
logger.info("Wrote README.md to staging directory");
|
|
125
173
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backdot",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Lightweight CLI to backup dotfiles and gitignored files to a Git repo on a daily schedule",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@inquirer/prompts": "^8.3.0",
|
|
44
|
+
"cac": "^7.0.0",
|
|
44
45
|
"chalk": "^5.6.2",
|
|
45
46
|
"fast-glob": "^3.3.3",
|
|
46
47
|
"ora": "^9.3.0",
|
|
@@ -52,20 +53,12 @@
|
|
|
52
53
|
"engines": {
|
|
53
54
|
"node": ">=18"
|
|
54
55
|
},
|
|
55
|
-
"lint-staged": {
|
|
56
|
-
"*.{ts,js}": [
|
|
57
|
-
"prettier --write",
|
|
58
|
-
"eslint --fix"
|
|
59
|
-
],
|
|
60
|
-
"*.{json,md,yml}": "prettier --write"
|
|
61
|
-
},
|
|
62
56
|
"devDependencies": {
|
|
63
57
|
"@eslint/js": "^10.0.1",
|
|
64
58
|
"@types/node": "^22.13.5",
|
|
65
59
|
"eslint": "^10.0.2",
|
|
66
60
|
"eslint-config-prettier": "^10.1.8",
|
|
67
61
|
"husky": "^9.1.7",
|
|
68
|
-
"lint-staged": "^16.2.7",
|
|
69
62
|
"prettier": "^3.8.1",
|
|
70
63
|
"typescript": "^5.7.3",
|
|
71
64
|
"typescript-eslint": "^8.56.1",
|