@dhruvwill/skills-cli 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/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc +111 -0
- package/PRD.md +56 -0
- package/README.md +329 -0
- package/bun.lock +45 -0
- package/index.ts +2 -0
- package/package.json +37 -0
- package/src/cli.ts +205 -0
- package/src/commands/doctor.ts +248 -0
- package/src/commands/source.ts +191 -0
- package/src/commands/status.ts +84 -0
- package/src/commands/sync.ts +72 -0
- package/src/commands/target.ts +97 -0
- package/src/commands/update.ts +108 -0
- package/src/lib/config.ts +127 -0
- package/src/lib/hash.ts +80 -0
- package/src/lib/paths.ts +236 -0
- package/src/types.ts +36 -0
- package/tests/cli.test.ts +252 -0
- package/tests/config.test.ts +135 -0
- package/tests/hash.test.ts +119 -0
- package/tests/paths.test.ts +173 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import Table from "cli-table3";
|
|
3
|
+
import { getSources, getTargets } from "../lib/config.ts";
|
|
4
|
+
import { SKILLS_ROOT, SKILLS_STORE, getNamespacePath } from "../lib/paths.ts";
|
|
5
|
+
import { directoryExists, compareDirectories } from "../lib/hash.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Show status overview of sources, targets, and sync state
|
|
9
|
+
*/
|
|
10
|
+
export async function status(): Promise<void> {
|
|
11
|
+
console.log();
|
|
12
|
+
console.log(chalk.bold("Skills Status"));
|
|
13
|
+
console.log(chalk.dim("─".repeat(50)));
|
|
14
|
+
|
|
15
|
+
// Paths
|
|
16
|
+
console.log();
|
|
17
|
+
console.log(chalk.bold("Paths"));
|
|
18
|
+
console.log(` Root: ${chalk.cyan(SKILLS_ROOT)}`);
|
|
19
|
+
console.log(` Store: ${chalk.cyan(SKILLS_STORE)}`);
|
|
20
|
+
|
|
21
|
+
// Sources summary
|
|
22
|
+
const sources = await getSources();
|
|
23
|
+
console.log();
|
|
24
|
+
console.log(chalk.bold(`Sources (${sources.length})`));
|
|
25
|
+
|
|
26
|
+
if (sources.length === 0) {
|
|
27
|
+
console.log(chalk.dim(" No sources registered"));
|
|
28
|
+
} else {
|
|
29
|
+
for (const source of sources) {
|
|
30
|
+
const exists = await directoryExists(getNamespacePath(source.namespace));
|
|
31
|
+
const icon = exists ? chalk.green("●") : chalk.red("●");
|
|
32
|
+
const typeLabel = source.type === "remote" ? chalk.blue("remote") : chalk.magenta("local");
|
|
33
|
+
console.log(` ${icon} ${source.namespace} ${chalk.dim(`(${typeLabel})`)}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Targets summary
|
|
38
|
+
const targets = await getTargets();
|
|
39
|
+
console.log();
|
|
40
|
+
console.log(chalk.bold(`Targets (${targets.length})`));
|
|
41
|
+
|
|
42
|
+
if (targets.length === 0) {
|
|
43
|
+
console.log(chalk.dim(" No targets registered"));
|
|
44
|
+
} else {
|
|
45
|
+
for (const target of targets) {
|
|
46
|
+
const syncStatus = await compareDirectories(SKILLS_STORE, target.path);
|
|
47
|
+
let icon: string;
|
|
48
|
+
let statusText: string;
|
|
49
|
+
|
|
50
|
+
if (syncStatus === "synced") {
|
|
51
|
+
icon = chalk.green("●");
|
|
52
|
+
statusText = chalk.green("synced");
|
|
53
|
+
} else if (syncStatus === "not synced") {
|
|
54
|
+
icon = chalk.yellow("●");
|
|
55
|
+
statusText = chalk.yellow("outdated");
|
|
56
|
+
} else {
|
|
57
|
+
icon = chalk.red("●");
|
|
58
|
+
statusText = chalk.red("missing");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log(` ${icon} ${target.name} ${chalk.dim(`→ ${statusText}`)}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Quick actions
|
|
66
|
+
console.log();
|
|
67
|
+
console.log(chalk.bold("Quick Actions"));
|
|
68
|
+
|
|
69
|
+
if (sources.length === 0) {
|
|
70
|
+
console.log(` ${chalk.cyan("skills source add <url> --remote")} Add a remote skill`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (targets.length === 0) {
|
|
74
|
+
console.log(` ${chalk.cyan("skills target add <name> <path>")} Add a target`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (sources.length > 0 && targets.length > 0) {
|
|
78
|
+
console.log(` ${chalk.cyan("skills sync")} Push skills to all targets`);
|
|
79
|
+
console.log(` ${chalk.cyan("skills update")} Refresh all sources`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log(` ${chalk.cyan("skills doctor")} Run diagnostics`);
|
|
83
|
+
console.log();
|
|
84
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { cp, rm, mkdir } from "fs/promises";
|
|
2
|
+
import { getTargets } from "../lib/config.ts";
|
|
3
|
+
import { SKILLS_STORE } from "../lib/paths.ts";
|
|
4
|
+
import { directoryExists } from "../lib/hash.ts";
|
|
5
|
+
import type { Target } from "../types.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Sync a single target with the store
|
|
9
|
+
*/
|
|
10
|
+
export async function syncToTarget(target: Target): Promise<void> {
|
|
11
|
+
// Ensure target directory exists
|
|
12
|
+
await mkdir(target.path, { recursive: true });
|
|
13
|
+
|
|
14
|
+
// Check if store has content
|
|
15
|
+
const storeExists = await directoryExists(SKILLS_STORE);
|
|
16
|
+
if (!storeExists) {
|
|
17
|
+
console.log(` ${target.name}: Store is empty, nothing to sync`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Clear target and copy fresh from store
|
|
22
|
+
// We remove contents but keep the directory
|
|
23
|
+
const entries = await Bun.file(target.path).exists() ? [] : [];
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
// Remove existing contents
|
|
27
|
+
const { readdir } = await import("fs/promises");
|
|
28
|
+
const existingFiles = await readdir(target.path);
|
|
29
|
+
|
|
30
|
+
for (const file of existingFiles) {
|
|
31
|
+
await rm(`${target.path}/${file}`, { recursive: true, force: true });
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
// Directory might be empty or not exist
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Copy store contents to target
|
|
38
|
+
await cp(SKILLS_STORE, target.path, { recursive: true });
|
|
39
|
+
|
|
40
|
+
console.log(` ${target.name}: Synced`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Sync all targets with the store
|
|
45
|
+
*/
|
|
46
|
+
export async function sync(): Promise<void> {
|
|
47
|
+
const targets = await getTargets();
|
|
48
|
+
|
|
49
|
+
if (targets.length === 0) {
|
|
50
|
+
console.log("No targets registered.");
|
|
51
|
+
console.log("Add a target with: skills target add <name> <path>");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const storeExists = await directoryExists(SKILLS_STORE);
|
|
56
|
+
if (!storeExists) {
|
|
57
|
+
console.log("Store is empty. Add sources first with: skills source add <url> --remote");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log("Syncing skills to all targets...\n");
|
|
62
|
+
|
|
63
|
+
for (const target of targets) {
|
|
64
|
+
try {
|
|
65
|
+
await syncToTarget(target);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error(` ${target.name}: Failed - ${error instanceof Error ? error.message : error}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
console.log("\nSync complete.");
|
|
72
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { resolve } from "path";
|
|
2
|
+
import { mkdir } from "fs/promises";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import Table from "cli-table3";
|
|
5
|
+
import { addTarget, removeTarget, getTargets } from "../lib/config.ts";
|
|
6
|
+
import { SKILLS_STORE } from "../lib/paths.ts";
|
|
7
|
+
import { compareDirectories, directoryExists } from "../lib/hash.ts";
|
|
8
|
+
import { syncToTarget } from "./sync.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Add a target directory
|
|
12
|
+
*/
|
|
13
|
+
export async function targetAdd(name: string, path: string): Promise<void> {
|
|
14
|
+
const absolutePath = resolve(path.replace(/^~/, process.env.HOME || process.env.USERPROFILE || ""));
|
|
15
|
+
|
|
16
|
+
console.log(`Adding target: ${name}`);
|
|
17
|
+
console.log(`Path: ${absolutePath}`);
|
|
18
|
+
|
|
19
|
+
// Create the target directory if it doesn't exist
|
|
20
|
+
await mkdir(absolutePath, { recursive: true });
|
|
21
|
+
|
|
22
|
+
// Add to config
|
|
23
|
+
await addTarget({
|
|
24
|
+
name,
|
|
25
|
+
path: absolutePath,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
console.log(`Successfully registered target: ${name}`);
|
|
29
|
+
|
|
30
|
+
// Perform initial sync
|
|
31
|
+
console.log("Performing initial sync...");
|
|
32
|
+
await syncToTarget({ name, path: absolutePath });
|
|
33
|
+
|
|
34
|
+
console.log(`Target "${name}" is now synced.`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Remove a target by name
|
|
39
|
+
*/
|
|
40
|
+
export async function targetRemove(name: string): Promise<void> {
|
|
41
|
+
const removed = await removeTarget(name);
|
|
42
|
+
|
|
43
|
+
if (!removed) {
|
|
44
|
+
throw new Error(`Target not found: ${name}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.log(`Removed target: ${name}`);
|
|
48
|
+
console.log(`Note: Files at ${removed.path} were not deleted.`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* List all targets with sync status in a table format
|
|
53
|
+
*/
|
|
54
|
+
export async function targetList(): Promise<void> {
|
|
55
|
+
const targets = await getTargets();
|
|
56
|
+
|
|
57
|
+
if (targets.length === 0) {
|
|
58
|
+
console.log("No targets registered.");
|
|
59
|
+
console.log(`Add a target with: ${chalk.cyan("skills target add <name> <path>")}`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const table = new Table({
|
|
64
|
+
head: [
|
|
65
|
+
chalk.bold("Name"),
|
|
66
|
+
chalk.bold("Path"),
|
|
67
|
+
chalk.bold("Status"),
|
|
68
|
+
],
|
|
69
|
+
style: {
|
|
70
|
+
head: [],
|
|
71
|
+
border: [],
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
for (const target of targets) {
|
|
76
|
+
const status = await compareDirectories(SKILLS_STORE, target.path);
|
|
77
|
+
let statusDisplay: string;
|
|
78
|
+
|
|
79
|
+
if (status === "synced") {
|
|
80
|
+
statusDisplay = chalk.green("synced");
|
|
81
|
+
} else if (status === "not synced") {
|
|
82
|
+
statusDisplay = chalk.yellow("outdated");
|
|
83
|
+
} else {
|
|
84
|
+
statusDisplay = chalk.red("missing");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
table.push([
|
|
88
|
+
target.name,
|
|
89
|
+
target.path,
|
|
90
|
+
statusDisplay,
|
|
91
|
+
]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log();
|
|
95
|
+
console.log(table.toString());
|
|
96
|
+
console.log();
|
|
97
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { $ } from "bun";
|
|
2
|
+
import { rm, cp } from "fs/promises";
|
|
3
|
+
import { getSources } from "../lib/config.ts";
|
|
4
|
+
import { getNamespacePath, parseGitUrl } from "../lib/paths.ts";
|
|
5
|
+
import { directoryExists } from "../lib/hash.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Update all sources from their origins
|
|
9
|
+
*/
|
|
10
|
+
export async function update(): Promise<void> {
|
|
11
|
+
const sources = await getSources();
|
|
12
|
+
|
|
13
|
+
if (sources.length === 0) {
|
|
14
|
+
console.log("No sources registered.");
|
|
15
|
+
console.log("Add a source with: skills source add <url> --remote");
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
console.log("Updating all sources...\n");
|
|
20
|
+
|
|
21
|
+
for (const source of sources) {
|
|
22
|
+
try {
|
|
23
|
+
if (source.type === "remote") {
|
|
24
|
+
await updateRemoteSource(source.namespace, source.url!);
|
|
25
|
+
} else {
|
|
26
|
+
await updateLocalSource(source.namespace, source.path!);
|
|
27
|
+
}
|
|
28
|
+
} catch (error) {
|
|
29
|
+
console.error(` ${source.namespace}: Failed - ${error instanceof Error ? error.message : error}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
console.log("\nUpdate complete.");
|
|
34
|
+
console.log("Run 'skills sync' to push changes to targets.");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Update a remote source by re-cloning
|
|
39
|
+
*/
|
|
40
|
+
async function updateRemoteSource(namespace: string, url: string): Promise<void> {
|
|
41
|
+
const targetPath = getNamespacePath(namespace);
|
|
42
|
+
const parsed = parseGitUrl(url);
|
|
43
|
+
|
|
44
|
+
if (!parsed) {
|
|
45
|
+
throw new Error(`Invalid URL: ${url}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const cloneUrl = parsed.cloneUrl;
|
|
49
|
+
const branch = parsed.branch || "main";
|
|
50
|
+
|
|
51
|
+
console.log(` ${namespace}: Updating from ${url}...`);
|
|
52
|
+
|
|
53
|
+
// Remove existing directory
|
|
54
|
+
await rm(targetPath, { recursive: true, force: true });
|
|
55
|
+
|
|
56
|
+
// Re-clone
|
|
57
|
+
try {
|
|
58
|
+
if (parsed.subdir) {
|
|
59
|
+
// Use sparse checkout for subdirectory
|
|
60
|
+
const tempPath = `${targetPath}_temp`;
|
|
61
|
+
|
|
62
|
+
await $`git clone --filter=blob:none --no-checkout --depth 1 --branch ${branch} ${cloneUrl} ${tempPath}`.quiet();
|
|
63
|
+
await $`git -C ${tempPath} sparse-checkout init --cone`.quiet();
|
|
64
|
+
await $`git -C ${tempPath} sparse-checkout set ${parsed.subdir}`.quiet();
|
|
65
|
+
await $`git -C ${tempPath} checkout`.quiet();
|
|
66
|
+
|
|
67
|
+
// Move subdirectory to target
|
|
68
|
+
const subdirFullPath = `${tempPath}/${parsed.subdir}`;
|
|
69
|
+
await cp(subdirFullPath, targetPath, { recursive: true });
|
|
70
|
+
|
|
71
|
+
// Clean up
|
|
72
|
+
await rm(tempPath, { recursive: true, force: true });
|
|
73
|
+
} else {
|
|
74
|
+
await $`git clone --depth 1 --branch ${branch} ${cloneUrl} ${targetPath}`.quiet();
|
|
75
|
+
|
|
76
|
+
// Remove .git directory
|
|
77
|
+
await rm(`${targetPath}/.git`, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.log(` ${namespace}: Updated`);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
// Clean up on failure
|
|
83
|
+
await rm(`${targetPath}_temp`, { recursive: true, force: true });
|
|
84
|
+
throw new Error(`Failed to clone: ${error}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Update a local source by re-copying
|
|
90
|
+
*/
|
|
91
|
+
async function updateLocalSource(namespace: string, sourcePath: string): Promise<void> {
|
|
92
|
+
const targetPath = getNamespacePath(namespace);
|
|
93
|
+
|
|
94
|
+
console.log(` ${namespace}: Updating from ${sourcePath}...`);
|
|
95
|
+
|
|
96
|
+
// Check if source still exists
|
|
97
|
+
if (!(await directoryExists(sourcePath))) {
|
|
98
|
+
throw new Error(`Source folder no longer exists: ${sourcePath}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Remove existing directory
|
|
102
|
+
await rm(targetPath, { recursive: true, force: true });
|
|
103
|
+
|
|
104
|
+
// Re-copy
|
|
105
|
+
await cp(sourcePath, targetPath, { recursive: true });
|
|
106
|
+
|
|
107
|
+
console.log(` ${namespace}: Updated`);
|
|
108
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { mkdir } from "fs/promises";
|
|
2
|
+
import { SKILLS_ROOT, SKILLS_STORE, CONFIG_PATH } from "./paths.ts";
|
|
3
|
+
import type { Config, Source, Target } from "../types.ts";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_CONFIG: Config = {
|
|
6
|
+
sources: [],
|
|
7
|
+
targets: [],
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Ensure the skills directories exist
|
|
12
|
+
*/
|
|
13
|
+
export async function ensureDirectories(): Promise<void> {
|
|
14
|
+
await mkdir(SKILLS_ROOT, { recursive: true });
|
|
15
|
+
await mkdir(SKILLS_STORE, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Read the config file, creating it if it doesn't exist
|
|
20
|
+
*/
|
|
21
|
+
export async function readConfig(): Promise<Config> {
|
|
22
|
+
await ensureDirectories();
|
|
23
|
+
|
|
24
|
+
const configFile = Bun.file(CONFIG_PATH);
|
|
25
|
+
|
|
26
|
+
if (!(await configFile.exists())) {
|
|
27
|
+
await writeConfig(DEFAULT_CONFIG);
|
|
28
|
+
return DEFAULT_CONFIG;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const content = await configFile.json();
|
|
33
|
+
return content as Config;
|
|
34
|
+
} catch {
|
|
35
|
+
// If config is corrupted, reset it
|
|
36
|
+
await writeConfig(DEFAULT_CONFIG);
|
|
37
|
+
return DEFAULT_CONFIG;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Write the config file
|
|
43
|
+
*/
|
|
44
|
+
export async function writeConfig(config: Config): Promise<void> {
|
|
45
|
+
await ensureDirectories();
|
|
46
|
+
await Bun.write(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Add a source to the config
|
|
51
|
+
*/
|
|
52
|
+
export async function addSource(source: Source): Promise<void> {
|
|
53
|
+
const config = await readConfig();
|
|
54
|
+
|
|
55
|
+
// Check if namespace already exists
|
|
56
|
+
const existing = config.sources.find(s => s.namespace === source.namespace);
|
|
57
|
+
if (existing) {
|
|
58
|
+
throw new Error(`Source with namespace "${source.namespace}" already exists`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
config.sources.push(source);
|
|
62
|
+
await writeConfig(config);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Remove a source from the config
|
|
67
|
+
*/
|
|
68
|
+
export async function removeSource(namespace: string): Promise<Source | null> {
|
|
69
|
+
const config = await readConfig();
|
|
70
|
+
|
|
71
|
+
const index = config.sources.findIndex(s => s.namespace === namespace);
|
|
72
|
+
if (index === -1) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const [removed] = config.sources.splice(index, 1);
|
|
77
|
+
await writeConfig(config);
|
|
78
|
+
return removed;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get all sources
|
|
83
|
+
*/
|
|
84
|
+
export async function getSources(): Promise<Source[]> {
|
|
85
|
+
const config = await readConfig();
|
|
86
|
+
return config.sources;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Add a target to the config
|
|
91
|
+
*/
|
|
92
|
+
export async function addTarget(target: Target): Promise<void> {
|
|
93
|
+
const config = await readConfig();
|
|
94
|
+
|
|
95
|
+
// Check if name already exists
|
|
96
|
+
const existing = config.targets.find(t => t.name === target.name);
|
|
97
|
+
if (existing) {
|
|
98
|
+
throw new Error(`Target with name "${target.name}" already exists`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
config.targets.push(target);
|
|
102
|
+
await writeConfig(config);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Remove a target from the config
|
|
107
|
+
*/
|
|
108
|
+
export async function removeTarget(name: string): Promise<Target | null> {
|
|
109
|
+
const config = await readConfig();
|
|
110
|
+
|
|
111
|
+
const index = config.targets.findIndex(t => t.name === name);
|
|
112
|
+
if (index === -1) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const [removed] = config.targets.splice(index, 1);
|
|
117
|
+
await writeConfig(config);
|
|
118
|
+
return removed;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get all targets
|
|
123
|
+
*/
|
|
124
|
+
export async function getTargets(): Promise<Target[]> {
|
|
125
|
+
const config = await readConfig();
|
|
126
|
+
return config.targets;
|
|
127
|
+
}
|
package/src/lib/hash.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { readdir, stat } from "fs/promises";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Recursively get all files in a directory with their content hashes
|
|
6
|
+
*/
|
|
7
|
+
async function getDirectoryFiles(dirPath: string, basePath: string = ""): Promise<Map<string, string>> {
|
|
8
|
+
const files = new Map<string, string>();
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
12
|
+
|
|
13
|
+
for (const entry of entries) {
|
|
14
|
+
const fullPath = join(dirPath, entry.name);
|
|
15
|
+
const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name;
|
|
16
|
+
|
|
17
|
+
if (entry.isDirectory()) {
|
|
18
|
+
const subFiles = await getDirectoryFiles(fullPath, relativePath);
|
|
19
|
+
for (const [path, hash] of subFiles) {
|
|
20
|
+
files.set(path, hash);
|
|
21
|
+
}
|
|
22
|
+
} else if (entry.isFile()) {
|
|
23
|
+
const file = Bun.file(fullPath);
|
|
24
|
+
const content = await file.arrayBuffer();
|
|
25
|
+
const hash = Bun.hash(content).toString(16);
|
|
26
|
+
files.set(relativePath, hash);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// Directory doesn't exist or can't be read
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return files;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Compute a hash representing the entire directory structure and contents
|
|
38
|
+
*/
|
|
39
|
+
export async function hashDirectory(dirPath: string): Promise<string> {
|
|
40
|
+
const files = await getDirectoryFiles(dirPath);
|
|
41
|
+
|
|
42
|
+
if (files.size === 0) {
|
|
43
|
+
return "empty";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Sort files by path for consistent hashing
|
|
47
|
+
const sortedEntries = Array.from(files.entries()).sort((a, b) => a[0].localeCompare(b[0]));
|
|
48
|
+
|
|
49
|
+
// Combine all file paths and hashes
|
|
50
|
+
const combined = sortedEntries.map(([path, hash]) => `${path}:${hash}`).join("|");
|
|
51
|
+
|
|
52
|
+
return Bun.hash(combined).toString(16);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check if a directory exists and is not empty
|
|
57
|
+
*/
|
|
58
|
+
export async function directoryExists(dirPath: string): Promise<boolean> {
|
|
59
|
+
try {
|
|
60
|
+
const stats = await stat(dirPath);
|
|
61
|
+
return stats.isDirectory();
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Compare two directories and return if they are in sync
|
|
69
|
+
*/
|
|
70
|
+
export async function compareDirectories(source: string, target: string): Promise<"synced" | "not synced" | "target missing"> {
|
|
71
|
+
const targetExists = await directoryExists(target);
|
|
72
|
+
if (!targetExists) {
|
|
73
|
+
return "target missing";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const sourceHash = await hashDirectory(source);
|
|
77
|
+
const targetHash = await hashDirectory(target);
|
|
78
|
+
|
|
79
|
+
return sourceHash === targetHash ? "synced" : "not synced";
|
|
80
|
+
}
|