skill-flow 1.0.4 → 1.0.6
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 +2 -2
- package/README.zh.md +1 -1
- package/dist/adapters/channel-adapters.js +11 -3
- package/dist/adapters/channel-adapters.js.map +1 -1
- package/dist/cli.js +64 -6
- package/dist/cli.js.map +1 -1
- package/dist/domain/types.d.ts +42 -0
- package/dist/services/config-coordinator.d.ts +38 -0
- package/dist/services/config-coordinator.js +81 -0
- package/dist/services/config-coordinator.js.map +1 -0
- package/dist/services/doctor-service.d.ts +2 -0
- package/dist/services/doctor-service.js +62 -0
- package/dist/services/doctor-service.js.map +1 -1
- package/dist/services/inventory-service.d.ts +4 -1
- package/dist/services/inventory-service.js +54 -26
- package/dist/services/inventory-service.js.map +1 -1
- package/dist/services/skill-flow.d.ts +38 -16
- package/dist/services/skill-flow.js +484 -194
- package/dist/services/skill-flow.js.map +1 -1
- package/dist/services/source-service.d.ts +14 -11
- package/dist/services/source-service.js +389 -81
- package/dist/services/source-service.js.map +1 -1
- package/dist/services/workspace-bootstrap-service.js +2 -10
- package/dist/services/workspace-bootstrap-service.js.map +1 -1
- package/dist/state/store.d.ts +16 -0
- package/dist/state/store.js +93 -19
- package/dist/state/store.js.map +1 -1
- package/dist/tests/add-selection-and-find-command.test.d.ts +1 -0
- package/dist/tests/add-selection-and-find-command.test.js +89 -0
- package/dist/tests/add-selection-and-find-command.test.js.map +1 -0
- package/dist/tests/clawhub.test.d.ts +1 -0
- package/dist/tests/clawhub.test.js +63 -0
- package/dist/tests/clawhub.test.js.map +1 -0
- package/dist/tests/cli-utils.test.d.ts +1 -0
- package/dist/tests/cli-utils.test.js +15 -0
- package/dist/tests/cli-utils.test.js.map +1 -0
- package/dist/tests/config-coordinator.test.d.ts +1 -0
- package/dist/tests/config-coordinator.test.js +172 -0
- package/dist/tests/config-coordinator.test.js.map +1 -0
- package/dist/tests/config-integration.test.d.ts +1 -0
- package/dist/tests/config-integration.test.js +238 -0
- package/dist/tests/config-integration.test.js.map +1 -0
- package/dist/tests/config-ui-utils.test.d.ts +1 -0
- package/dist/tests/config-ui-utils.test.js +473 -0
- package/dist/tests/config-ui-utils.test.js.map +1 -0
- package/dist/tests/find-and-naming-utils.test.d.ts +1 -0
- package/dist/tests/find-and-naming-utils.test.js +127 -0
- package/dist/tests/find-and-naming-utils.test.js.map +1 -0
- package/dist/tests/inventory-service-precedence.test.d.ts +1 -0
- package/dist/tests/inventory-service-precedence.test.js +42 -0
- package/dist/tests/inventory-service-precedence.test.js.map +1 -0
- package/dist/tests/skill-flow.test.js +311 -889
- package/dist/tests/skill-flow.test.js.map +1 -1
- package/dist/tests/source-lifecycle.test.d.ts +1 -0
- package/dist/tests/source-lifecycle.test.js +605 -0
- package/dist/tests/source-lifecycle.test.js.map +1 -0
- package/dist/tests/source-parsing-compatibility.test.d.ts +1 -0
- package/dist/tests/source-parsing-compatibility.test.js +71 -0
- package/dist/tests/source-parsing-compatibility.test.js.map +1 -0
- package/dist/tests/target-definitions.test.d.ts +1 -0
- package/dist/tests/target-definitions.test.js +51 -0
- package/dist/tests/target-definitions.test.js.map +1 -0
- package/dist/tests/test-helpers.d.ts +18 -0
- package/dist/tests/test-helpers.js +123 -0
- package/dist/tests/test-helpers.js.map +1 -0
- package/dist/tui/config-app.d.ts +150 -24
- package/dist/tui/config-app.js +1034 -338
- package/dist/tui/config-app.js.map +1 -1
- package/dist/utils/clawhub.d.ts +3 -0
- package/dist/utils/clawhub.js +32 -3
- package/dist/utils/clawhub.js.map +1 -1
- package/dist/utils/cli.d.ts +1 -0
- package/dist/utils/cli.js +15 -0
- package/dist/utils/cli.js.map +1 -0
- package/dist/utils/constants.d.ts +4 -0
- package/dist/utils/constants.js +31 -0
- package/dist/utils/constants.js.map +1 -1
- package/dist/utils/find-command.js +10 -2
- package/dist/utils/find-command.js.map +1 -1
- package/dist/utils/fs.d.ts +5 -0
- package/dist/utils/fs.js +52 -1
- package/dist/utils/fs.js.map +1 -1
- package/package.json +1 -1
|
@@ -2,14 +2,15 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createChannelAdapters } from "../adapters/channel-adapters.js";
|
|
4
4
|
import { StateStore } from "../state/store.js";
|
|
5
|
-
import { ensureDir, hashDirectory, pathExists, readJsonFile, removePath, writeJsonFile } from "../utils/fs.js";
|
|
5
|
+
import { ensureDir, hashDirectory, pathExists, readJsonFile, removePath, writeJsonFile, } from "../utils/fs.js";
|
|
6
6
|
import { getBuiltinGitSources } from "../utils/builtin-git-sources.js";
|
|
7
7
|
import { fetchGitHubSkillPaths } from "../utils/github-catalog.js";
|
|
8
|
-
import { parseGitHubRepo } from "../utils/naming.js";
|
|
8
|
+
import { buildProjectedSkillNameCandidates, parseGitHubRepo, resolveProjectedSkillNames, } from "../utils/naming.js";
|
|
9
9
|
import { fail, ok } from "../utils/result.js";
|
|
10
10
|
import { searchClawHubSkills } from "../utils/clawhub.js";
|
|
11
11
|
import { deriveDisplayName, deriveSourceId } from "../utils/source-id.js";
|
|
12
12
|
import { DeploymentApplier } from "./deployment-applier.js";
|
|
13
|
+
import { ConfigCoordinator } from "./config-coordinator.js";
|
|
13
14
|
import { DeploymentPlanner } from "./deployment-planner.js";
|
|
14
15
|
import { DoctorService } from "./doctor-service.js";
|
|
15
16
|
import { InventoryService } from "./inventory-service.js";
|
|
@@ -17,56 +18,98 @@ import { SourceService } from "./source-service.js";
|
|
|
17
18
|
import { WorkflowService } from "./workflow-service.js";
|
|
18
19
|
import { WorkspaceBootstrapService, } from "./workspace-bootstrap-service.js";
|
|
19
20
|
export class SkillFlowApp {
|
|
20
|
-
store
|
|
21
|
-
inventoryService
|
|
22
|
-
sourceService
|
|
23
|
-
planner
|
|
24
|
-
applier
|
|
25
|
-
doctorService
|
|
26
|
-
workflowService
|
|
27
|
-
workspaceBootstrapService
|
|
21
|
+
store;
|
|
22
|
+
inventoryService;
|
|
23
|
+
sourceService;
|
|
24
|
+
planner;
|
|
25
|
+
applier;
|
|
26
|
+
doctorService;
|
|
27
|
+
workflowService;
|
|
28
|
+
workspaceBootstrapService;
|
|
29
|
+
configCoordinator;
|
|
30
|
+
mutationQueue = Promise.resolve();
|
|
31
|
+
constructor() {
|
|
32
|
+
this.store = new StateStore();
|
|
33
|
+
this.inventoryService = new InventoryService();
|
|
34
|
+
this.sourceService = new SourceService(this.store, this.inventoryService);
|
|
35
|
+
this.planner = new DeploymentPlanner(createChannelAdapters());
|
|
36
|
+
this.applier = new DeploymentApplier();
|
|
37
|
+
this.doctorService = new DoctorService();
|
|
38
|
+
this.workflowService = new WorkflowService();
|
|
39
|
+
this.workspaceBootstrapService = new WorkspaceBootstrapService(this.store);
|
|
40
|
+
this.configCoordinator = new ConfigCoordinator({
|
|
41
|
+
store: this.store,
|
|
42
|
+
doctorService: this.doctorService,
|
|
43
|
+
workflowService: this.workflowService,
|
|
44
|
+
getAvailableTargets: () => this.getAvailableTargets(),
|
|
45
|
+
pruneMissingCheckouts: () => this.pruneMissingCheckoutsImpl(),
|
|
46
|
+
getConfigData: () => this.getConfigDataImpl(),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
28
49
|
async addSource(locator, options) {
|
|
50
|
+
return this.runSerializedMutation(() => this.addSourceImpl(locator, options));
|
|
51
|
+
}
|
|
52
|
+
async addSourceImpl(locator, options) {
|
|
29
53
|
const addOptions = options ?? {};
|
|
30
54
|
const result = await this.sourceService.addSource(locator, addOptions);
|
|
31
55
|
if (!result.ok) {
|
|
32
56
|
return result;
|
|
33
57
|
}
|
|
34
|
-
await this.store.
|
|
35
|
-
const manifest = await this.store.readManifest();
|
|
36
|
-
const lockFile = await this.store.readLock();
|
|
58
|
+
const { manifest, lockFile } = await this.store.readState();
|
|
37
59
|
const source = manifest.sources.find((item) => item.id === result.data.manifest.id);
|
|
38
60
|
if (!source) {
|
|
39
61
|
return result;
|
|
40
62
|
}
|
|
63
|
+
const requestedPath = this.normalizeRequestedPath(source.requestedPath);
|
|
64
|
+
if (requestedPath) {
|
|
65
|
+
source.requestedPath = requestedPath;
|
|
66
|
+
result.data.manifest.requestedPath = requestedPath;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
delete source.requestedPath;
|
|
70
|
+
delete result.data.manifest.requestedPath;
|
|
71
|
+
}
|
|
72
|
+
const sourceLeafs = lockFile.leafInventory.filter((leaf) => leaf.sourceId === source.id);
|
|
73
|
+
const selectedLeafIds = this.selectLeafIdsForRequestedPath(sourceLeafs, requestedPath);
|
|
41
74
|
source.selectionMode =
|
|
42
75
|
addOptions.selectionMode ??
|
|
43
|
-
(
|
|
76
|
+
(selectedLeafIds.length >= sourceLeafs.length && sourceLeafs.length > 0
|
|
77
|
+
? "all"
|
|
78
|
+
: "partial");
|
|
79
|
+
result.data.manifest.selectionMode = source.selectionMode;
|
|
44
80
|
const enabledTargets = addOptions.enabledTargets ??
|
|
45
81
|
await this.getAvailableTargets();
|
|
46
82
|
manifest.bindings[source.id] = this.bindingFromDraft({
|
|
47
83
|
enabledTargets,
|
|
48
|
-
selectedLeafIds
|
|
84
|
+
selectedLeafIds,
|
|
49
85
|
});
|
|
50
|
-
await this.store.
|
|
86
|
+
await this.store.writeState(manifest, lockFile);
|
|
87
|
+
const warnings = [...result.warnings];
|
|
88
|
+
if (requestedPath && selectedLeafIds.length < sourceLeafs.length) {
|
|
89
|
+
warnings.push({
|
|
90
|
+
code: "ADD_SELECTION_PRESELECTED",
|
|
91
|
+
message: `Preselected ${selectedLeafIds.length} of ${sourceLeafs.length} ` +
|
|
92
|
+
`skill${sourceLeafs.length === 1 ? "" : "s"} under '${requestedPath}'; ` +
|
|
93
|
+
"the full skills group was imported.",
|
|
94
|
+
});
|
|
95
|
+
}
|
|
51
96
|
if (addOptions.project === false) {
|
|
52
|
-
return result;
|
|
97
|
+
return ok(result.data, warnings);
|
|
53
98
|
}
|
|
54
99
|
const plan = await this.planForAffectedSources(manifest, lockFile, source.id);
|
|
55
100
|
if (!plan.ok) {
|
|
56
|
-
return fail(plan.errors, [...
|
|
101
|
+
return fail(plan.errors, [...warnings, ...plan.warnings]);
|
|
57
102
|
}
|
|
58
103
|
const applied = await this.applier.applyPlan(lockFile, plan.data.actions);
|
|
59
|
-
await this.store.
|
|
104
|
+
await this.store.writeState(manifest, lockFile);
|
|
60
105
|
if (!applied.ok) {
|
|
61
|
-
return fail(applied.errors, [...
|
|
106
|
+
return fail(applied.errors, [...warnings, ...plan.warnings, ...applied.warnings]);
|
|
62
107
|
}
|
|
63
|
-
return ok(result.data, [...
|
|
108
|
+
return ok(result.data, [...warnings, ...plan.warnings, ...applied.warnings]);
|
|
64
109
|
}
|
|
65
110
|
async findSkills(query) {
|
|
66
|
-
await this.store.
|
|
67
|
-
const
|
|
68
|
-
const lockFile = await this.store.readLock();
|
|
69
|
-
const normalizedQuery = query.trim().toLowerCase();
|
|
111
|
+
const { manifest, lockFile } = await this.store.readState();
|
|
112
|
+
const normalizedQuery = this.normalizeSearchQuery(query);
|
|
70
113
|
const warnings = [];
|
|
71
114
|
const localKeys = new Set();
|
|
72
115
|
const candidates = [];
|
|
@@ -111,7 +154,7 @@ export class SkillFlowApp {
|
|
|
111
154
|
}
|
|
112
155
|
}
|
|
113
156
|
try {
|
|
114
|
-
const results = await searchClawHubSkills(
|
|
157
|
+
const results = await searchClawHubSkills(normalizedQuery, 8);
|
|
115
158
|
remoteSearchSucceeded = true;
|
|
116
159
|
for (const result of results) {
|
|
117
160
|
candidates.push({
|
|
@@ -147,124 +190,63 @@ export class SkillFlowApp {
|
|
|
147
190
|
return ok({ candidates }, warnings);
|
|
148
191
|
}
|
|
149
192
|
async listWorkflows() {
|
|
193
|
+
return this.runSerializedMutation(() => this.listWorkflowsImpl());
|
|
194
|
+
}
|
|
195
|
+
async listWorkflowsImpl() {
|
|
196
|
+
const pruned = await this.pruneMissingCheckoutsImpl();
|
|
197
|
+
if (!pruned.ok) {
|
|
198
|
+
return fail(pruned.errors, pruned.warnings);
|
|
199
|
+
}
|
|
150
200
|
const reconciled = await this.sourceService.reconcileInventory(undefined, {
|
|
151
201
|
force: true,
|
|
152
202
|
});
|
|
153
203
|
if (!reconciled.ok) {
|
|
154
204
|
return fail(reconciled.errors, reconciled.warnings);
|
|
155
205
|
}
|
|
156
|
-
await this.store.
|
|
157
|
-
const manifest = await this.store.readManifest();
|
|
158
|
-
const lockFile = await this.store.readLock();
|
|
206
|
+
const { manifest, lockFile } = await this.store.readState();
|
|
159
207
|
await this.persistNormalizedBindings(manifest, lockFile);
|
|
160
208
|
return ok({
|
|
161
209
|
summaries: this.workflowService.getSummaries(manifest, lockFile),
|
|
162
|
-
});
|
|
210
|
+
}, pruned.warnings);
|
|
163
211
|
}
|
|
164
212
|
async getConfigData() {
|
|
213
|
+
return this.runSerializedMutation(() => this.getConfigDataImpl());
|
|
214
|
+
}
|
|
215
|
+
async getConfigDataImpl() {
|
|
216
|
+
const pruned = await this.pruneMissingCheckoutsImpl();
|
|
217
|
+
if (!pruned.ok) {
|
|
218
|
+
return fail(pruned.errors, pruned.warnings);
|
|
219
|
+
}
|
|
165
220
|
const reconciled = await this.sourceService.reconcileInventory(undefined, {
|
|
166
221
|
force: true,
|
|
167
222
|
});
|
|
168
223
|
if (!reconciled.ok) {
|
|
169
224
|
return fail(reconciled.errors, reconciled.warnings);
|
|
170
225
|
}
|
|
171
|
-
await this.store.
|
|
172
|
-
const manifest = await this.store.readManifest();
|
|
173
|
-
const lockFile = await this.store.readLock();
|
|
226
|
+
const { manifest, lockFile } = await this.store.readState();
|
|
174
227
|
await this.persistNormalizedBindings(manifest, lockFile);
|
|
175
228
|
return ok({
|
|
176
229
|
manifest,
|
|
177
230
|
lockFile,
|
|
178
231
|
summaries: this.workflowService.getSummaries(manifest, lockFile),
|
|
179
|
-
});
|
|
232
|
+
}, pruned.warnings);
|
|
180
233
|
}
|
|
181
234
|
async bootstrapWorkspaceState(onEvent) {
|
|
182
|
-
onEvent
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
await this.store.init();
|
|
189
|
-
let manifest = await this.store.readManifest();
|
|
190
|
-
let lockFile = await this.store.readLock();
|
|
191
|
-
const detected = await this.workspaceBootstrapService.detectUnmanagedExternalSkills(manifest, lockFile, onEvent);
|
|
192
|
-
const importedSourceIds = [];
|
|
193
|
-
if (detected.length > 0) {
|
|
194
|
-
onEvent?.({
|
|
195
|
-
phase: "import-unmanaged-skills",
|
|
196
|
-
level: "info",
|
|
197
|
-
message: `Importing ${detected.length} unmanaged skill${detected.length === 1 ? "" : "s"} into skill-flow...`,
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
for (const item of detected) {
|
|
201
|
-
const imported = await this.addSource(item.path, {
|
|
202
|
-
enabledTargets: item.importedFromTargets,
|
|
203
|
-
selectionMode: "all",
|
|
204
|
-
project: false,
|
|
205
|
-
sourceIdOverride: item.sourceId,
|
|
206
|
-
displayNameOverride: item.displayName,
|
|
207
|
-
importedFromTargets: item.importedFromTargets,
|
|
208
|
-
importMode: "bootstrap-detected",
|
|
209
|
-
...(item.originLocator ? { originLocator: item.originLocator } : {}),
|
|
210
|
-
...(item.originRequestedPath ? { originRequestedPath: item.originRequestedPath } : {}),
|
|
211
|
-
...(item.originBranch ? { originBranch: item.originBranch } : {}),
|
|
212
|
-
});
|
|
213
|
-
if (!imported.ok) {
|
|
214
|
-
return fail(imported.errors, imported.warnings);
|
|
215
|
-
}
|
|
216
|
-
importedSourceIds.push(imported.data.manifest.id);
|
|
217
|
-
onEvent?.({
|
|
218
|
-
phase: "import-unmanaged-skills",
|
|
219
|
-
level: "success",
|
|
220
|
-
message: `Imported ${imported.data.manifest.displayName}.`,
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
|
-
onEvent?.({
|
|
224
|
-
phase: "refresh-sources",
|
|
225
|
-
level: "info",
|
|
226
|
-
message: "Refreshing managed inventory...",
|
|
227
|
-
});
|
|
228
|
-
const reconciled = await this.sourceService.reconcileInventory(undefined, { force: true });
|
|
229
|
-
if (!reconciled.ok) {
|
|
230
|
-
return fail(reconciled.errors, reconciled.warnings);
|
|
235
|
+
return this.runSerializedMutation(() => this.bootstrapWorkspaceStateImpl(onEvent));
|
|
236
|
+
}
|
|
237
|
+
async bootstrapWorkspaceStateImpl(onEvent) {
|
|
238
|
+
const boot = await this.configCoordinator.bootstrapWorkspaceState(onEvent);
|
|
239
|
+
if (!boot.ok) {
|
|
240
|
+
return fail(boot.errors, boot.warnings);
|
|
231
241
|
}
|
|
232
|
-
manifest = await this.store.readManifest();
|
|
233
|
-
lockFile = await this.store.readLock();
|
|
234
|
-
onEvent?.({
|
|
235
|
-
phase: "normalize-bindings",
|
|
236
|
-
level: "info",
|
|
237
|
-
message: "Normalizing config state...",
|
|
238
|
-
});
|
|
239
|
-
await this.persistNormalizedBindings(manifest, lockFile);
|
|
240
|
-
onEvent?.({
|
|
241
|
-
phase: "audit-projections",
|
|
242
|
-
level: "info",
|
|
243
|
-
message: "Auditing current projections...",
|
|
244
|
-
});
|
|
245
|
-
const audit = await this.doctorService.run(manifest, lockFile);
|
|
246
|
-
if (!audit.ok) {
|
|
247
|
-
return fail(audit.errors, audit.warnings);
|
|
248
|
-
}
|
|
249
|
-
onEvent?.({
|
|
250
|
-
phase: "build-summaries",
|
|
251
|
-
level: "info",
|
|
252
|
-
message: "Building config summaries...",
|
|
253
|
-
});
|
|
254
|
-
const summaries = this.workflowService.getSummaries(manifest, lockFile, audit.data);
|
|
255
|
-
onEvent?.({
|
|
256
|
-
phase: "done",
|
|
257
|
-
level: "success",
|
|
258
|
-
message: "Config bootstrap complete.",
|
|
259
|
-
});
|
|
260
242
|
return ok({
|
|
261
|
-
availableTargets,
|
|
262
|
-
manifest,
|
|
263
|
-
lockFile,
|
|
264
|
-
summaries,
|
|
265
|
-
initialDrafts:
|
|
266
|
-
audit:
|
|
267
|
-
importedSourceIds,
|
|
243
|
+
availableTargets: boot.data.availableTargets,
|
|
244
|
+
manifest: boot.data.manifest,
|
|
245
|
+
lockFile: boot.data.lockFile,
|
|
246
|
+
summaries: boot.data.summaries,
|
|
247
|
+
initialDrafts: boot.data.initialDrafts,
|
|
248
|
+
audit: boot.data.audit,
|
|
249
|
+
importedSourceIds: [],
|
|
268
250
|
});
|
|
269
251
|
}
|
|
270
252
|
async getAvailableTargets() {
|
|
@@ -282,9 +264,7 @@ export class SkillFlowApp {
|
|
|
282
264
|
// config TUI state flow:
|
|
283
265
|
// draft -> previewDraft() -> plan only
|
|
284
266
|
// draft -> applyDraft() -> plan + filesystem + manifest/lock writes
|
|
285
|
-
await this.store.
|
|
286
|
-
const manifest = await this.store.readManifest();
|
|
287
|
-
const lockFile = await this.store.readLock();
|
|
267
|
+
const { manifest, lockFile } = await this.store.readState();
|
|
288
268
|
this.normalizeBindings(manifest, lockFile);
|
|
289
269
|
const prepared = this.prepareManifestForDraft(manifest, lockFile, sourceId, draft);
|
|
290
270
|
const plan = await this.planForAffectedSources(prepared.manifest, lockFile, sourceId);
|
|
@@ -294,15 +274,10 @@ export class SkillFlowApp {
|
|
|
294
274
|
return ok({ plan: plan.data, manifest: prepared.manifest, lockFile }, [...prepared.warnings, ...plan.warnings]);
|
|
295
275
|
}
|
|
296
276
|
async applyDraft(sourceId, draft) {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
return fail(reconciled.errors, reconciled.warnings);
|
|
302
|
-
}
|
|
303
|
-
await this.store.init();
|
|
304
|
-
const manifest = await this.store.readManifest();
|
|
305
|
-
const lockFile = await this.store.readLock();
|
|
277
|
+
return this.runSerializedMutation(() => this.applyDraftImpl(sourceId, draft));
|
|
278
|
+
}
|
|
279
|
+
async applyDraftImpl(sourceId, draft) {
|
|
280
|
+
const { manifest, lockFile } = await this.store.readState();
|
|
306
281
|
this.normalizeBindings(manifest, lockFile);
|
|
307
282
|
const prepared = this.prepareManifestForDraft(manifest, lockFile, sourceId, draft);
|
|
308
283
|
const plan = await this.planForAffectedSources(prepared.manifest, lockFile, sourceId);
|
|
@@ -310,56 +285,143 @@ export class SkillFlowApp {
|
|
|
310
285
|
return fail(plan.errors, [...prepared.warnings, ...plan.warnings]);
|
|
311
286
|
}
|
|
312
287
|
const applyResult = await this.applier.applyPlan(lockFile, plan.data.actions);
|
|
313
|
-
await this.store.
|
|
314
|
-
await this.store.writeLock(lockFile);
|
|
288
|
+
await this.store.writeState(prepared.manifest, lockFile);
|
|
315
289
|
if (!applyResult.ok) {
|
|
316
290
|
return fail(applyResult.errors, [...prepared.warnings, ...plan.warnings, ...applyResult.warnings]);
|
|
317
291
|
}
|
|
318
292
|
return ok({ actions: plan.data.actions, draft: prepared.draft }, [...prepared.warnings, ...plan.warnings, ...applyResult.warnings]);
|
|
319
293
|
}
|
|
320
294
|
async updateSources(sourceIds) {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
295
|
+
return this.runSerializedMutation(() => this.updateSourcesImpl(sourceIds));
|
|
296
|
+
}
|
|
297
|
+
async updateSourcesImpl(sourceIds) {
|
|
298
|
+
const pruned = await this.pruneMissingCheckoutsImpl();
|
|
299
|
+
if (!pruned.ok) {
|
|
300
|
+
return fail(pruned.errors, pruned.warnings);
|
|
326
301
|
}
|
|
327
|
-
const
|
|
302
|
+
const requestedIds = sourceIds?.filter((sourceId) => !pruned.data.removedSourceIds.includes(sourceId));
|
|
303
|
+
if (sourceIds?.length && requestedIds?.length === 0) {
|
|
304
|
+
return ok({ updated: [] }, pruned.warnings);
|
|
305
|
+
}
|
|
306
|
+
const updated = await this.sourceService.updateSources(requestedIds);
|
|
328
307
|
if (!updated.ok) {
|
|
329
308
|
return updated;
|
|
330
309
|
}
|
|
331
|
-
const manifest = await this.store.
|
|
332
|
-
|
|
333
|
-
this.applySelectionModeForUpdatedSources(manifest, lockFile, updated.data.updated);
|
|
310
|
+
const { manifest, lockFile } = await this.store.readState();
|
|
311
|
+
this.applySourceUpdateResults(manifest, lockFile, updated.data.updated);
|
|
334
312
|
await this.persistNormalizedBindings(manifest, lockFile);
|
|
335
|
-
const
|
|
313
|
+
const planSourceIds = manifest.sources
|
|
336
314
|
.map((source) => source.id)
|
|
337
|
-
.filter((id) =>
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
await this.applier.applyPlan(lockFile, plan.data.actions);
|
|
315
|
+
.filter((id) => updated.data.updated.some((item) => item.sourceId === id) ||
|
|
316
|
+
this.hasActiveTargets(manifest, id) ||
|
|
317
|
+
lockFile.deployments.some((deployment) => deployment.sourceId === id));
|
|
318
|
+
const planned = await this.planAndApplySources(manifest, lockFile, planSourceIds);
|
|
319
|
+
if (!planned.ok) {
|
|
320
|
+
return fail(planned.errors, [...pruned.warnings, ...updated.warnings, ...planned.warnings]);
|
|
344
321
|
}
|
|
345
|
-
await this.store.
|
|
346
|
-
return updated;
|
|
322
|
+
await this.store.writeState(manifest, lockFile);
|
|
323
|
+
return ok(updated.data, [...pruned.warnings, ...updated.warnings, ...planned.warnings]);
|
|
347
324
|
}
|
|
348
325
|
async doctor() {
|
|
326
|
+
return this.runSerializedMutation(() => this.doctorImpl());
|
|
327
|
+
}
|
|
328
|
+
async doctorImpl() {
|
|
329
|
+
const pruned = await this.pruneMissingCheckoutsImpl();
|
|
330
|
+
if (!pruned.ok) {
|
|
331
|
+
return fail(pruned.errors, pruned.warnings);
|
|
332
|
+
}
|
|
349
333
|
const reconciled = await this.sourceService.reconcileInventory();
|
|
350
334
|
if (!reconciled.ok) {
|
|
351
335
|
return fail(reconciled.errors, reconciled.warnings);
|
|
352
336
|
}
|
|
353
|
-
await this.store.
|
|
354
|
-
const manifest = await this.store.readManifest();
|
|
355
|
-
const lockFile = await this.store.readLock();
|
|
337
|
+
const { manifest, lockFile } = await this.store.readState();
|
|
356
338
|
await this.persistNormalizedBindings(manifest, lockFile);
|
|
357
|
-
|
|
339
|
+
const doctor = await this.doctorService.run(manifest, lockFile);
|
|
340
|
+
if (!doctor.ok) {
|
|
341
|
+
return doctor;
|
|
342
|
+
}
|
|
343
|
+
return ok(doctor.data, [...pruned.warnings, ...doctor.warnings]);
|
|
344
|
+
}
|
|
345
|
+
async repairTargets(sourceIds) {
|
|
346
|
+
return this.runSerializedMutation(() => this.repairTargetsImpl(sourceIds));
|
|
347
|
+
}
|
|
348
|
+
async repairTargetsImpl(sourceIds) {
|
|
349
|
+
const { manifest, lockFile } = await this.store.readState();
|
|
350
|
+
this.normalizeBindings(manifest, lockFile);
|
|
351
|
+
const requestedIds = sourceIds?.length
|
|
352
|
+
? sourceIds
|
|
353
|
+
: manifest.sources.map((source) => source.id);
|
|
354
|
+
for (const sourceId of requestedIds) {
|
|
355
|
+
if (!manifest.sources.some((source) => source.id === sourceId)) {
|
|
356
|
+
return fail({
|
|
357
|
+
code: "SOURCE_NOT_FOUND",
|
|
358
|
+
message: `Skills group id '${sourceId}' is not registered.`,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const planSourceIds = requestedIds.filter((sourceId) => this.hasActiveTargets(manifest, sourceId) ||
|
|
363
|
+
lockFile.deployments.some((deployment) => deployment.sourceId === sourceId));
|
|
364
|
+
const planned = await this.planAndApplySources(manifest, lockFile, planSourceIds);
|
|
365
|
+
if (!planned.ok) {
|
|
366
|
+
return fail(planned.errors, planned.warnings);
|
|
367
|
+
}
|
|
368
|
+
await this.store.writeState(manifest, lockFile);
|
|
369
|
+
return ok({ actions: planned.data.actions }, planned.warnings);
|
|
370
|
+
}
|
|
371
|
+
async repairSource(sourceIds) {
|
|
372
|
+
return this.runSerializedMutation(() => this.repairSourceImpl(sourceIds));
|
|
373
|
+
}
|
|
374
|
+
async repairSourceImpl(sourceIds) {
|
|
375
|
+
const pruned = await this.pruneMissingCheckoutsImpl();
|
|
376
|
+
if (!pruned.ok) {
|
|
377
|
+
return fail(pruned.errors, pruned.warnings);
|
|
378
|
+
}
|
|
379
|
+
const requestedIds = sourceIds?.filter((sourceId) => !pruned.data.removedSourceIds.includes(sourceId));
|
|
380
|
+
if (sourceIds?.length && requestedIds?.length === 0) {
|
|
381
|
+
return ok({ updated: [] }, pruned.warnings);
|
|
382
|
+
}
|
|
383
|
+
const repaired = await this.sourceService.updateSources(requestedIds);
|
|
384
|
+
if (!repaired.ok) {
|
|
385
|
+
return repaired;
|
|
386
|
+
}
|
|
387
|
+
const { manifest, lockFile } = await this.store.readState();
|
|
388
|
+
this.applySourceUpdateResults(manifest, lockFile, repaired.data.updated);
|
|
389
|
+
await this.persistNormalizedBindings(manifest, lockFile);
|
|
390
|
+
await this.store.writeState(manifest, lockFile);
|
|
391
|
+
return ok(repaired.data, [...pruned.warnings, ...repaired.warnings]);
|
|
392
|
+
}
|
|
393
|
+
async repairState(sourceIds) {
|
|
394
|
+
return this.runSerializedMutation(() => this.repairStateImpl(sourceIds));
|
|
395
|
+
}
|
|
396
|
+
async repairStateImpl(sourceIds) {
|
|
397
|
+
const pruned = await this.pruneMissingCheckoutsImpl();
|
|
398
|
+
if (!pruned.ok) {
|
|
399
|
+
return fail(pruned.errors, pruned.warnings);
|
|
400
|
+
}
|
|
401
|
+
const requestedIds = sourceIds?.filter((sourceId) => !pruned.data.removedSourceIds.includes(sourceId));
|
|
402
|
+
if (sourceIds?.length && requestedIds?.length === 0) {
|
|
403
|
+
return ok({ repairedSourceIds: [], removedDeploymentCount: 0 }, pruned.warnings);
|
|
404
|
+
}
|
|
405
|
+
const reconciled = await this.sourceService.reconcileInventory(requestedIds, {
|
|
406
|
+
force: true,
|
|
407
|
+
});
|
|
408
|
+
if (!reconciled.ok) {
|
|
409
|
+
return fail(reconciled.errors, [...pruned.warnings, ...reconciled.warnings]);
|
|
410
|
+
}
|
|
411
|
+
const { manifest, lockFile } = await this.store.readState();
|
|
412
|
+
await this.persistNormalizedBindings(manifest, lockFile);
|
|
413
|
+
const removedDeploymentCount = await this.rebuildDeploymentState(manifest, lockFile, requestedIds);
|
|
414
|
+
await this.store.writeState(manifest, lockFile);
|
|
415
|
+
return ok({
|
|
416
|
+
repairedSourceIds: reconciled.data.updatedSourceIds,
|
|
417
|
+
removedDeploymentCount,
|
|
418
|
+
}, [...pruned.warnings, ...reconciled.warnings]);
|
|
358
419
|
}
|
|
359
420
|
async uninstall(sourceIds) {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
421
|
+
return this.runSerializedMutation(() => this.uninstallImpl(sourceIds));
|
|
422
|
+
}
|
|
423
|
+
async uninstallImpl(sourceIds) {
|
|
424
|
+
const { manifest, lockFile } = await this.store.readState();
|
|
363
425
|
const warnings = [];
|
|
364
426
|
const removedRefs = sourceIds
|
|
365
427
|
.map((sourceId) => manifest.sources.find((source) => source.id === sourceId))
|
|
@@ -370,26 +432,33 @@ export class SkillFlowApp {
|
|
|
370
432
|
if (!(await pathExists(deployment.targetPath))) {
|
|
371
433
|
continue;
|
|
372
434
|
}
|
|
373
|
-
|
|
374
|
-
const stats = await fs.lstat(deployment.targetPath);
|
|
375
|
-
if (stats.isSymbolicLink()) {
|
|
376
|
-
await removePath(deployment.targetPath);
|
|
377
|
-
}
|
|
378
|
-
else {
|
|
379
|
-
warnings.push(`Skipped ${deployment.targetPath} because it no longer looks like a managed symlink.`);
|
|
380
|
-
}
|
|
381
|
-
continue;
|
|
382
|
-
}
|
|
383
|
-
const currentHash = await hashDirectory(deployment.targetPath);
|
|
384
|
-
if (currentHash === deployment.contentHash) {
|
|
435
|
+
try {
|
|
385
436
|
await removePath(deployment.targetPath);
|
|
386
437
|
}
|
|
387
|
-
|
|
388
|
-
warnings.push(`
|
|
438
|
+
catch (error) {
|
|
439
|
+
warnings.push(`Unable to remove ${deployment.targetPath}: ${String(error)}`);
|
|
389
440
|
}
|
|
390
441
|
}
|
|
391
442
|
}
|
|
392
|
-
|
|
443
|
+
if (warnings.length > 0) {
|
|
444
|
+
return fail({
|
|
445
|
+
code: "GROUP_DELETE_INCOMPLETE",
|
|
446
|
+
message: `Unable to fully delete ${warnings.length} managed path${warnings.length === 1 ? "" : "s"}.`,
|
|
447
|
+
}, warnings.map((message) => ({
|
|
448
|
+
code: "GROUP_DELETE_PATH_FAILED",
|
|
449
|
+
message,
|
|
450
|
+
})));
|
|
451
|
+
}
|
|
452
|
+
let removed;
|
|
453
|
+
try {
|
|
454
|
+
removed = await this.sourceService.removeSource(sourceIds);
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
return fail({
|
|
458
|
+
code: "GROUP_DELETE_INCOMPLETE",
|
|
459
|
+
message: `Unable to fully delete selected skills groups: ${String(error)}`,
|
|
460
|
+
});
|
|
461
|
+
}
|
|
393
462
|
if (!removed.ok) {
|
|
394
463
|
return fail(removed.errors, removed.warnings);
|
|
395
464
|
}
|
|
@@ -405,11 +474,58 @@ export class SkillFlowApp {
|
|
|
405
474
|
}
|
|
406
475
|
return { targets };
|
|
407
476
|
}
|
|
477
|
+
async pruneMissingCheckoutsImpl() {
|
|
478
|
+
const { manifest, lockFile } = await this.store.readState();
|
|
479
|
+
const removedSourceIds = [];
|
|
480
|
+
const warnings = [];
|
|
481
|
+
for (const source of lockFile.sources) {
|
|
482
|
+
if (await pathExists(source.checkoutPath)) {
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
removedSourceIds.push(source.id);
|
|
486
|
+
warnings.push({
|
|
487
|
+
code: "SOURCE_CHECKOUT_MISSING",
|
|
488
|
+
message: `Removed ${source.displayName} because checkout is missing at ${source.checkoutPath}.`,
|
|
489
|
+
});
|
|
490
|
+
const deployments = lockFile.deployments.filter((deployment) => deployment.sourceId === source.id);
|
|
491
|
+
for (const deployment of deployments) {
|
|
492
|
+
if (!(await pathExists(deployment.targetPath))) {
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
try {
|
|
496
|
+
await removePath(deployment.targetPath);
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
return fail({
|
|
500
|
+
code: "SOURCE_CHECKOUT_PRUNE_FAILED",
|
|
501
|
+
message: `Unable to clean deployment ${deployment.targetPath}: ${String(error)}`,
|
|
502
|
+
}, warnings);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (removedSourceIds.length === 0) {
|
|
507
|
+
return ok({ removedSourceIds: [] });
|
|
508
|
+
}
|
|
509
|
+
manifest.sources = manifest.sources.filter((source) => !removedSourceIds.includes(source.id));
|
|
510
|
+
for (const sourceId of removedSourceIds) {
|
|
511
|
+
delete manifest.bindings[sourceId];
|
|
512
|
+
}
|
|
513
|
+
lockFile.sources = lockFile.sources.filter((source) => !removedSourceIds.includes(source.id));
|
|
514
|
+
lockFile.leafInventory = lockFile.leafInventory.filter((leaf) => !removedSourceIds.includes(leaf.sourceId));
|
|
515
|
+
lockFile.deployments = lockFile.deployments.filter((deployment) => !removedSourceIds.includes(deployment.sourceId));
|
|
516
|
+
await this.store.writeState(manifest, lockFile);
|
|
517
|
+
return ok({ removedSourceIds }, warnings);
|
|
518
|
+
}
|
|
408
519
|
async persistNormalizedBindings(manifest, lockFile) {
|
|
409
520
|
if (!this.normalizeBindings(manifest, lockFile)) {
|
|
410
521
|
return;
|
|
411
522
|
}
|
|
412
|
-
await this.store.
|
|
523
|
+
await this.store.writeState(manifest, lockFile);
|
|
524
|
+
}
|
|
525
|
+
async runSerializedMutation(task) {
|
|
526
|
+
const run = this.mutationQueue.then(() => this.store.withMutationLock(task), () => this.store.withMutationLock(task));
|
|
527
|
+
this.mutationQueue = run.then(() => undefined, () => undefined);
|
|
528
|
+
return run;
|
|
413
529
|
}
|
|
414
530
|
normalizeBindings(manifest, lockFile) {
|
|
415
531
|
let changed = false;
|
|
@@ -439,15 +555,22 @@ export class SkillFlowApp {
|
|
|
439
555
|
};
|
|
440
556
|
}
|
|
441
557
|
selectLeafIdsForRequestedPath(leafs, requestedPath) {
|
|
442
|
-
|
|
558
|
+
const normalizedPath = this.normalizeRequestedPath(requestedPath);
|
|
559
|
+
if (!normalizedPath) {
|
|
443
560
|
return leafs.map((leaf) => leaf.id);
|
|
444
561
|
}
|
|
445
|
-
const normalizedPath = requestedPath.replace(/^\.\/+/, "").replace(/\/+$/, "");
|
|
446
562
|
return leafs
|
|
447
563
|
.filter((leaf) => leaf.relativePath === normalizedPath ||
|
|
448
564
|
leaf.relativePath.startsWith(`${normalizedPath}/`))
|
|
449
565
|
.map((leaf) => leaf.id);
|
|
450
566
|
}
|
|
567
|
+
normalizeRequestedPath(requestedPath) {
|
|
568
|
+
if (!requestedPath) {
|
|
569
|
+
return undefined;
|
|
570
|
+
}
|
|
571
|
+
const normalized = requestedPath.trim().replace(/^\.\/+/, "").replace(/\/+$/, "");
|
|
572
|
+
return normalized.length > 0 && normalized !== "." ? normalized : undefined;
|
|
573
|
+
}
|
|
451
574
|
prepareManifestForDraft(manifest, lockFile, sourceId, draft) {
|
|
452
575
|
manifest.bindings[sourceId] = this.bindingFromDraft(draft);
|
|
453
576
|
const source = manifest.sources.find((item) => item.id === sourceId);
|
|
@@ -510,9 +633,13 @@ export class SkillFlowApp {
|
|
|
510
633
|
const sourceIds = manifest.sources
|
|
511
634
|
.map((source) => source.id)
|
|
512
635
|
.filter((sourceId) => sourceId === primarySourceId || this.hasActiveTargets(manifest, sourceId));
|
|
636
|
+
return this.planForSources(manifest, lockFile, sourceIds);
|
|
637
|
+
}
|
|
638
|
+
async planForSources(manifest, lockFile, sourceIds) {
|
|
639
|
+
const uniqueSourceIds = [...new Set(sourceIds)];
|
|
513
640
|
const actions = [];
|
|
514
641
|
const warnings = [];
|
|
515
|
-
for (const sourceId of
|
|
642
|
+
for (const sourceId of uniqueSourceIds) {
|
|
516
643
|
const plan = await this.planner.planForSource(sourceId, manifest, lockFile);
|
|
517
644
|
if (!plan.ok) {
|
|
518
645
|
return fail(plan.errors, [...warnings, ...plan.warnings]);
|
|
@@ -526,6 +653,148 @@ export class SkillFlowApp {
|
|
|
526
653
|
blocked: actions.filter((action) => action.kind === "blocked"),
|
|
527
654
|
}, warnings);
|
|
528
655
|
}
|
|
656
|
+
async planAndApplySources(manifest, lockFile, sourceIds) {
|
|
657
|
+
const planned = await this.planForSources(manifest, lockFile, sourceIds);
|
|
658
|
+
if (!planned.ok) {
|
|
659
|
+
return fail(planned.errors, planned.warnings);
|
|
660
|
+
}
|
|
661
|
+
const applyResult = await this.applier.applyPlan(lockFile, planned.data.actions);
|
|
662
|
+
if (!applyResult.ok) {
|
|
663
|
+
return fail(applyResult.errors, [...planned.warnings, ...applyResult.warnings]);
|
|
664
|
+
}
|
|
665
|
+
return ok({ actions: planned.data.actions }, [...planned.warnings, ...applyResult.warnings]);
|
|
666
|
+
}
|
|
667
|
+
async rebuildDeploymentState(manifest, lockFile, sourceIds) {
|
|
668
|
+
const requested = sourceIds?.length ? new Set(sourceIds) : undefined;
|
|
669
|
+
const previousDeployments = lockFile.deployments;
|
|
670
|
+
const previousCount = previousDeployments.length;
|
|
671
|
+
const previousByKey = new Map(previousDeployments.map((deployment) => [
|
|
672
|
+
this.getDeploymentKey(deployment.sourceId, deployment.leafId, deployment.target),
|
|
673
|
+
deployment,
|
|
674
|
+
]));
|
|
675
|
+
const nextDeployments = previousDeployments.filter((deployment) => requested ? !requested.has(deployment.sourceId) : false);
|
|
676
|
+
const adapters = createChannelAdapters();
|
|
677
|
+
const detectionCache = new Map();
|
|
678
|
+
const projectedNameCache = new Map();
|
|
679
|
+
for (const source of manifest.sources) {
|
|
680
|
+
if (requested && !requested.has(source.id)) {
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
const binding = manifest.bindings[source.id] ?? { targets: {} };
|
|
684
|
+
for (const adapter of adapters) {
|
|
685
|
+
const targetBinding = binding.targets[adapter.target];
|
|
686
|
+
if (!targetBinding?.enabled) {
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
let detection = detectionCache.get(adapter.target);
|
|
690
|
+
if (!detection) {
|
|
691
|
+
detection = await adapter.detect();
|
|
692
|
+
detectionCache.set(adapter.target, detection);
|
|
693
|
+
}
|
|
694
|
+
for (const leafId of targetBinding.leafIds) {
|
|
695
|
+
const leaf = lockFile.leafInventory.find((candidate) => candidate.sourceId === source.id && candidate.id === leafId);
|
|
696
|
+
if (!leaf) {
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
const existing = previousByKey.get(this.getDeploymentKey(source.id, leaf.id, adapter.target));
|
|
700
|
+
if (!detection.available) {
|
|
701
|
+
if (existing) {
|
|
702
|
+
nextDeployments.push({
|
|
703
|
+
...existing,
|
|
704
|
+
contentHash: leaf.contentHash,
|
|
705
|
+
status: "active",
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
let projectedLinkNames = projectedNameCache.get(adapter.target);
|
|
711
|
+
if (!projectedLinkNames) {
|
|
712
|
+
projectedLinkNames = this.buildProjectedLinkNameMap(manifest, lockFile, adapter.target);
|
|
713
|
+
projectedNameCache.set(adapter.target, projectedLinkNames);
|
|
714
|
+
}
|
|
715
|
+
const rebuilt = await this.findManagedDeploymentOnDisk(source, leaf, adapter.target, adapter.strategy, detection.rootPath, projectedLinkNames, existing);
|
|
716
|
+
if (!rebuilt) {
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
nextDeployments.push(rebuilt);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
lockFile.deployments = nextDeployments;
|
|
724
|
+
return Math.max(0, previousCount - nextDeployments.length);
|
|
725
|
+
}
|
|
726
|
+
buildProjectedLinkNameMap(manifest, lockFile, target) {
|
|
727
|
+
return resolveProjectedSkillNames(manifest.sources.flatMap((source) => {
|
|
728
|
+
const targetBinding = manifest.bindings[source.id]?.targets[target];
|
|
729
|
+
if (!targetBinding?.enabled) {
|
|
730
|
+
return [];
|
|
731
|
+
}
|
|
732
|
+
return targetBinding.leafIds
|
|
733
|
+
.map((leafId) => lockFile.leafInventory.find((leaf) => leaf.id === leafId))
|
|
734
|
+
.filter((leaf) => Boolean(leaf))
|
|
735
|
+
.map((leaf) => ({
|
|
736
|
+
leafId: leaf.id,
|
|
737
|
+
groupId: source.id,
|
|
738
|
+
groupName: source.displayName,
|
|
739
|
+
groupAuthor: parseGitHubRepo(source.locator)?.owner,
|
|
740
|
+
skillName: leaf.linkName,
|
|
741
|
+
}));
|
|
742
|
+
}));
|
|
743
|
+
}
|
|
744
|
+
async findManagedDeploymentOnDisk(source, leaf, target, strategy, rootPath, projectedLinkNames, existing) {
|
|
745
|
+
const projectedLinkName = projectedLinkNames.get(leaf.id) ?? leaf.linkName;
|
|
746
|
+
const candidatePaths = buildProjectedSkillNameCandidates({
|
|
747
|
+
preferredName: projectedLinkName,
|
|
748
|
+
groupId: source.id,
|
|
749
|
+
groupName: source.displayName,
|
|
750
|
+
groupAuthor: parseGitHubRepo(source.locator)?.owner,
|
|
751
|
+
skillName: leaf.linkName,
|
|
752
|
+
}).map((name) => path.join(rootPath, name));
|
|
753
|
+
if (existing?.targetPath && !candidatePaths.includes(existing.targetPath)) {
|
|
754
|
+
candidatePaths.unshift(existing.targetPath);
|
|
755
|
+
}
|
|
756
|
+
for (const targetPath of candidatePaths) {
|
|
757
|
+
const matches = await this.matchesManagedProjection(strategy, targetPath, leaf);
|
|
758
|
+
if (!matches) {
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
return {
|
|
762
|
+
sourceId: source.id,
|
|
763
|
+
leafId: leaf.id,
|
|
764
|
+
target,
|
|
765
|
+
targetPath,
|
|
766
|
+
strategy,
|
|
767
|
+
status: "active",
|
|
768
|
+
contentHash: leaf.contentHash,
|
|
769
|
+
appliedAt: existing?.appliedAt ?? new Date().toISOString(),
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
return undefined;
|
|
773
|
+
}
|
|
774
|
+
async matchesManagedProjection(strategy, targetPath, leaf) {
|
|
775
|
+
try {
|
|
776
|
+
const stats = await fs.lstat(targetPath);
|
|
777
|
+
if (strategy === "symlink") {
|
|
778
|
+
if (!stats.isSymbolicLink()) {
|
|
779
|
+
return false;
|
|
780
|
+
}
|
|
781
|
+
const linked = await fs.readlink(targetPath);
|
|
782
|
+
const resolved = path.resolve(path.dirname(targetPath), linked);
|
|
783
|
+
return resolved === leaf.absolutePath;
|
|
784
|
+
}
|
|
785
|
+
if (!stats.isDirectory()) {
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
788
|
+
const onDiskHash = await hashDirectory(targetPath);
|
|
789
|
+
return onDiskHash === leaf.contentHash;
|
|
790
|
+
}
|
|
791
|
+
catch {
|
|
792
|
+
return false;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
getDeploymentKey(sourceId, leafId, target) {
|
|
796
|
+
return `${sourceId}\n${leafId}\n${target}`;
|
|
797
|
+
}
|
|
529
798
|
hasActiveTargets(manifest, sourceId) {
|
|
530
799
|
const binding = manifest.bindings[sourceId];
|
|
531
800
|
if (!binding) {
|
|
@@ -547,7 +816,7 @@ export class SkillFlowApp {
|
|
|
547
816
|
const source = manifest.sources.find((item) => item.id === leaf.sourceId);
|
|
548
817
|
return {
|
|
549
818
|
id: `local:${leaf.id}`,
|
|
550
|
-
title: leaf
|
|
819
|
+
title: this.getCandidateTitle(leaf),
|
|
551
820
|
description: leaf.description,
|
|
552
821
|
source: "local",
|
|
553
822
|
sourceLabel: source
|
|
@@ -666,7 +935,7 @@ export class SkillFlowApp {
|
|
|
666
935
|
if (tokens.length === 0) {
|
|
667
936
|
return true;
|
|
668
937
|
}
|
|
669
|
-
const haystack = fields.join("\n")
|
|
938
|
+
const haystack = this.normalizeSearchQuery(fields.join("\n"));
|
|
670
939
|
return tokens.every((token) => haystack.includes(token));
|
|
671
940
|
}
|
|
672
941
|
compareCandidates(left, right, query) {
|
|
@@ -696,8 +965,8 @@ export class SkillFlowApp {
|
|
|
696
965
|
}
|
|
697
966
|
getQueryScore(candidate, query) {
|
|
698
967
|
const tokens = query.split(/\s+/).filter(Boolean);
|
|
699
|
-
const titleField = candidate.title
|
|
700
|
-
const pathTail = (candidate.relativePath ?? "").split("/").pop()
|
|
968
|
+
const titleField = this.normalizeSearchQuery(candidate.title);
|
|
969
|
+
const pathTail = this.normalizeSearchQuery((candidate.relativePath ?? "").split("/").pop() ?? "");
|
|
701
970
|
const fields = [
|
|
702
971
|
titleField,
|
|
703
972
|
pathTail,
|
|
@@ -723,6 +992,16 @@ export class SkillFlowApp {
|
|
|
723
992
|
}
|
|
724
993
|
return candidate.locator;
|
|
725
994
|
}
|
|
995
|
+
getCandidateTitle(leaf) {
|
|
996
|
+
const title = leaf.title.trim();
|
|
997
|
+
if (title.length === 0 || /^\{[^}]+\}$/.test(title)) {
|
|
998
|
+
return leaf.linkName || leaf.name;
|
|
999
|
+
}
|
|
1000
|
+
return title;
|
|
1001
|
+
}
|
|
1002
|
+
normalizeSearchQuery(value) {
|
|
1003
|
+
return value.trim().toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ");
|
|
1004
|
+
}
|
|
726
1005
|
formatSourceLabel(locator, displayName) {
|
|
727
1006
|
if (locator.startsWith("clawhub:")) {
|
|
728
1007
|
return `${displayName}@clawhub`;
|
|
@@ -736,30 +1015,41 @@ export class SkillFlowApp {
|
|
|
736
1015
|
}
|
|
737
1016
|
return `${displayName}@${repo.owner}`;
|
|
738
1017
|
}
|
|
739
|
-
|
|
740
|
-
return Object.fromEntries(summaries.map((summary) => {
|
|
741
|
-
const enabledTargets = Object.entries(summary.bindings.targets)
|
|
742
|
-
.filter(([, value]) => value?.enabled)
|
|
743
|
-
.map(([target]) => target);
|
|
744
|
-
const selectedLeafIds = [...new Set(enabledTargets.flatMap((target) => summary.bindings.targets[target]?.leafIds ?? []))];
|
|
745
|
-
return [summary.source.id, { enabledTargets, selectedLeafIds }];
|
|
746
|
-
}));
|
|
747
|
-
}
|
|
748
|
-
applySelectionModeForUpdatedSources(manifest, lockFile, updates) {
|
|
1018
|
+
applySourceUpdateResults(manifest, lockFile, updates) {
|
|
749
1019
|
for (const update of updates) {
|
|
750
|
-
if (!update.changed
|
|
1020
|
+
if (!update.changed) {
|
|
751
1021
|
continue;
|
|
752
1022
|
}
|
|
753
1023
|
const source = manifest.sources.find((item) => item.id === update.sourceId);
|
|
754
1024
|
const binding = manifest.bindings[update.sourceId];
|
|
755
|
-
if (!source || !binding
|
|
1025
|
+
if (!source || !binding) {
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
for (const diff of update.diffs) {
|
|
1029
|
+
if (diff.kind !== "moved" || !diff.previousLeafId) {
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
for (const targetBinding of Object.values(binding.targets)) {
|
|
1033
|
+
if (!targetBinding?.enabled || !targetBinding.leafIds.includes(diff.previousLeafId)) {
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
targetBinding.leafIds = targetBinding.leafIds.map((leafId) => leafId === diff.previousLeafId ? diff.leafId : leafId);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
if ((update.selectionMode ?? source.selectionMode) !== "all") {
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
const addedLeafIds = update.diffs
|
|
1043
|
+
.filter((diff) => diff.kind === "added")
|
|
1044
|
+
.map((diff) => diff.leafId);
|
|
1045
|
+
if (addedLeafIds.length === 0) {
|
|
756
1046
|
continue;
|
|
757
1047
|
}
|
|
758
1048
|
for (const targetBinding of Object.values(binding.targets)) {
|
|
759
1049
|
if (!targetBinding?.enabled) {
|
|
760
1050
|
continue;
|
|
761
1051
|
}
|
|
762
|
-
const merged = new Set([...targetBinding.leafIds, ...
|
|
1052
|
+
const merged = new Set([...targetBinding.leafIds, ...addedLeafIds]);
|
|
763
1053
|
targetBinding.leafIds = [...merged].filter((leafId) => lockFile.leafInventory.some((leaf) => leaf.id === leafId && leaf.sourceId === update.sourceId));
|
|
764
1054
|
}
|
|
765
1055
|
}
|