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,427 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type {
|
|
3
|
+
LockFile,
|
|
4
|
+
Manifest,
|
|
5
|
+
Result,
|
|
6
|
+
SourceLockRecord,
|
|
7
|
+
SourceManifestRecord,
|
|
8
|
+
} from "../domain/types.js";
|
|
9
|
+
import { StateStore } from "../state/store.js";
|
|
10
|
+
import { ensureDir, pathExists, removePath } from "../utils/fs.js";
|
|
11
|
+
import { git } from "../utils/git.js";
|
|
12
|
+
import { fail, ok } from "../utils/result.js";
|
|
13
|
+
import { deriveDisplayName, deriveSourceId } from "../utils/source-id.js";
|
|
14
|
+
import { InventoryService } from "./inventory-service.js";
|
|
15
|
+
|
|
16
|
+
export type SourceSnapshot = {
|
|
17
|
+
manifest: SourceManifestRecord;
|
|
18
|
+
lock: SourceLockRecord;
|
|
19
|
+
leafCount: number;
|
|
20
|
+
invalidLeafCount: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class SourceService {
|
|
24
|
+
constructor(
|
|
25
|
+
private readonly store: StateStore,
|
|
26
|
+
private readonly inventoryService: InventoryService,
|
|
27
|
+
) {}
|
|
28
|
+
|
|
29
|
+
async addSource(locator: string): Promise<Result<SourceSnapshot>> {
|
|
30
|
+
await this.store.init();
|
|
31
|
+
const manifest = await this.store.readManifest();
|
|
32
|
+
const lockFile = await this.store.readLock();
|
|
33
|
+
|
|
34
|
+
const normalizedLocator = await this.normalizeLocator(locator);
|
|
35
|
+
const displayName = deriveDisplayName(locator);
|
|
36
|
+
const sourceId = deriveSourceId(locator);
|
|
37
|
+
|
|
38
|
+
if (manifest.sources.some((source) => source.id === sourceId)) {
|
|
39
|
+
return fail({
|
|
40
|
+
code: "SOURCE_EXISTS",
|
|
41
|
+
message: `Workflow group '${sourceId}' is already registered.`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const checkoutPath = path.join(this.store.sourceRoot, sourceId);
|
|
46
|
+
await ensureDir(this.store.sourceRoot);
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
await git(["clone", "--depth", "1", normalizedLocator, checkoutPath]);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
await removePath(checkoutPath);
|
|
52
|
+
return fail({
|
|
53
|
+
code: "GIT_CLONE_FAILED",
|
|
54
|
+
message: `Unable to fetch source '${locator}': ${String(error)}`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const snapshot = await this.buildSnapshot(
|
|
59
|
+
sourceId,
|
|
60
|
+
locator,
|
|
61
|
+
displayName,
|
|
62
|
+
checkoutPath,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
if (!snapshot.ok) {
|
|
66
|
+
await removePath(checkoutPath);
|
|
67
|
+
return fail(snapshot.errors, snapshot.warnings);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
manifest.sources.push(snapshot.data.manifest);
|
|
71
|
+
manifest.bindings[sourceId] = { targets: {} };
|
|
72
|
+
lockFile.sources.push(snapshot.data.lock);
|
|
73
|
+
lockFile.leafInventory.push(...snapshot.data.leafs);
|
|
74
|
+
|
|
75
|
+
await this.store.writeManifest(manifest);
|
|
76
|
+
await this.store.writeLock(lockFile);
|
|
77
|
+
|
|
78
|
+
return ok({
|
|
79
|
+
manifest: snapshot.data.manifest,
|
|
80
|
+
lock: snapshot.data.lock,
|
|
81
|
+
leafCount: snapshot.data.leafs.length,
|
|
82
|
+
invalidLeafCount: snapshot.data.lock.invalidLeafs.length,
|
|
83
|
+
}, snapshot.warnings);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async updateSources(sourceIds?: string[]): Promise<
|
|
87
|
+
Result<
|
|
88
|
+
{
|
|
89
|
+
updated: Array<{
|
|
90
|
+
sourceId: string;
|
|
91
|
+
changed: boolean;
|
|
92
|
+
addedLeafIds: string[];
|
|
93
|
+
removedLeafIds: string[];
|
|
94
|
+
invalidatedLeafIds: string[];
|
|
95
|
+
}>;
|
|
96
|
+
}
|
|
97
|
+
>
|
|
98
|
+
> {
|
|
99
|
+
await this.store.init();
|
|
100
|
+
const manifest = await this.store.readManifest();
|
|
101
|
+
const lockFile = await this.store.readLock();
|
|
102
|
+
const selectedIds = sourceIds?.length
|
|
103
|
+
? sourceIds
|
|
104
|
+
: manifest.sources.map((source) => source.id);
|
|
105
|
+
|
|
106
|
+
const updated: Array<{
|
|
107
|
+
sourceId: string;
|
|
108
|
+
changed: boolean;
|
|
109
|
+
addedLeafIds: string[];
|
|
110
|
+
removedLeafIds: string[];
|
|
111
|
+
invalidatedLeafIds: string[];
|
|
112
|
+
}> = [];
|
|
113
|
+
|
|
114
|
+
for (const sourceId of selectedIds) {
|
|
115
|
+
const source = manifest.sources.find((item) => item.id === sourceId);
|
|
116
|
+
const currentLock = lockFile.sources.find((item) => item.id === sourceId);
|
|
117
|
+
|
|
118
|
+
if (!source || !currentLock) {
|
|
119
|
+
return fail({
|
|
120
|
+
code: "SOURCE_NOT_FOUND",
|
|
121
|
+
message: `Workflow group '${sourceId}' is not registered.`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
await git(["pull", "--ff-only"], { cwd: currentLock.checkoutPath });
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return fail({
|
|
129
|
+
code: "GIT_UPDATE_FAILED",
|
|
130
|
+
message: `Unable to update '${sourceId}': ${String(error)}`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const latestCommitSha = await git(["rev-parse", "HEAD"], {
|
|
135
|
+
cwd: currentLock.checkoutPath,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (latestCommitSha === currentLock.commitSha) {
|
|
139
|
+
updated.push({
|
|
140
|
+
sourceId,
|
|
141
|
+
changed: false,
|
|
142
|
+
addedLeafIds: [],
|
|
143
|
+
removedLeafIds: [],
|
|
144
|
+
invalidatedLeafIds: [],
|
|
145
|
+
});
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const snapshot = await this.buildSnapshot(
|
|
150
|
+
source.id,
|
|
151
|
+
source.locator,
|
|
152
|
+
source.displayName,
|
|
153
|
+
currentLock.checkoutPath,
|
|
154
|
+
{ allowEmptyLeafs: true },
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
if (!snapshot.ok) {
|
|
158
|
+
return fail(snapshot.errors, snapshot.warnings);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const previousLeafs = lockFile.leafInventory.filter(
|
|
162
|
+
(leaf) => leaf.sourceId === sourceId,
|
|
163
|
+
);
|
|
164
|
+
const previousLeafIds = new Set(previousLeafs.map((leaf) => leaf.id));
|
|
165
|
+
const nextLeafIds = new Set(snapshot.data.leafs.map((leaf) => leaf.id));
|
|
166
|
+
const previousInvalidPaths = new Set(
|
|
167
|
+
currentLock.invalidLeafs.map((leaf) => leaf.path),
|
|
168
|
+
);
|
|
169
|
+
const nextInvalidPaths = new Set(
|
|
170
|
+
snapshot.data.lock.invalidLeafs.map((leaf) => leaf.path),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
lockFile.sources = lockFile.sources.map((item) =>
|
|
174
|
+
item.id === sourceId ? snapshot.data.lock : item,
|
|
175
|
+
);
|
|
176
|
+
lockFile.leafInventory = [
|
|
177
|
+
...lockFile.leafInventory.filter((leaf) => leaf.sourceId !== sourceId),
|
|
178
|
+
...snapshot.data.leafs,
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
updated.push({
|
|
182
|
+
sourceId,
|
|
183
|
+
changed: true,
|
|
184
|
+
addedLeafIds: [...nextLeafIds].filter((id) => !previousLeafIds.has(id)),
|
|
185
|
+
removedLeafIds: [...previousLeafIds].filter((id) => !nextLeafIds.has(id)),
|
|
186
|
+
invalidatedLeafIds: [...nextInvalidPaths].filter(
|
|
187
|
+
(value) => !previousInvalidPaths.has(value),
|
|
188
|
+
),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
await this.store.writeLock(lockFile);
|
|
193
|
+
return ok({ updated });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async removeSource(sourceIds: string[]): Promise<Result<{ removed: string[] }>> {
|
|
197
|
+
await this.store.init();
|
|
198
|
+
const manifest = await this.store.readManifest();
|
|
199
|
+
const lockFile = await this.store.readLock();
|
|
200
|
+
const removed: string[] = [];
|
|
201
|
+
|
|
202
|
+
for (const sourceId of sourceIds) {
|
|
203
|
+
const currentSource = manifest.sources.find((source) => source.id === sourceId);
|
|
204
|
+
const currentLock = lockFile.sources.find((source) => source.id === sourceId);
|
|
205
|
+
if (!currentSource || !currentLock) {
|
|
206
|
+
return fail({
|
|
207
|
+
code: "SOURCE_NOT_FOUND",
|
|
208
|
+
message: `Workflow group '${sourceId}' is not registered.`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
manifest.sources = manifest.sources.filter((source) => source.id !== sourceId);
|
|
213
|
+
delete manifest.bindings[sourceId];
|
|
214
|
+
lockFile.sources = lockFile.sources.filter((source) => source.id !== sourceId);
|
|
215
|
+
lockFile.leafInventory = lockFile.leafInventory.filter(
|
|
216
|
+
(leaf) => leaf.sourceId !== sourceId,
|
|
217
|
+
);
|
|
218
|
+
lockFile.deployments = lockFile.deployments.filter(
|
|
219
|
+
(deployment) => deployment.sourceId !== sourceId,
|
|
220
|
+
);
|
|
221
|
+
if (currentLock && (await pathExists(currentLock.checkoutPath))) {
|
|
222
|
+
await removePath(currentLock.checkoutPath);
|
|
223
|
+
}
|
|
224
|
+
removed.push(sourceId);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
await this.store.writeManifest(manifest);
|
|
228
|
+
await this.store.writeLock(lockFile);
|
|
229
|
+
return ok({ removed });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async reconcileInventory(
|
|
233
|
+
sourceIds?: string[],
|
|
234
|
+
options: { force?: boolean } = {},
|
|
235
|
+
): Promise<Result<{ updatedSourceIds: string[] }>> {
|
|
236
|
+
await this.store.init();
|
|
237
|
+
const manifest = await this.store.readManifest();
|
|
238
|
+
const lockFile = await this.store.readLock();
|
|
239
|
+
const selectedIds = sourceIds?.length
|
|
240
|
+
? sourceIds
|
|
241
|
+
: manifest.sources.map((source) => source.id);
|
|
242
|
+
const updatedSourceIds: string[] = [];
|
|
243
|
+
|
|
244
|
+
for (const sourceId of selectedIds) {
|
|
245
|
+
const source = manifest.sources.find((item) => item.id === sourceId);
|
|
246
|
+
const currentLock = lockFile.sources.find((item) => item.id === sourceId);
|
|
247
|
+
if (!source || !currentLock) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const sourceLeafs = lockFile.leafInventory.filter((leaf) => leaf.sourceId === sourceId);
|
|
252
|
+
const sourceDeployments = lockFile.deployments.filter(
|
|
253
|
+
(deployment) => deployment.sourceId === sourceId,
|
|
254
|
+
);
|
|
255
|
+
if (
|
|
256
|
+
!options.force &&
|
|
257
|
+
!this.needsInventoryReconcile(sourceId, sourceLeafs, sourceDeployments)
|
|
258
|
+
) {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const snapshot = await this.buildSnapshot(
|
|
263
|
+
source.id,
|
|
264
|
+
source.locator,
|
|
265
|
+
source.displayName,
|
|
266
|
+
currentLock.checkoutPath,
|
|
267
|
+
{ allowEmptyLeafs: true },
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
if (!snapshot.ok) {
|
|
271
|
+
return fail(snapshot.errors, snapshot.warnings);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const leafIdsChanged =
|
|
275
|
+
JSON.stringify(currentLock.leafIds) !==
|
|
276
|
+
JSON.stringify(snapshot.data.lock.leafIds);
|
|
277
|
+
const invalidLeafsChanged =
|
|
278
|
+
JSON.stringify(currentLock.invalidLeafs) !==
|
|
279
|
+
JSON.stringify(snapshot.data.lock.invalidLeafs);
|
|
280
|
+
const leafInventoryChanged =
|
|
281
|
+
JSON.stringify(sourceLeafs) !== JSON.stringify(snapshot.data.leafs);
|
|
282
|
+
|
|
283
|
+
if (
|
|
284
|
+
!options.force &&
|
|
285
|
+
!leafIdsChanged &&
|
|
286
|
+
!invalidLeafsChanged &&
|
|
287
|
+
!leafInventoryChanged
|
|
288
|
+
) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
lockFile.sources = lockFile.sources.map((item) =>
|
|
293
|
+
item.id === sourceId ? snapshot.data.lock : item,
|
|
294
|
+
);
|
|
295
|
+
lockFile.leafInventory = [
|
|
296
|
+
...lockFile.leafInventory.filter((leaf) => leaf.sourceId !== sourceId),
|
|
297
|
+
...snapshot.data.leafs,
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
const nextLeafIds = new Set(snapshot.data.leafs.map((leaf) => leaf.id));
|
|
301
|
+
const binding = manifest.bindings[sourceId];
|
|
302
|
+
if (binding) {
|
|
303
|
+
for (const targetBinding of Object.values(binding.targets)) {
|
|
304
|
+
if (!targetBinding) {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
targetBinding.leafIds = targetBinding.leafIds.filter((leafId) =>
|
|
308
|
+
nextLeafIds.has(leafId),
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
updatedSourceIds.push(sourceId);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (updatedSourceIds.length > 0) {
|
|
317
|
+
await this.store.writeManifest(manifest);
|
|
318
|
+
await this.store.writeLock(lockFile);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return ok({ updatedSourceIds });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private needsInventoryReconcile(
|
|
325
|
+
sourceId: string,
|
|
326
|
+
sourceLeafs: LockFile["leafInventory"],
|
|
327
|
+
sourceDeployments: LockFile["deployments"],
|
|
328
|
+
): boolean {
|
|
329
|
+
const hasGeneratedLeafs = sourceLeafs.some((leaf) =>
|
|
330
|
+
/^(?:\.agents|\.claude|\.codex|\.opencode|\.openclaw)(?:\/|$)/.test(
|
|
331
|
+
leaf.relativePath,
|
|
332
|
+
),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const hasLegacyTargetNames = sourceDeployments.some((deployment) =>
|
|
336
|
+
path.basename(deployment.targetPath).startsWith(`${sourceId}--`),
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
return hasGeneratedLeafs || hasLegacyTargetNames;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private async buildSnapshot(
|
|
343
|
+
sourceId: string,
|
|
344
|
+
locator: string,
|
|
345
|
+
displayName: string,
|
|
346
|
+
checkoutPath: string,
|
|
347
|
+
options: { allowEmptyLeafs?: boolean } = {},
|
|
348
|
+
): Promise<
|
|
349
|
+
Result<{
|
|
350
|
+
manifest: SourceManifestRecord;
|
|
351
|
+
lock: SourceLockRecord;
|
|
352
|
+
leafs: LockFile["leafInventory"];
|
|
353
|
+
}>
|
|
354
|
+
> {
|
|
355
|
+
const commitSha = await git(["rev-parse", "HEAD"], { cwd: checkoutPath });
|
|
356
|
+
const scanned = await this.inventoryService.scanSource(sourceId, checkoutPath);
|
|
357
|
+
const metadataWarnings = scanned.leafs.flatMap((leaf) =>
|
|
358
|
+
leaf.metadataWarnings.map((message) => ({
|
|
359
|
+
code: "SKILL_METADATA_WARNING",
|
|
360
|
+
message: `${leaf.relativePath}: ${message}`,
|
|
361
|
+
})),
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
if (scanned.leafs.length === 0 && !options.allowEmptyLeafs) {
|
|
365
|
+
return fail(
|
|
366
|
+
{
|
|
367
|
+
code: "NO_VALID_LEAFS",
|
|
368
|
+
message: `Source '${displayName}' has no valid skills.`,
|
|
369
|
+
},
|
|
370
|
+
scanned.invalidLeafs.map((leaf) => ({
|
|
371
|
+
code: "INVALID_LEAF",
|
|
372
|
+
message: `${leaf.path}: ${leaf.reason}`,
|
|
373
|
+
})),
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return ok(
|
|
378
|
+
{
|
|
379
|
+
manifest: {
|
|
380
|
+
id: sourceId,
|
|
381
|
+
locator,
|
|
382
|
+
kind: "git",
|
|
383
|
+
displayName,
|
|
384
|
+
addedAt: new Date().toISOString(),
|
|
385
|
+
},
|
|
386
|
+
lock: {
|
|
387
|
+
id: sourceId,
|
|
388
|
+
locator,
|
|
389
|
+
kind: "git",
|
|
390
|
+
displayName,
|
|
391
|
+
checkoutPath,
|
|
392
|
+
commitSha,
|
|
393
|
+
updatedAt: new Date().toISOString(),
|
|
394
|
+
leafIds: scanned.leafs.map((leaf) => leaf.id),
|
|
395
|
+
invalidLeafs: scanned.invalidLeafs,
|
|
396
|
+
},
|
|
397
|
+
leafs: scanned.leafs,
|
|
398
|
+
},
|
|
399
|
+
[
|
|
400
|
+
...metadataWarnings,
|
|
401
|
+
...scanned.invalidLeafs.map((leaf) => ({
|
|
402
|
+
code: "INVALID_LEAF",
|
|
403
|
+
message: `${leaf.path}: ${leaf.reason}`,
|
|
404
|
+
})),
|
|
405
|
+
],
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private async normalizeLocator(locator: string): Promise<string> {
|
|
410
|
+
const trimmed = locator.trim();
|
|
411
|
+
|
|
412
|
+
if (/^[^/\s]+\/[^/\s]+$/.test(trimmed)) {
|
|
413
|
+
return `https://github.com/${trimmed}.git`;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (trimmed.startsWith("git@") || trimmed.startsWith("http")) {
|
|
417
|
+
return trimmed;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const resolvedPath = path.resolve(trimmed);
|
|
421
|
+
if (await pathExists(resolvedPath)) {
|
|
422
|
+
return resolvedPath;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return trimmed;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HealthStatus,
|
|
3
|
+
LockFile,
|
|
4
|
+
Manifest,
|
|
5
|
+
SourceBinding,
|
|
6
|
+
WorkflowSummary,
|
|
7
|
+
} from "../domain/types.js";
|
|
8
|
+
|
|
9
|
+
export class WorkflowService {
|
|
10
|
+
getSummaries(manifest: Manifest, lockFile: LockFile): WorkflowSummary[] {
|
|
11
|
+
return manifest.sources.map((source) => {
|
|
12
|
+
const lock = lockFile.sources.find((item) => item.id === source.id);
|
|
13
|
+
const leafs = lockFile.leafInventory.filter((leaf) => leaf.sourceId === source.id);
|
|
14
|
+
const bindings = manifest.bindings[source.id] ?? ({ targets: {} } satisfies SourceBinding);
|
|
15
|
+
const activeTargetCount = Object.values(bindings.targets).filter(
|
|
16
|
+
(binding) => binding?.enabled,
|
|
17
|
+
).length;
|
|
18
|
+
const warningCount = leafs.reduce(
|
|
19
|
+
(count, leaf) => count + leaf.metadataWarnings.length,
|
|
20
|
+
0,
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
source,
|
|
25
|
+
lock,
|
|
26
|
+
leafs,
|
|
27
|
+
bindings,
|
|
28
|
+
activeTargetCount,
|
|
29
|
+
health: this.resolveHealth(
|
|
30
|
+
lock ? lock.invalidLeafs.length : 0,
|
|
31
|
+
warningCount,
|
|
32
|
+
activeTargetCount,
|
|
33
|
+
lock,
|
|
34
|
+
),
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private resolveHealth(
|
|
40
|
+
invalidLeafCount: number,
|
|
41
|
+
warningCount: number,
|
|
42
|
+
activeTargetCount: number,
|
|
43
|
+
lock?: LockFile["sources"][number],
|
|
44
|
+
): HealthStatus {
|
|
45
|
+
if (!lock) {
|
|
46
|
+
return "BLOCKED";
|
|
47
|
+
}
|
|
48
|
+
if (invalidLeafCount > 0 || warningCount > 0) {
|
|
49
|
+
return "PARTIAL";
|
|
50
|
+
}
|
|
51
|
+
if (activeTargetCount === 0) {
|
|
52
|
+
return "INACTIVE";
|
|
53
|
+
}
|
|
54
|
+
return "ACTIVE";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { LockFile, Manifest } from "../domain/types.js";
|
|
3
|
+
import { SCHEMA_VERSION, getStateRoot } from "../utils/constants.js";
|
|
4
|
+
import { ensureDir, readJsonFile, writeJsonFile } from "../utils/fs.js";
|
|
5
|
+
|
|
6
|
+
export class StateStore {
|
|
7
|
+
constructor(private readonly stateRoot = getStateRoot()) {}
|
|
8
|
+
|
|
9
|
+
get rootPath(): string {
|
|
10
|
+
return this.stateRoot;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
get sourceRoot(): string {
|
|
14
|
+
return path.join(this.stateRoot, "source", "git");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get manifestPath(): string {
|
|
18
|
+
return path.join(this.stateRoot, "manifest.json");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get lockPath(): string {
|
|
22
|
+
return path.join(this.stateRoot, "lock.json");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async init(): Promise<void> {
|
|
26
|
+
await ensureDir(this.sourceRoot);
|
|
27
|
+
await this.writeManifest(await this.readManifest());
|
|
28
|
+
await this.writeLock(await this.readLock());
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async readManifest(): Promise<Manifest> {
|
|
32
|
+
return readJsonFile<Manifest>(this.manifestPath, {
|
|
33
|
+
schemaVersion: SCHEMA_VERSION,
|
|
34
|
+
sources: [],
|
|
35
|
+
bindings: {},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async writeManifest(manifest: Manifest): Promise<void> {
|
|
40
|
+
await writeJsonFile(this.manifestPath, manifest);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async readLock(): Promise<LockFile> {
|
|
44
|
+
const lockFile = await readJsonFile<LockFile>(this.lockPath, {
|
|
45
|
+
schemaVersion: SCHEMA_VERSION,
|
|
46
|
+
sources: [],
|
|
47
|
+
leafInventory: [],
|
|
48
|
+
deployments: [],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
...lockFile,
|
|
53
|
+
leafInventory: lockFile.leafInventory.map((leaf) => ({
|
|
54
|
+
...leaf,
|
|
55
|
+
linkName:
|
|
56
|
+
leaf.linkName ??
|
|
57
|
+
(leaf.relativePath === "."
|
|
58
|
+
? leaf.name
|
|
59
|
+
: path.basename(leaf.relativePath) || leaf.name),
|
|
60
|
+
metadataWarnings: leaf.metadataWarnings ?? [],
|
|
61
|
+
})),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async writeLock(lockFile: LockFile): Promise<void> {
|
|
66
|
+
await writeJsonFile(this.lockPath, lockFile);
|
|
67
|
+
}
|
|
68
|
+
}
|