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,381 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { createChannelAdapters } from "../adapters/channel-adapters.js";
|
|
3
|
+
import type {
|
|
4
|
+
DeploymentAction,
|
|
5
|
+
DeploymentPlan,
|
|
6
|
+
LeafRecord,
|
|
7
|
+
DeploymentTargetName,
|
|
8
|
+
DoctorReport,
|
|
9
|
+
LockFile,
|
|
10
|
+
Manifest,
|
|
11
|
+
Result,
|
|
12
|
+
SourceBinding,
|
|
13
|
+
TargetBinding,
|
|
14
|
+
Warning,
|
|
15
|
+
WorkflowSummary,
|
|
16
|
+
} from "../domain/types.js";
|
|
17
|
+
import { StateStore } from "../state/store.js";
|
|
18
|
+
import { hashDirectory, pathExists, removePath } from "../utils/fs.js";
|
|
19
|
+
import { fail, ok } from "../utils/result.js";
|
|
20
|
+
import { DeploymentApplier } from "./deployment-applier.js";
|
|
21
|
+
import { DeploymentPlanner } from "./deployment-planner.js";
|
|
22
|
+
import { DoctorService } from "./doctor-service.js";
|
|
23
|
+
import { InventoryService } from "./inventory-service.js";
|
|
24
|
+
import { SourceService } from "./source-service.js";
|
|
25
|
+
import { WorkflowService } from "./workflow-service.js";
|
|
26
|
+
|
|
27
|
+
export type DraftBinding = {
|
|
28
|
+
enabledTargets: DeploymentTargetName[];
|
|
29
|
+
selectedLeafIds: string[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export class SkillFlowApp {
|
|
33
|
+
readonly store = new StateStore();
|
|
34
|
+
readonly inventoryService = new InventoryService();
|
|
35
|
+
readonly sourceService = new SourceService(this.store, this.inventoryService);
|
|
36
|
+
readonly planner = new DeploymentPlanner(createChannelAdapters());
|
|
37
|
+
readonly applier = new DeploymentApplier();
|
|
38
|
+
readonly doctorService = new DoctorService();
|
|
39
|
+
readonly workflowService = new WorkflowService();
|
|
40
|
+
|
|
41
|
+
async addSource(locator: string) {
|
|
42
|
+
return this.sourceService.addSource(locator);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async listWorkflows(): Promise<Result<{ summaries: WorkflowSummary[] }>> {
|
|
46
|
+
const reconciled = await this.sourceService.reconcileInventory(undefined, {
|
|
47
|
+
force: true,
|
|
48
|
+
});
|
|
49
|
+
if (!reconciled.ok) {
|
|
50
|
+
return fail(reconciled.errors, reconciled.warnings);
|
|
51
|
+
}
|
|
52
|
+
await this.store.init();
|
|
53
|
+
const manifest = await this.store.readManifest();
|
|
54
|
+
const lockFile = await this.store.readLock();
|
|
55
|
+
return ok({
|
|
56
|
+
summaries: this.workflowService.getSummaries(manifest, lockFile),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async getConfigData(): Promise<
|
|
61
|
+
Result<{ manifest: Manifest; lockFile: LockFile; summaries: WorkflowSummary[] }>
|
|
62
|
+
> {
|
|
63
|
+
const reconciled = await this.sourceService.reconcileInventory(undefined, {
|
|
64
|
+
force: true,
|
|
65
|
+
});
|
|
66
|
+
if (!reconciled.ok) {
|
|
67
|
+
return fail(reconciled.errors, reconciled.warnings);
|
|
68
|
+
}
|
|
69
|
+
await this.store.init();
|
|
70
|
+
const manifest = await this.store.readManifest();
|
|
71
|
+
const lockFile = await this.store.readLock();
|
|
72
|
+
return ok({
|
|
73
|
+
manifest,
|
|
74
|
+
lockFile,
|
|
75
|
+
summaries: this.workflowService.getSummaries(manifest, lockFile),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async getAvailableTargets(): Promise<DeploymentTargetName[]> {
|
|
80
|
+
const adapters = createChannelAdapters();
|
|
81
|
+
const availableTargets: DeploymentTargetName[] = [];
|
|
82
|
+
|
|
83
|
+
for (const adapter of adapters) {
|
|
84
|
+
const detection = await adapter.detect();
|
|
85
|
+
if (detection.available) {
|
|
86
|
+
availableTargets.push(adapter.target);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return availableTargets;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async previewDraft(
|
|
94
|
+
sourceId: string,
|
|
95
|
+
draft: DraftBinding,
|
|
96
|
+
): Promise<Result<{ plan: DeploymentPlan; manifest: Manifest; lockFile: LockFile }>> {
|
|
97
|
+
// config TUI state flow:
|
|
98
|
+
// draft -> previewDraft() -> plan only
|
|
99
|
+
// draft -> applyDraft() -> plan + filesystem + manifest/lock writes
|
|
100
|
+
await this.store.init();
|
|
101
|
+
const manifest = await this.store.readManifest();
|
|
102
|
+
const lockFile = await this.store.readLock();
|
|
103
|
+
const prepared = this.prepareManifestForDraft(manifest, lockFile, sourceId, draft);
|
|
104
|
+
const plan = await this.planForAffectedSources(
|
|
105
|
+
prepared.manifest,
|
|
106
|
+
lockFile,
|
|
107
|
+
sourceId,
|
|
108
|
+
);
|
|
109
|
+
if (!plan.ok) {
|
|
110
|
+
return fail(plan.errors, [...prepared.warnings, ...plan.warnings]);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return ok(
|
|
114
|
+
{ plan: plan.data, manifest: prepared.manifest, lockFile },
|
|
115
|
+
[...prepared.warnings, ...plan.warnings],
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async applyDraft(
|
|
120
|
+
sourceId: string,
|
|
121
|
+
draft: DraftBinding,
|
|
122
|
+
): Promise<Result<{ actions: DeploymentAction[]; draft: DraftBinding }>> {
|
|
123
|
+
const reconciled = await this.sourceService.reconcileInventory([sourceId], {
|
|
124
|
+
force: true,
|
|
125
|
+
});
|
|
126
|
+
if (!reconciled.ok) {
|
|
127
|
+
return fail(reconciled.errors, reconciled.warnings);
|
|
128
|
+
}
|
|
129
|
+
await this.store.init();
|
|
130
|
+
const manifest = await this.store.readManifest();
|
|
131
|
+
const lockFile = await this.store.readLock();
|
|
132
|
+
const prepared = this.prepareManifestForDraft(manifest, lockFile, sourceId, draft);
|
|
133
|
+
|
|
134
|
+
const plan = await this.planForAffectedSources(prepared.manifest, lockFile, sourceId);
|
|
135
|
+
if (!plan.ok) {
|
|
136
|
+
return fail(plan.errors, [...prepared.warnings, ...plan.warnings]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const applyResult = await this.applier.applyPlan(lockFile, plan.data.actions);
|
|
140
|
+
await this.store.writeManifest(prepared.manifest);
|
|
141
|
+
await this.store.writeLock(lockFile);
|
|
142
|
+
|
|
143
|
+
if (!applyResult.ok) {
|
|
144
|
+
return fail(
|
|
145
|
+
applyResult.errors,
|
|
146
|
+
[...prepared.warnings, ...plan.warnings, ...applyResult.warnings],
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return ok(
|
|
151
|
+
{ actions: plan.data.actions, draft: prepared.draft },
|
|
152
|
+
[...prepared.warnings, ...plan.warnings, ...applyResult.warnings],
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async updateSources(sourceIds?: string[]): Promise<
|
|
157
|
+
Result<
|
|
158
|
+
{
|
|
159
|
+
updated: Array<{
|
|
160
|
+
sourceId: string;
|
|
161
|
+
changed: boolean;
|
|
162
|
+
addedLeafIds: string[];
|
|
163
|
+
removedLeafIds: string[];
|
|
164
|
+
invalidatedLeafIds: string[];
|
|
165
|
+
}>;
|
|
166
|
+
}
|
|
167
|
+
>
|
|
168
|
+
> {
|
|
169
|
+
const reconciled = await this.sourceService.reconcileInventory(sourceIds, {
|
|
170
|
+
force: true,
|
|
171
|
+
});
|
|
172
|
+
if (!reconciled.ok) {
|
|
173
|
+
return fail(reconciled.errors, reconciled.warnings);
|
|
174
|
+
}
|
|
175
|
+
const updated = await this.sourceService.updateSources(sourceIds);
|
|
176
|
+
if (!updated.ok) {
|
|
177
|
+
return updated;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const manifest = await this.store.readManifest();
|
|
181
|
+
const lockFile = await this.store.readLock();
|
|
182
|
+
const activeSourceIds = manifest.sources
|
|
183
|
+
.map((source) => source.id)
|
|
184
|
+
.filter((id) => this.hasActiveTargets(manifest, id));
|
|
185
|
+
|
|
186
|
+
for (const sourceId of activeSourceIds) {
|
|
187
|
+
const plan = await this.planner.planForSource(sourceId, manifest, lockFile);
|
|
188
|
+
if (!plan.ok) {
|
|
189
|
+
return fail(plan.errors, plan.warnings);
|
|
190
|
+
}
|
|
191
|
+
await this.applier.applyPlan(lockFile, plan.data.actions);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await this.store.writeLock(lockFile);
|
|
195
|
+
return updated;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async doctor(): Promise<Result<DoctorReport>> {
|
|
199
|
+
const reconciled = await this.sourceService.reconcileInventory();
|
|
200
|
+
if (!reconciled.ok) {
|
|
201
|
+
return fail(reconciled.errors, reconciled.warnings);
|
|
202
|
+
}
|
|
203
|
+
await this.store.init();
|
|
204
|
+
const manifest = await this.store.readManifest();
|
|
205
|
+
const lockFile = await this.store.readLock();
|
|
206
|
+
return this.doctorService.run(manifest, lockFile);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async uninstall(sourceIds: string[]): Promise<Result<{ removed: string[]; warnings: string[] }>> {
|
|
210
|
+
await this.store.init();
|
|
211
|
+
const lockFile = await this.store.readLock();
|
|
212
|
+
const warnings: string[] = [];
|
|
213
|
+
|
|
214
|
+
for (const sourceId of sourceIds) {
|
|
215
|
+
const deployments = lockFile.deployments.filter(
|
|
216
|
+
(deployment) => deployment.sourceId === sourceId,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
for (const deployment of deployments) {
|
|
220
|
+
if (!(await pathExists(deployment.targetPath))) {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (deployment.strategy === "symlink") {
|
|
225
|
+
const stats = await fs.lstat(deployment.targetPath);
|
|
226
|
+
if (stats.isSymbolicLink()) {
|
|
227
|
+
await removePath(deployment.targetPath);
|
|
228
|
+
} else {
|
|
229
|
+
warnings.push(
|
|
230
|
+
`Skipped ${deployment.targetPath} because it no longer looks like a managed symlink.`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const currentHash = await hashDirectory(deployment.targetPath);
|
|
237
|
+
if (currentHash === deployment.contentHash) {
|
|
238
|
+
await removePath(deployment.targetPath);
|
|
239
|
+
} else {
|
|
240
|
+
warnings.push(
|
|
241
|
+
`Skipped ${deployment.targetPath} because the copied skill has drifted from saved state.`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const removed = await this.sourceService.removeSource(sourceIds);
|
|
248
|
+
if (!removed.ok) {
|
|
249
|
+
return fail(removed.errors, removed.warnings);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return ok({ removed: removed.data.removed, warnings });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
bindingFromDraft(draft: DraftBinding): SourceBinding {
|
|
256
|
+
const targets: Partial<Record<DeploymentTargetName, TargetBinding>> = {};
|
|
257
|
+
for (const target of draft.enabledTargets) {
|
|
258
|
+
targets[target] = {
|
|
259
|
+
enabled: true,
|
|
260
|
+
leafIds: [...draft.selectedLeafIds],
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
return { targets };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private prepareManifestForDraft(
|
|
267
|
+
manifest: Manifest,
|
|
268
|
+
lockFile: LockFile,
|
|
269
|
+
sourceId: string,
|
|
270
|
+
draft: DraftBinding,
|
|
271
|
+
): { manifest: Manifest; draft: DraftBinding; warnings: Warning[] } {
|
|
272
|
+
manifest.bindings[sourceId] = this.bindingFromDraft(draft);
|
|
273
|
+
|
|
274
|
+
const conflictingLeafIds = this.findExactDuplicateLeafSelections(
|
|
275
|
+
manifest,
|
|
276
|
+
lockFile,
|
|
277
|
+
sourceId,
|
|
278
|
+
draft.enabledTargets,
|
|
279
|
+
);
|
|
280
|
+
const normalizedDraft: DraftBinding = {
|
|
281
|
+
enabledTargets: [...draft.enabledTargets],
|
|
282
|
+
selectedLeafIds: draft.selectedLeafIds.filter((leafId) => !conflictingLeafIds.has(leafId)),
|
|
283
|
+
};
|
|
284
|
+
manifest.bindings[sourceId] = this.bindingFromDraft(normalizedDraft);
|
|
285
|
+
|
|
286
|
+
const warnings = [...conflictingLeafIds].map((leafId) => ({
|
|
287
|
+
code: "DUPLICATE_LEAF_SELECTION_SKIPPED",
|
|
288
|
+
message: `${leafId} skipped because an identical skill is already selected in another workflow group.`,
|
|
289
|
+
}));
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
manifest,
|
|
293
|
+
draft: normalizedDraft,
|
|
294
|
+
warnings,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private findExactDuplicateLeafSelections(
|
|
299
|
+
manifest: Manifest,
|
|
300
|
+
lockFile: LockFile,
|
|
301
|
+
currentSourceId: string,
|
|
302
|
+
enabledTargets: DeploymentTargetName[],
|
|
303
|
+
): Set<string> {
|
|
304
|
+
const conflictingKeys = new Set<string>();
|
|
305
|
+
|
|
306
|
+
for (const source of manifest.sources) {
|
|
307
|
+
if (source.id === currentSourceId) {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const binding = manifest.bindings[source.id];
|
|
312
|
+
if (!binding) {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
for (const target of enabledTargets) {
|
|
317
|
+
const targetBinding = binding.targets[target];
|
|
318
|
+
if (!targetBinding?.enabled) {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
for (const leafId of targetBinding.leafIds) {
|
|
323
|
+
const leaf = lockFile.leafInventory.find((item) => item.id === leafId);
|
|
324
|
+
if (!leaf) {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
conflictingKeys.add(this.getExactDuplicateKey(leaf));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const currentLeafs = lockFile.leafInventory.filter((leaf) => leaf.sourceId === currentSourceId);
|
|
333
|
+
return new Set(
|
|
334
|
+
currentLeafs
|
|
335
|
+
.filter((leaf) => conflictingKeys.has(this.getExactDuplicateKey(leaf)))
|
|
336
|
+
.map((leaf) => leaf.id),
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private getExactDuplicateKey(leaf: LeafRecord): string {
|
|
341
|
+
return `${leaf.linkName}\n${leaf.name}\n${leaf.description}`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
private async planForAffectedSources(
|
|
345
|
+
manifest: Manifest,
|
|
346
|
+
lockFile: LockFile,
|
|
347
|
+
primarySourceId: string,
|
|
348
|
+
): Promise<Result<DeploymentPlan>> {
|
|
349
|
+
const sourceIds = manifest.sources
|
|
350
|
+
.map((source) => source.id)
|
|
351
|
+
.filter((sourceId) => sourceId === primarySourceId || this.hasActiveTargets(manifest, sourceId));
|
|
352
|
+
|
|
353
|
+
const actions: DeploymentAction[] = [];
|
|
354
|
+
const warnings: Warning[] = [];
|
|
355
|
+
|
|
356
|
+
for (const sourceId of sourceIds) {
|
|
357
|
+
const plan = await this.planner.planForSource(sourceId, manifest, lockFile);
|
|
358
|
+
if (!plan.ok) {
|
|
359
|
+
return fail(plan.errors, [...warnings, ...plan.warnings]);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
actions.push(...plan.data.actions);
|
|
363
|
+
warnings.push(...plan.warnings);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return ok({
|
|
367
|
+
actions,
|
|
368
|
+
warnings,
|
|
369
|
+
blocked: actions.filter((action) => action.kind === "blocked"),
|
|
370
|
+
}, warnings);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private hasActiveTargets(manifest: Manifest, sourceId: string): boolean {
|
|
374
|
+
const binding = manifest.bindings[sourceId];
|
|
375
|
+
if (!binding) {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return Object.values(binding.targets).some((target) => target?.enabled);
|
|
380
|
+
}
|
|
381
|
+
}
|