skill-flow 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 +190 -0
- package/README.md +108 -0
- package/README.zh.md +108 -0
- package/dist/adapters/channel-adapters.d.ts +8 -0
- package/dist/adapters/channel-adapters.js +56 -0
- package/dist/adapters/channel-adapters.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +118 -0
- package/dist/cli.js.map +1 -0
- package/dist/domain/types.d.ts +133 -0
- package/dist/domain/types.js +2 -0
- package/dist/domain/types.js.map +1 -0
- package/dist/services/deployment-applier.d.ts +6 -0
- package/dist/services/deployment-applier.js +54 -0
- package/dist/services/deployment-applier.js.map +1 -0
- package/dist/services/deployment-planner.d.ts +11 -0
- package/dist/services/deployment-planner.js +179 -0
- package/dist/services/deployment-planner.js.map +1 -0
- package/dist/services/doctor-service.d.ts +5 -0
- package/dist/services/doctor-service.js +129 -0
- package/dist/services/doctor-service.js.map +1 -0
- package/dist/services/inventory-service.d.ts +14 -0
- package/dist/services/inventory-service.js +186 -0
- package/dist/services/inventory-service.js.map +1 -0
- package/dist/services/skill-flow.d.ts +60 -0
- package/dist/services/skill-flow.js +260 -0
- package/dist/services/skill-flow.js.map +1 -0
- package/dist/services/source-service.d.ts +35 -0
- package/dist/services/source-service.js +270 -0
- package/dist/services/source-service.js.map +1 -0
- package/dist/services/workflow-service.d.ts +5 -0
- package/dist/services/workflow-service.js +32 -0
- package/dist/services/workflow-service.js.map +1 -0
- package/dist/state/store.d.ts +14 -0
- package/dist/state/store.js +59 -0
- package/dist/state/store.js.map +1 -0
- package/dist/tests/skill-flow.test.d.ts +1 -0
- package/dist/tests/skill-flow.test.js +926 -0
- package/dist/tests/skill-flow.test.js.map +1 -0
- package/dist/tui/config-app.d.ts +47 -0
- package/dist/tui/config-app.js +732 -0
- package/dist/tui/config-app.js.map +1 -0
- package/dist/tui/selection-state.d.ts +8 -0
- package/dist/tui/selection-state.js +32 -0
- package/dist/tui/selection-state.js.map +1 -0
- package/dist/utils/constants.d.ts +19 -0
- package/dist/utils/constants.js +164 -0
- package/dist/utils/constants.js.map +1 -0
- package/dist/utils/format.d.ts +6 -0
- package/dist/utils/format.js +45 -0
- package/dist/utils/format.js.map +1 -0
- package/dist/utils/fs.d.ts +10 -0
- package/dist/utils/fs.js +89 -0
- package/dist/utils/fs.js.map +1 -0
- package/dist/utils/git.d.ts +3 -0
- package/dist/utils/git.js +12 -0
- package/dist/utils/git.js.map +1 -0
- package/dist/utils/result.d.ts +4 -0
- package/dist/utils/result.js +15 -0
- package/dist/utils/result.js.map +1 -0
- package/dist/utils/source-id.d.ts +2 -0
- package/dist/utils/source-id.js +16 -0
- package/dist/utils/source-id.js.map +1 -0
- package/img/img-1.jpg +0 -0
- package/package.json +39 -0
- package/src/adapters/channel-adapters.ts +75 -0
- package/src/cli.tsx +147 -0
- package/src/domain/types.ts +175 -0
- package/src/services/deployment-applier.ts +81 -0
- package/src/services/deployment-planner.ts +259 -0
- package/src/services/doctor-service.ts +156 -0
- package/src/services/inventory-service.ts +251 -0
- package/src/services/skill-flow.ts +381 -0
- package/src/services/source-service.ts +427 -0
- package/src/services/workflow-service.ts +56 -0
- package/src/state/store.ts +68 -0
- package/src/tests/skill-flow.test.ts +1184 -0
- package/src/tui/config-app.tsx +1094 -0
- package/src/tui/selection-state.ts +45 -0
- package/src/utils/constants.ts +201 -0
- package/src/utils/format.ts +59 -0
- package/src/utils/fs.ts +102 -0
- package/src/utils/git.ts +16 -0
- package/src/utils/result.ts +23 -0
- package/src/utils/source-id.ts +19 -0
- package/tsconfig.json +22 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type ParentSelectionState = "empty" | "partial" | "full";
|
|
2
|
+
|
|
3
|
+
export type TreeSelectionState = {
|
|
4
|
+
allLeafIds: string[];
|
|
5
|
+
selectedLeafIds: string[];
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function getParentSelectionState(
|
|
9
|
+
state: TreeSelectionState,
|
|
10
|
+
): ParentSelectionState {
|
|
11
|
+
if (state.selectedLeafIds.length === 0) {
|
|
12
|
+
return "empty";
|
|
13
|
+
}
|
|
14
|
+
if (state.selectedLeafIds.length === state.allLeafIds.length) {
|
|
15
|
+
return "full";
|
|
16
|
+
}
|
|
17
|
+
return "partial";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// parent
|
|
21
|
+
// [ ] -> [x] -> [-] transitions come from child toggles
|
|
22
|
+
// children
|
|
23
|
+
// toggle child recalculates parent from current selected set
|
|
24
|
+
export function toggleParent(state: TreeSelectionState): TreeSelectionState {
|
|
25
|
+
return getParentSelectionState(state) === "full"
|
|
26
|
+
? { ...state, selectedLeafIds: [] }
|
|
27
|
+
: { ...state, selectedLeafIds: [...state.allLeafIds] };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function toggleChild(
|
|
31
|
+
state: TreeSelectionState,
|
|
32
|
+
leafId: string,
|
|
33
|
+
): TreeSelectionState {
|
|
34
|
+
const selected = new Set(state.selectedLeafIds);
|
|
35
|
+
if (selected.has(leafId)) {
|
|
36
|
+
selected.delete(leafId);
|
|
37
|
+
} else {
|
|
38
|
+
selected.add(leafId);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
...state,
|
|
43
|
+
selectedLeafIds: state.allLeafIds.filter((id) => selected.has(id)),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type {
|
|
4
|
+
DeploymentStrategy,
|
|
5
|
+
DeploymentTargetName,
|
|
6
|
+
} from "../domain/types.js";
|
|
7
|
+
|
|
8
|
+
export const SCHEMA_VERSION = 1 as const;
|
|
9
|
+
|
|
10
|
+
export function getStateRoot(): string {
|
|
11
|
+
return process.env.SKILL_FLOW_STATE_ROOT
|
|
12
|
+
? path.resolve(process.env.SKILL_FLOW_STATE_ROOT)
|
|
13
|
+
: path.join(os.homedir(), ".skillflow");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type TargetDefinition = {
|
|
17
|
+
label: string;
|
|
18
|
+
strategy: DeploymentStrategy;
|
|
19
|
+
envVar: string;
|
|
20
|
+
writerKey: string;
|
|
21
|
+
writeRootCandidates: string[];
|
|
22
|
+
compatReadRootCandidates: string[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const TARGET_ORDER: DeploymentTargetName[] = [
|
|
26
|
+
"claude-code",
|
|
27
|
+
"codex",
|
|
28
|
+
"cursor",
|
|
29
|
+
"github-copilot",
|
|
30
|
+
"gemini-cli",
|
|
31
|
+
"opencode",
|
|
32
|
+
"openclaw",
|
|
33
|
+
"pi",
|
|
34
|
+
"windsurf",
|
|
35
|
+
"roo-code",
|
|
36
|
+
"cline",
|
|
37
|
+
"amp",
|
|
38
|
+
"kiro",
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
export const TARGET_DEFINITIONS: Record<DeploymentTargetName, TargetDefinition> = {
|
|
42
|
+
"claude-code": {
|
|
43
|
+
label: "Claude Code",
|
|
44
|
+
strategy: "symlink",
|
|
45
|
+
envVar: "SKILL_FLOW_TARGET_CLAUDE_CODE",
|
|
46
|
+
writerKey: "claude-home",
|
|
47
|
+
writeRootCandidates: [path.join(os.homedir(), ".claude", "skills")],
|
|
48
|
+
compatReadRootCandidates: [],
|
|
49
|
+
},
|
|
50
|
+
codex: {
|
|
51
|
+
label: "Codex",
|
|
52
|
+
strategy: "symlink",
|
|
53
|
+
envVar: "SKILL_FLOW_TARGET_CODEX",
|
|
54
|
+
writerKey: "agents-skills",
|
|
55
|
+
writeRootCandidates: [
|
|
56
|
+
path.join(os.homedir(), ".agents", "skills"),
|
|
57
|
+
path.join(os.homedir(), ".codex", ".agents", "skills"),
|
|
58
|
+
],
|
|
59
|
+
compatReadRootCandidates: [path.join("/etc", "codex", "skills")],
|
|
60
|
+
},
|
|
61
|
+
cursor: {
|
|
62
|
+
label: "Cursor",
|
|
63
|
+
strategy: "symlink",
|
|
64
|
+
envVar: "SKILL_FLOW_TARGET_CURSOR",
|
|
65
|
+
writerKey: "cursor-home",
|
|
66
|
+
writeRootCandidates: [path.join(os.homedir(), ".cursor", "skills")],
|
|
67
|
+
compatReadRootCandidates: [
|
|
68
|
+
path.join(os.homedir(), ".agents", "skills"),
|
|
69
|
+
path.join(os.homedir(), ".claude", "skills"),
|
|
70
|
+
path.join(os.homedir(), ".codex", "skills"),
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
"github-copilot": {
|
|
74
|
+
label: "GitHub Copilot",
|
|
75
|
+
strategy: "symlink",
|
|
76
|
+
envVar: "SKILL_FLOW_TARGET_GITHUB_COPILOT",
|
|
77
|
+
writerKey: "copilot-home",
|
|
78
|
+
writeRootCandidates: [path.join(os.homedir(), ".copilot", "skills")],
|
|
79
|
+
compatReadRootCandidates: [
|
|
80
|
+
path.join(os.homedir(), ".claude", "skills"),
|
|
81
|
+
path.join(os.homedir(), ".agents", "skills"),
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
"gemini-cli": {
|
|
85
|
+
label: "Gemini CLI",
|
|
86
|
+
strategy: "symlink",
|
|
87
|
+
envVar: "SKILL_FLOW_TARGET_GEMINI_CLI",
|
|
88
|
+
writerKey: "gemini-home",
|
|
89
|
+
writeRootCandidates: [path.join(os.homedir(), ".gemini", "skills")],
|
|
90
|
+
compatReadRootCandidates: [path.join(os.homedir(), ".agents", "skills")],
|
|
91
|
+
},
|
|
92
|
+
opencode: {
|
|
93
|
+
label: "OpenCode",
|
|
94
|
+
strategy: "symlink",
|
|
95
|
+
envVar: "SKILL_FLOW_TARGET_OPENCODE",
|
|
96
|
+
writerKey: "opencode-home",
|
|
97
|
+
writeRootCandidates: [
|
|
98
|
+
path.join(os.homedir(), ".config", "opencode", "skills"),
|
|
99
|
+
path.join(os.homedir(), ".opencode", "skills"),
|
|
100
|
+
],
|
|
101
|
+
compatReadRootCandidates: [
|
|
102
|
+
path.join(os.homedir(), ".claude", "skills"),
|
|
103
|
+
path.join(os.homedir(), ".agents", "skills"),
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
openclaw: {
|
|
107
|
+
label: "OpenClaw",
|
|
108
|
+
strategy: "copy",
|
|
109
|
+
envVar: "SKILL_FLOW_TARGET_OPENCLAW",
|
|
110
|
+
writerKey: "openclaw-home",
|
|
111
|
+
writeRootCandidates: [path.join(os.homedir(), ".openclaw", "skills")],
|
|
112
|
+
compatReadRootCandidates: [],
|
|
113
|
+
},
|
|
114
|
+
pi: {
|
|
115
|
+
label: "Pi",
|
|
116
|
+
strategy: "symlink",
|
|
117
|
+
envVar: "SKILL_FLOW_TARGET_PI",
|
|
118
|
+
writerKey: "pi-home",
|
|
119
|
+
writeRootCandidates: [path.join(os.homedir(), ".pi", "agent", "skills")],
|
|
120
|
+
compatReadRootCandidates: [
|
|
121
|
+
path.join(os.homedir(), ".agents", "skills"),
|
|
122
|
+
path.join(os.homedir(), ".claude", "skills"),
|
|
123
|
+
path.join(os.homedir(), ".codex", "skills"),
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
windsurf: {
|
|
127
|
+
label: "Windsurf",
|
|
128
|
+
strategy: "symlink",
|
|
129
|
+
envVar: "SKILL_FLOW_TARGET_WINDSURF",
|
|
130
|
+
writerKey: "windsurf-home",
|
|
131
|
+
writeRootCandidates: [path.join(os.homedir(), ".codeium", "windsurf", "skills")],
|
|
132
|
+
compatReadRootCandidates: [
|
|
133
|
+
path.join("/Library", "Application Support", "Windsurf", "skills"),
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
"roo-code": {
|
|
137
|
+
label: "Roo Code",
|
|
138
|
+
strategy: "symlink",
|
|
139
|
+
envVar: "SKILL_FLOW_TARGET_ROO_CODE",
|
|
140
|
+
writerKey: "roo-home",
|
|
141
|
+
writeRootCandidates: [path.join(os.homedir(), ".roo", "skills")],
|
|
142
|
+
compatReadRootCandidates: [],
|
|
143
|
+
},
|
|
144
|
+
cline: {
|
|
145
|
+
label: "Cline",
|
|
146
|
+
strategy: "symlink",
|
|
147
|
+
envVar: "SKILL_FLOW_TARGET_CLINE",
|
|
148
|
+
writerKey: "cline-home",
|
|
149
|
+
writeRootCandidates: [path.join(os.homedir(), ".cline", "skills")],
|
|
150
|
+
compatReadRootCandidates: [path.join(os.homedir(), ".claude", "skills")],
|
|
151
|
+
},
|
|
152
|
+
amp: {
|
|
153
|
+
label: "Amp",
|
|
154
|
+
strategy: "symlink",
|
|
155
|
+
envVar: "SKILL_FLOW_TARGET_AMP",
|
|
156
|
+
writerKey: "amp-home",
|
|
157
|
+
writeRootCandidates: [
|
|
158
|
+
path.join(os.homedir(), ".config", "agents", "skills"),
|
|
159
|
+
path.join(os.homedir(), ".config", "amp", "skills"),
|
|
160
|
+
],
|
|
161
|
+
compatReadRootCandidates: [path.join(os.homedir(), ".claude", "skills")],
|
|
162
|
+
},
|
|
163
|
+
kiro: {
|
|
164
|
+
label: "Kiro",
|
|
165
|
+
strategy: "symlink",
|
|
166
|
+
envVar: "SKILL_FLOW_TARGET_KIRO",
|
|
167
|
+
writerKey: "kiro-home",
|
|
168
|
+
writeRootCandidates: [path.join(os.homedir(), ".kiro", "skills")],
|
|
169
|
+
compatReadRootCandidates: [],
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export const TARGET_LABELS: Record<DeploymentTargetName, string> = Object.fromEntries(
|
|
174
|
+
TARGET_ORDER.map((target) => [target, TARGET_DEFINITIONS[target].label]),
|
|
175
|
+
) as Record<DeploymentTargetName, string>;
|
|
176
|
+
|
|
177
|
+
export const TARGET_STRATEGIES: Record<DeploymentTargetName, DeploymentStrategy> =
|
|
178
|
+
Object.fromEntries(
|
|
179
|
+
TARGET_ORDER.map((target) => [target, TARGET_DEFINITIONS[target].strategy]),
|
|
180
|
+
) as Record<DeploymentTargetName, DeploymentStrategy>;
|
|
181
|
+
|
|
182
|
+
export const TARGET_ENV_VARS: Record<DeploymentTargetName, string> = Object.fromEntries(
|
|
183
|
+
TARGET_ORDER.map((target) => [target, TARGET_DEFINITIONS[target].envVar]),
|
|
184
|
+
) as Record<DeploymentTargetName, string>;
|
|
185
|
+
|
|
186
|
+
export const TARGET_WRITER_KEYS: Record<DeploymentTargetName, string> = Object.fromEntries(
|
|
187
|
+
TARGET_ORDER.map((target) => [target, TARGET_DEFINITIONS[target].writerKey]),
|
|
188
|
+
) as Record<DeploymentTargetName, string>;
|
|
189
|
+
|
|
190
|
+
export const TARGET_PATH_CANDIDATES: Record<DeploymentTargetName, string[]> =
|
|
191
|
+
Object.fromEntries(
|
|
192
|
+
TARGET_ORDER.map((target) => [target, TARGET_DEFINITIONS[target].writeRootCandidates]),
|
|
193
|
+
) as Record<DeploymentTargetName, string[]>;
|
|
194
|
+
|
|
195
|
+
export const TARGET_COMPAT_READ_CANDIDATES: Record<DeploymentTargetName, string[]> =
|
|
196
|
+
Object.fromEntries(
|
|
197
|
+
TARGET_ORDER.map((target) => [
|
|
198
|
+
target,
|
|
199
|
+
TARGET_DEFINITIONS[target].compatReadRootCandidates,
|
|
200
|
+
]),
|
|
201
|
+
) as Record<DeploymentTargetName, string[]>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DeploymentAction,
|
|
3
|
+
DeploymentTargetName,
|
|
4
|
+
DoctorIssue,
|
|
5
|
+
WorkflowSummary,
|
|
6
|
+
} from "../domain/types.js";
|
|
7
|
+
import { TARGET_LABELS } from "./constants.js";
|
|
8
|
+
|
|
9
|
+
export function formatWorkflowList(summaries: WorkflowSummary[]): string {
|
|
10
|
+
if (summaries.length === 0) {
|
|
11
|
+
return [
|
|
12
|
+
"No workflow groups yet",
|
|
13
|
+
"Add a Git source to discover a grouped set of related skills.",
|
|
14
|
+
].join("\n");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return summaries
|
|
18
|
+
.map((summary) => {
|
|
19
|
+
const invalidCount = summary.lock?.invalidLeafs.length ?? 0;
|
|
20
|
+
const warningCount = summary.leafs.reduce(
|
|
21
|
+
(count, leaf) => count + leaf.metadataWarnings.length,
|
|
22
|
+
0,
|
|
23
|
+
);
|
|
24
|
+
const suffixParts = [];
|
|
25
|
+
if (warningCount > 0) {
|
|
26
|
+
suffixParts.push(`${warningCount} warnings`);
|
|
27
|
+
}
|
|
28
|
+
if (invalidCount > 0) {
|
|
29
|
+
suffixParts.push(`${invalidCount} skipped`);
|
|
30
|
+
}
|
|
31
|
+
const suffix = suffixParts.length > 0 ? `, ${suffixParts.join(", ")}` : "";
|
|
32
|
+
return `${summary.source.id} ${summary.health} ${summary.leafs.length} skills ${summary.activeTargetCount} targets${suffix}`;
|
|
33
|
+
})
|
|
34
|
+
.join("\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function countActions(actions: DeploymentAction[]): Record<string, number> {
|
|
38
|
+
return actions.reduce<Record<string, number>>((acc, action) => {
|
|
39
|
+
acc[action.kind] = (acc[action.kind] ?? 0) + 1;
|
|
40
|
+
return acc;
|
|
41
|
+
}, {});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function formatActionSummary(actions: DeploymentAction[]): string {
|
|
45
|
+
const counts = countActions(actions);
|
|
46
|
+
return ["create", "update", "remove", "noop", "blocked"]
|
|
47
|
+
.map((kind) => `${kind}:${counts[kind] ?? 0}`)
|
|
48
|
+
.join(" ");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatTargetName(target: DeploymentTargetName): string {
|
|
52
|
+
return TARGET_LABELS[target];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function formatDoctorIssue(issue: DoctorIssue): string {
|
|
56
|
+
const target = issue.target ? ` ${formatTargetName(issue.target)}` : "";
|
|
57
|
+
const leaf = issue.leafId ? ` ${issue.leafId}` : "";
|
|
58
|
+
return `[${issue.severity.toUpperCase()}] ${issue.sourceId}${target}${leaf} ${issue.message}`;
|
|
59
|
+
}
|
package/src/utils/fs.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export async function pathExists(targetPath: string): Promise<boolean> {
|
|
6
|
+
try {
|
|
7
|
+
await fs.lstat(targetPath);
|
|
8
|
+
return true;
|
|
9
|
+
} catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function ensureDir(targetPath: string): Promise<void> {
|
|
15
|
+
await fs.mkdir(targetPath, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readJsonFile<T>(filePath: string, fallback: T): Promise<T> {
|
|
19
|
+
try {
|
|
20
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
21
|
+
return JSON.parse(raw) as T;
|
|
22
|
+
} catch {
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
|
|
28
|
+
await ensureDir(path.dirname(filePath));
|
|
29
|
+
const tempPath = `${filePath}.tmp`;
|
|
30
|
+
await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
31
|
+
await fs.rename(tempPath, filePath);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function removePath(targetPath: string): Promise<void> {
|
|
35
|
+
await fs.rm(targetPath, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function copyDirectory(sourcePath: string, targetPath: string): Promise<void> {
|
|
39
|
+
await removePath(targetPath);
|
|
40
|
+
await fs.cp(sourcePath, targetPath, { recursive: true, dereference: false });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function createSymlink(
|
|
44
|
+
sourcePath: string,
|
|
45
|
+
targetPath: string,
|
|
46
|
+
): Promise<void> {
|
|
47
|
+
await removePath(targetPath);
|
|
48
|
+
await ensureDir(path.dirname(targetPath));
|
|
49
|
+
await fs.symlink(sourcePath, targetPath, "junction");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function isBrokenSymlink(targetPath: string): Promise<boolean> {
|
|
53
|
+
try {
|
|
54
|
+
const stats = await fs.lstat(targetPath);
|
|
55
|
+
if (!stats.isSymbolicLink()) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
const resolved = await fs.readlink(targetPath);
|
|
59
|
+
const absolute = path.resolve(path.dirname(targetPath), resolved);
|
|
60
|
+
return !(await pathExists(absolute));
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function hashDirectory(rootPath: string): Promise<string> {
|
|
67
|
+
const hash = crypto.createHash("sha256");
|
|
68
|
+
|
|
69
|
+
async function walk(currentPath: string): Promise<void> {
|
|
70
|
+
const entries = await fs.readdir(currentPath, { withFileTypes: true });
|
|
71
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
72
|
+
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
if (entry.name === ".git" || entry.name === "node_modules") {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const entryPath = path.join(currentPath, entry.name);
|
|
79
|
+
const relativePath = path.relative(rootPath, entryPath);
|
|
80
|
+
|
|
81
|
+
hash.update(relativePath);
|
|
82
|
+
if (entry.isDirectory()) {
|
|
83
|
+
hash.update("dir");
|
|
84
|
+
await walk(entryPath);
|
|
85
|
+
} else if (entry.isFile()) {
|
|
86
|
+
hash.update("file");
|
|
87
|
+
hash.update(await fs.readFile(entryPath));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
await walk(rootPath);
|
|
93
|
+
return hash.digest("hex");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function slugify(value: string): string {
|
|
97
|
+
return value
|
|
98
|
+
.toLowerCase()
|
|
99
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
100
|
+
.replace(/^-+|-+$/g, "")
|
|
101
|
+
.slice(0, 80);
|
|
102
|
+
}
|
package/src/utils/git.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
export async function git(
|
|
7
|
+
args: string[],
|
|
8
|
+
options: { cwd?: string } = {},
|
|
9
|
+
): Promise<string> {
|
|
10
|
+
const { stdout } = await execFileAsync("git", args, {
|
|
11
|
+
cwd: options.cwd,
|
|
12
|
+
encoding: "utf8",
|
|
13
|
+
env: process.env,
|
|
14
|
+
});
|
|
15
|
+
return stdout.trim();
|
|
16
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Failure, Result, Warning } from "../domain/types.js";
|
|
2
|
+
|
|
3
|
+
export function ok<T>(data: T, warnings: Warning[] = []): Result<T> {
|
|
4
|
+
return { ok: true, data, warnings, errors: [] };
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function fail<T>(
|
|
8
|
+
errors: Failure | Failure[],
|
|
9
|
+
warnings: Warning[] = [],
|
|
10
|
+
data?: T,
|
|
11
|
+
): Result<T> {
|
|
12
|
+
const base = {
|
|
13
|
+
ok: false,
|
|
14
|
+
warnings,
|
|
15
|
+
errors: Array.isArray(errors) ? errors : [errors],
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
return data === undefined ? base : { ...base, data };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function mergeWarnings(...warningSets: Warning[][]): Warning[] {
|
|
22
|
+
return warningSets.flat();
|
|
23
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { slugify } from "./fs.js";
|
|
3
|
+
|
|
4
|
+
export function deriveDisplayName(locator: string): string {
|
|
5
|
+
const trimmed = locator.replace(/\/+$/, "");
|
|
6
|
+
if (/^[^/\s]+\/[^/\s]+$/.test(trimmed)) {
|
|
7
|
+
return trimmed.split("/")[1] ?? trimmed;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (trimmed.endsWith(".git")) {
|
|
11
|
+
return path.basename(trimmed, ".git");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return path.basename(trimmed) || slugify(locator);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function deriveSourceId(locator: string): string {
|
|
18
|
+
return slugify(deriveDisplayName(locator) || locator);
|
|
19
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"outDir": "dist",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"noUncheckedIndexedAccess": true,
|
|
11
|
+
"exactOptionalPropertyTypes": true,
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"sourceMap": true,
|
|
14
|
+
"esModuleInterop": true,
|
|
15
|
+
"skipLibCheck": true,
|
|
16
|
+
"resolveJsonModule": true,
|
|
17
|
+
"jsx": "react-jsx",
|
|
18
|
+
"types": ["node", "vitest/globals"]
|
|
19
|
+
},
|
|
20
|
+
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
|
21
|
+
"exclude": ["dist", "node_modules"]
|
|
22
|
+
}
|