@automagik/genie 4.260331.6 → 4.260331.8
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/.claude-plugin/marketplace.json +1 -1
- package/dist/genie.js +134 -253
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/plugins/genie/.claude-plugin/plugin.json +1 -1
- package/plugins/genie/package.json +1 -1
- package/src/db/migrations/015_agent_archived_state.sql +0 -20
- package/src/db/migrations/018_drop_app_store.sql +4 -0
- package/src/genie.ts +0 -11
- package/src/lib/agent-sync.ts +55 -194
- package/src/lib/export-format.ts +1 -12
- package/src/lib/import-order.ts +1 -11
- package/src/lib/task-service.ts +4 -0
- package/src/term-commands/agent/directory.ts +2 -37
- package/src/term-commands/dir.ts +3 -68
- package/src/term-commands/export.ts +0 -13
- package/src/term-commands/team.ts +14 -11
- package/src/lib/agent-cache.ts +0 -282
- package/src/lib/manifest.ts +0 -342
- package/src/term-commands/install.ts +0 -372
- package/src/term-commands/item-uninstall.ts +0 -118
- package/src/term-commands/item-update.ts +0 -205
- package/src/term-commands/publish.ts +0 -187
package/src/lib/manifest.ts
DELETED
|
@@ -1,342 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Manifest — Genie item manifest parsing, validation, and detection.
|
|
3
|
-
*
|
|
4
|
-
* Every registry item (agent, skill, app, board, workflow, stack, template, hook)
|
|
5
|
-
* is described by a `genie.yaml` manifest. This module handles:
|
|
6
|
-
* - Parsing YAML manifests into typed GenieManifest objects
|
|
7
|
-
* - Validating manifests against type-specific rules
|
|
8
|
-
* - Auto-detecting manifests from directory conventions (AGENTS.md, skill.md, etc.)
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
12
|
-
import { basename, join } from 'node:path';
|
|
13
|
-
import * as yaml from 'js-yaml';
|
|
14
|
-
|
|
15
|
-
// ============================================================================
|
|
16
|
-
// Types
|
|
17
|
-
// ============================================================================
|
|
18
|
-
|
|
19
|
-
export type ItemType = 'agent' | 'skill' | 'app' | 'board' | 'workflow' | 'stack' | 'template' | 'hook';
|
|
20
|
-
|
|
21
|
-
const ITEM_TYPES: ItemType[] = ['agent', 'skill', 'app', 'board', 'workflow', 'stack', 'template', 'hook'];
|
|
22
|
-
|
|
23
|
-
export interface StageConfig {
|
|
24
|
-
name: string;
|
|
25
|
-
label?: string;
|
|
26
|
-
gate: 'human' | 'agent' | 'human+agent';
|
|
27
|
-
action?: string;
|
|
28
|
-
auto_advance?: boolean;
|
|
29
|
-
roles?: string[];
|
|
30
|
-
color?: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface StackItem {
|
|
34
|
-
name: string;
|
|
35
|
-
type: ItemType;
|
|
36
|
-
source?: string;
|
|
37
|
-
inline?: boolean;
|
|
38
|
-
config?: Record<string, unknown>;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface GenieManifest {
|
|
42
|
-
name: string;
|
|
43
|
-
version: string;
|
|
44
|
-
type: ItemType;
|
|
45
|
-
description?: string;
|
|
46
|
-
author?: { name: string; url?: string };
|
|
47
|
-
agent?: { model?: string; promptMode?: string; roles?: string[]; entrypoint: string };
|
|
48
|
-
skill?: { triggers?: string[]; entrypoint: string };
|
|
49
|
-
app?: { runtime?: string; natsPrefix?: string; icon?: string; entrypoint: string };
|
|
50
|
-
board?: { stages: StageConfig[] };
|
|
51
|
-
workflow?: { cron: string; timezone?: string; command: string; run_spec?: Record<string, unknown> };
|
|
52
|
-
stack?: { items: StackItem[] };
|
|
53
|
-
dependencies?: string[];
|
|
54
|
-
tags?: string[];
|
|
55
|
-
category?: string;
|
|
56
|
-
license?: string;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
interface ValidationResult {
|
|
60
|
-
valid: boolean;
|
|
61
|
-
errors: string[];
|
|
62
|
-
warnings: string[];
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// ============================================================================
|
|
66
|
-
// Parsing
|
|
67
|
-
// ============================================================================
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Parse a YAML string into a typed GenieManifest.
|
|
71
|
-
*
|
|
72
|
-
* Validates that required fields (name, type, version) are present and that
|
|
73
|
-
* the type value is a recognized ItemType. Throws on invalid input.
|
|
74
|
-
*/
|
|
75
|
-
function parseManifest(yamlContent: string): GenieManifest {
|
|
76
|
-
const raw = yaml.load(yamlContent);
|
|
77
|
-
if (!raw || typeof raw !== 'object') {
|
|
78
|
-
throw new Error('Manifest YAML is empty or not an object');
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const obj = raw as Record<string, unknown>;
|
|
82
|
-
|
|
83
|
-
if (!obj.name || typeof obj.name !== 'string') {
|
|
84
|
-
throw new Error('Manifest is missing required field: name');
|
|
85
|
-
}
|
|
86
|
-
if (!obj.type || typeof obj.type !== 'string') {
|
|
87
|
-
throw new Error('Manifest is missing required field: type');
|
|
88
|
-
}
|
|
89
|
-
if (!obj.version || typeof obj.version !== 'string') {
|
|
90
|
-
throw new Error('Manifest is missing required field: version');
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (!ITEM_TYPES.includes(obj.type as ItemType)) {
|
|
94
|
-
throw new Error(`Invalid item type "${obj.type}". Must be one of: ${ITEM_TYPES.join(', ')}`);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return obj as unknown as GenieManifest;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// ============================================================================
|
|
101
|
-
// Validation
|
|
102
|
-
// ============================================================================
|
|
103
|
-
|
|
104
|
-
const VALID_GATES = new Set(['human', 'agent', 'human+agent']);
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Validate a manifest against type-specific rules.
|
|
108
|
-
*
|
|
109
|
-
* Checks required fields, type-specific sections, entrypoint file existence,
|
|
110
|
-
* board stage structure, cron format, and stack item completeness.
|
|
111
|
-
* Returns a ValidationResult with errors (blockers) and warnings (advisory).
|
|
112
|
-
*/
|
|
113
|
-
export function validateManifest(manifest: GenieManifest, itemDir: string): ValidationResult {
|
|
114
|
-
const errors: string[] = [];
|
|
115
|
-
const warnings: string[] = [];
|
|
116
|
-
|
|
117
|
-
// Required top-level fields
|
|
118
|
-
if (!manifest.name) errors.push('Missing required field: name');
|
|
119
|
-
if (!manifest.type) errors.push('Missing required field: type');
|
|
120
|
-
if (!manifest.version) errors.push('Missing required field: version');
|
|
121
|
-
|
|
122
|
-
if (manifest.type && !ITEM_TYPES.includes(manifest.type)) {
|
|
123
|
-
errors.push(`Invalid item type "${manifest.type}". Must be one of: ${ITEM_TYPES.join(', ')}`);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Type-specific validation
|
|
127
|
-
const { type } = manifest;
|
|
128
|
-
|
|
129
|
-
// Warn if the type-specific section is missing
|
|
130
|
-
if (type && !manifest[type as keyof GenieManifest]) {
|
|
131
|
-
warnings.push(`Manifest type is "${type}" but no "${type}" section is defined`);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Agent validation
|
|
135
|
-
if (manifest.agent) {
|
|
136
|
-
validateEntrypoint(manifest.agent.entrypoint, itemDir, 'agent', errors);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Skill validation
|
|
140
|
-
if (manifest.skill) {
|
|
141
|
-
validateEntrypoint(manifest.skill.entrypoint, itemDir, 'skill', errors);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// App validation
|
|
145
|
-
if (manifest.app) {
|
|
146
|
-
validateEntrypoint(manifest.app.entrypoint, itemDir, 'app', errors);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Board validation
|
|
150
|
-
if (manifest.board) {
|
|
151
|
-
validateStages(manifest.board.stages, errors);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Workflow validation
|
|
155
|
-
if (manifest.workflow) {
|
|
156
|
-
validateCron(manifest.workflow.cron, errors);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Stack validation
|
|
160
|
-
if (manifest.stack) {
|
|
161
|
-
validateStackItems(manifest.stack.items, errors);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return { valid: errors.length === 0, errors, warnings };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** Check that an entrypoint file exists on disk. */
|
|
168
|
-
function validateEntrypoint(entrypoint: string | undefined, itemDir: string, section: string, errors: string[]): void {
|
|
169
|
-
if (!entrypoint) {
|
|
170
|
-
errors.push(`${section} section is missing required field: entrypoint`);
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
const fullPath = join(itemDir, entrypoint);
|
|
174
|
-
if (!existsSync(fullPath)) {
|
|
175
|
-
errors.push(`${section} entrypoint not found: ${entrypoint} (expected at ${fullPath})`);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** Validate board stage definitions. */
|
|
180
|
-
function validateStages(stages: StageConfig[] | undefined, errors: string[]): void {
|
|
181
|
-
if (!stages || !Array.isArray(stages)) {
|
|
182
|
-
errors.push('board section is missing required field: stages');
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
for (let i = 0; i < stages.length; i++) {
|
|
186
|
-
const stage = stages[i];
|
|
187
|
-
if (!stage.name) {
|
|
188
|
-
errors.push(`board.stages[${i}] is missing required field: name`);
|
|
189
|
-
}
|
|
190
|
-
if (!stage.gate) {
|
|
191
|
-
errors.push(`board.stages[${i}] is missing required field: gate`);
|
|
192
|
-
} else if (!VALID_GATES.has(stage.gate)) {
|
|
193
|
-
errors.push(`board.stages[${i}].gate "${stage.gate}" is invalid. Must be one of: human, agent, human+agent`);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Validate a cron expression has 5 or 6 space-separated fields.
|
|
200
|
-
* This is a basic format check, not full cron syntax validation.
|
|
201
|
-
*/
|
|
202
|
-
function validateCron(cron: string | undefined, errors: string[]): void {
|
|
203
|
-
if (!cron) {
|
|
204
|
-
errors.push('workflow section is missing required field: cron');
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
const fields = cron.trim().split(/\s+/);
|
|
208
|
-
if (fields.length < 5 || fields.length > 6) {
|
|
209
|
-
errors.push(`workflow.cron "${cron}" is invalid: expected 5 or 6 space-separated fields, got ${fields.length}`);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/** Validate stack item definitions. */
|
|
214
|
-
function validateStackItems(items: StackItem[] | undefined, errors: string[]): void {
|
|
215
|
-
if (!items || !Array.isArray(items)) {
|
|
216
|
-
errors.push('stack section is missing required field: items');
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
for (let i = 0; i < items.length; i++) {
|
|
220
|
-
const item = items[i];
|
|
221
|
-
if (!item.type) {
|
|
222
|
-
errors.push(`stack.items[${i}] is missing required field: type`);
|
|
223
|
-
}
|
|
224
|
-
if (!item.source && !item.inline) {
|
|
225
|
-
errors.push(`stack.items[${i}] must have either "source" or "inline: true"`);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// ============================================================================
|
|
231
|
-
// Detection
|
|
232
|
-
// ============================================================================
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Detect a manifest from a directory using a fallback chain.
|
|
236
|
-
*
|
|
237
|
-
* Detection order:
|
|
238
|
-
* 1. `genie.yaml` — explicit manifest file
|
|
239
|
-
* 2. `AGENTS.md` — infer agent manifest from YAML frontmatter
|
|
240
|
-
* 3. `manifest.ts` — infer app manifest
|
|
241
|
-
* 4. `skill.md` — infer skill manifest
|
|
242
|
-
* 5. None found — return error
|
|
243
|
-
*/
|
|
244
|
-
export async function detectManifest(
|
|
245
|
-
dir: string,
|
|
246
|
-
): Promise<{ manifest: GenieManifest; source: string } | { error: string }> {
|
|
247
|
-
// 1. Explicit genie.yaml
|
|
248
|
-
const yamlPath = join(dir, 'genie.yaml');
|
|
249
|
-
if (existsSync(yamlPath)) {
|
|
250
|
-
try {
|
|
251
|
-
const content = readFileSync(yamlPath, 'utf-8');
|
|
252
|
-
const manifest = parseManifest(content);
|
|
253
|
-
return { manifest, source: 'genie.yaml' };
|
|
254
|
-
} catch (err) {
|
|
255
|
-
return { error: `Failed to parse genie.yaml: ${(err as Error).message}` };
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const dirName = basename(dir);
|
|
260
|
-
|
|
261
|
-
// 2. AGENTS.md — infer agent manifest from frontmatter
|
|
262
|
-
const agentsPath = join(dir, 'AGENTS.md');
|
|
263
|
-
if (existsSync(agentsPath)) {
|
|
264
|
-
const manifest = inferAgentManifest(agentsPath, dirName);
|
|
265
|
-
return { manifest, source: 'AGENTS.md' };
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// 3. manifest.ts — infer app manifest
|
|
269
|
-
const manifestTsPath = join(dir, 'manifest.ts');
|
|
270
|
-
if (existsSync(manifestTsPath)) {
|
|
271
|
-
const manifest: GenieManifest = {
|
|
272
|
-
name: dirName,
|
|
273
|
-
type: 'app',
|
|
274
|
-
version: '0.0.0',
|
|
275
|
-
};
|
|
276
|
-
return { manifest, source: 'manifest.ts' };
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// 4. skill.md — infer skill manifest
|
|
280
|
-
const skillPath = join(dir, 'skill.md');
|
|
281
|
-
if (existsSync(skillPath)) {
|
|
282
|
-
const manifest: GenieManifest = {
|
|
283
|
-
name: dirName,
|
|
284
|
-
type: 'skill',
|
|
285
|
-
version: '0.0.0',
|
|
286
|
-
};
|
|
287
|
-
return { manifest, source: 'skill.md' };
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// 5. Nothing found
|
|
291
|
-
return {
|
|
292
|
-
error:
|
|
293
|
-
'No manifest found. Create a genie.yaml or use a recognized file pattern (AGENTS.md, manifest.ts, skill.md).',
|
|
294
|
-
};
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* Extract YAML frontmatter from a markdown file.
|
|
299
|
-
*
|
|
300
|
-
* Looks for content between `---` markers at the top of the file.
|
|
301
|
-
* Returns the parsed object or null if no frontmatter is found.
|
|
302
|
-
*/
|
|
303
|
-
function extractFrontmatter(filePath: string): Record<string, unknown> | null {
|
|
304
|
-
const content = readFileSync(filePath, 'utf-8');
|
|
305
|
-
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
306
|
-
if (!match) return null;
|
|
307
|
-
|
|
308
|
-
try {
|
|
309
|
-
const parsed = yaml.load(match[1]);
|
|
310
|
-
if (parsed && typeof parsed === 'object') {
|
|
311
|
-
return parsed as Record<string, unknown>;
|
|
312
|
-
}
|
|
313
|
-
} catch {
|
|
314
|
-
// Frontmatter is not valid YAML — ignore
|
|
315
|
-
}
|
|
316
|
-
return null;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/** Build an agent manifest from AGENTS.md frontmatter. */
|
|
320
|
-
function inferAgentManifest(agentsPath: string, dirName: string): GenieManifest {
|
|
321
|
-
const frontmatter = extractFrontmatter(agentsPath);
|
|
322
|
-
|
|
323
|
-
const name = (frontmatter?.name as string) || dirName;
|
|
324
|
-
const model = frontmatter?.model as string | undefined;
|
|
325
|
-
const roles = Array.isArray(frontmatter?.roles) ? (frontmatter.roles as string[]) : undefined;
|
|
326
|
-
|
|
327
|
-
const manifest: GenieManifest = {
|
|
328
|
-
name,
|
|
329
|
-
type: 'agent',
|
|
330
|
-
version: '0.0.0',
|
|
331
|
-
};
|
|
332
|
-
|
|
333
|
-
if (model || roles) {
|
|
334
|
-
manifest.agent = {
|
|
335
|
-
entrypoint: 'AGENTS.md',
|
|
336
|
-
...(model && { model }),
|
|
337
|
-
...(roles && { roles }),
|
|
338
|
-
};
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
return manifest;
|
|
342
|
-
}
|
|
@@ -1,372 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Install Command — `genie install <git-url>[@version]`
|
|
3
|
-
*
|
|
4
|
-
* Clones a git repository, detects/validates the manifest, registers in
|
|
5
|
-
* app_store, and performs type-specific setup (cache regen, board creation, etc.).
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { execSync } from 'node:child_process';
|
|
9
|
-
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
10
|
-
import { homedir } from 'node:os';
|
|
11
|
-
import { basename, join } from 'node:path';
|
|
12
|
-
import type { Command } from 'commander';
|
|
13
|
-
import {
|
|
14
|
-
getItemFromStore,
|
|
15
|
-
regenerateAgentCache,
|
|
16
|
-
registerItemInStore,
|
|
17
|
-
removeItemFromStore,
|
|
18
|
-
} from '../lib/agent-cache.js';
|
|
19
|
-
import { getActor, recordAuditEvent } from '../lib/audit.js';
|
|
20
|
-
import { getConnection, isAvailable } from '../lib/db.js';
|
|
21
|
-
import { type GenieManifest, type StageConfig, detectManifest, validateManifest } from '../lib/manifest.js';
|
|
22
|
-
|
|
23
|
-
const GENIE_HOME = process.env.GENIE_HOME ?? join(homedir(), '.genie');
|
|
24
|
-
const ITEMS_DIR = join(GENIE_HOME, 'items');
|
|
25
|
-
|
|
26
|
-
// ============================================================================
|
|
27
|
-
// URL parsing
|
|
28
|
-
// ============================================================================
|
|
29
|
-
|
|
30
|
-
interface ParsedUrl {
|
|
31
|
-
url: string;
|
|
32
|
-
version?: string;
|
|
33
|
-
name: string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Parse a git install target like `github.com/user/repo@v1.2.0`
|
|
38
|
-
* into its components.
|
|
39
|
-
*/
|
|
40
|
-
function parseInstallTarget(target: string): ParsedUrl {
|
|
41
|
-
let url = target;
|
|
42
|
-
let version: string | undefined;
|
|
43
|
-
|
|
44
|
-
// Extract @version suffix
|
|
45
|
-
const atIdx = url.lastIndexOf('@');
|
|
46
|
-
if (atIdx > 0 && !url.slice(atIdx).includes('/')) {
|
|
47
|
-
version = url.slice(atIdx + 1);
|
|
48
|
-
url = url.slice(0, atIdx);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Normalise bare github.com/user/repo → https://github.com/user/repo.git
|
|
52
|
-
if (!url.startsWith('http') && !url.startsWith('git@') && !url.startsWith('ssh://')) {
|
|
53
|
-
url = `https://${url}`;
|
|
54
|
-
}
|
|
55
|
-
if (url.startsWith('https://') && !url.endsWith('.git')) {
|
|
56
|
-
url = `${url}.git`;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// Derive name from repo URL
|
|
60
|
-
const name = basename(url, '.git');
|
|
61
|
-
|
|
62
|
-
return { url, version, name };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// ============================================================================
|
|
66
|
-
// Git operations
|
|
67
|
-
// ============================================================================
|
|
68
|
-
|
|
69
|
-
function cloneRepo(url: string, dest: string, options: { shallow?: boolean; version?: string }): void {
|
|
70
|
-
const args = ['git', 'clone'];
|
|
71
|
-
if (options.shallow !== false) args.push('--depth', '1');
|
|
72
|
-
if (options.version) args.push('--branch', options.version);
|
|
73
|
-
args.push(url, dest);
|
|
74
|
-
|
|
75
|
-
execSync(args.join(' '), { stdio: 'pipe', timeout: 120_000 });
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function cleanupDir(dir: string): void {
|
|
79
|
-
if (existsSync(dir)) {
|
|
80
|
-
rmSync(dir, { recursive: true, force: true });
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// ============================================================================
|
|
85
|
-
// Type-specific registration
|
|
86
|
-
// ============================================================================
|
|
87
|
-
|
|
88
|
-
async function registerByType(manifest: GenieManifest, installPath: string): Promise<void> {
|
|
89
|
-
switch (manifest.type) {
|
|
90
|
-
case 'agent':
|
|
91
|
-
await regenerateAgentCache();
|
|
92
|
-
break;
|
|
93
|
-
case 'board':
|
|
94
|
-
await registerBoard(manifest);
|
|
95
|
-
break;
|
|
96
|
-
case 'workflow':
|
|
97
|
-
await registerWorkflow(manifest);
|
|
98
|
-
break;
|
|
99
|
-
case 'stack':
|
|
100
|
-
await installStack(manifest, installPath);
|
|
101
|
-
break;
|
|
102
|
-
// skill, app, template, hook — no extra registration needed beyond app_store
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
async function registerBoard(manifest: GenieManifest): Promise<void> {
|
|
107
|
-
if (!manifest.board?.stages) return;
|
|
108
|
-
if (!(await isAvailable())) return;
|
|
109
|
-
|
|
110
|
-
const sql = await getConnection();
|
|
111
|
-
const stages = manifest.board.stages.map((s, i) => ({
|
|
112
|
-
id: crypto.randomUUID(),
|
|
113
|
-
name: s.name,
|
|
114
|
-
label: s.label ?? s.name,
|
|
115
|
-
gate: s.gate,
|
|
116
|
-
action: s.action ?? null,
|
|
117
|
-
auto_advance: s.auto_advance ?? false,
|
|
118
|
-
roles: s.roles ?? ['*'],
|
|
119
|
-
color: s.color ?? '#94a3b8',
|
|
120
|
-
parallel: false,
|
|
121
|
-
on_fail: null,
|
|
122
|
-
position: i,
|
|
123
|
-
transitions: [],
|
|
124
|
-
}));
|
|
125
|
-
|
|
126
|
-
await sql`
|
|
127
|
-
INSERT INTO task_types (id, name, description, stages, is_builtin)
|
|
128
|
-
VALUES (
|
|
129
|
-
${manifest.name},
|
|
130
|
-
${manifest.name},
|
|
131
|
-
${manifest.description ?? null},
|
|
132
|
-
${sql.json(stages)},
|
|
133
|
-
false
|
|
134
|
-
)
|
|
135
|
-
ON CONFLICT (id) DO UPDATE SET
|
|
136
|
-
stages = EXCLUDED.stages,
|
|
137
|
-
description = EXCLUDED.description,
|
|
138
|
-
updated_at = now()
|
|
139
|
-
`;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
async function registerWorkflow(manifest: GenieManifest): Promise<void> {
|
|
143
|
-
if (!manifest.workflow) return;
|
|
144
|
-
if (!(await isAvailable())) return;
|
|
145
|
-
|
|
146
|
-
const sql = await getConnection();
|
|
147
|
-
const wf = manifest.workflow;
|
|
148
|
-
|
|
149
|
-
await sql`
|
|
150
|
-
INSERT INTO schedules (id, name, cron_expression, timezone, command, run_spec, status)
|
|
151
|
-
VALUES (
|
|
152
|
-
${`sched-${manifest.name}`},
|
|
153
|
-
${manifest.name},
|
|
154
|
-
${wf.cron},
|
|
155
|
-
${wf.timezone ?? 'UTC'},
|
|
156
|
-
${wf.command},
|
|
157
|
-
${sql.json(wf.run_spec ?? {})},
|
|
158
|
-
'active'
|
|
159
|
-
)
|
|
160
|
-
ON CONFLICT (id) DO UPDATE SET
|
|
161
|
-
cron_expression = EXCLUDED.cron_expression,
|
|
162
|
-
timezone = EXCLUDED.timezone,
|
|
163
|
-
command = EXCLUDED.command,
|
|
164
|
-
run_spec = EXCLUDED.run_spec,
|
|
165
|
-
updated_at = now()
|
|
166
|
-
`;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
async function handleInlineStackItem(
|
|
170
|
-
item: import('../lib/manifest.js').StackItem,
|
|
171
|
-
version: string,
|
|
172
|
-
tx: Awaited<ReturnType<typeof getConnection>>,
|
|
173
|
-
): Promise<void> {
|
|
174
|
-
if (item.type === 'board' && item.config) {
|
|
175
|
-
await registerBoard({
|
|
176
|
-
name: item.name,
|
|
177
|
-
type: 'board',
|
|
178
|
-
version,
|
|
179
|
-
board: { stages: (item.config.stages ?? []) as StageConfig[] },
|
|
180
|
-
});
|
|
181
|
-
} else if (item.type === 'workflow' && item.config) {
|
|
182
|
-
await registerWorkflow({
|
|
183
|
-
name: item.name,
|
|
184
|
-
type: 'workflow',
|
|
185
|
-
version,
|
|
186
|
-
workflow: item.config as unknown as NonNullable<GenieManifest['workflow']>,
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
await tx`
|
|
190
|
-
INSERT INTO app_store (name, item_type, version, manifest)
|
|
191
|
-
VALUES (${item.name}, ${item.type}, ${version}, ${tx.json(item.config ?? {})})
|
|
192
|
-
ON CONFLICT (name) DO NOTHING
|
|
193
|
-
`;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
async function handleExternalStackItem(
|
|
197
|
-
item: import('../lib/manifest.js').StackItem,
|
|
198
|
-
tx: Awaited<ReturnType<typeof getConnection>>,
|
|
199
|
-
): Promise<string> {
|
|
200
|
-
if (!item.source) throw new Error(`Stack item "${item.name}" is missing source`);
|
|
201
|
-
const parsed = parseInstallTarget(item.source);
|
|
202
|
-
const itemDir = join(ITEMS_DIR, parsed.name);
|
|
203
|
-
mkdirSync(ITEMS_DIR, { recursive: true });
|
|
204
|
-
cloneRepo(parsed.url, itemDir, { version: parsed.version });
|
|
205
|
-
|
|
206
|
-
const detection = await detectManifest(itemDir);
|
|
207
|
-
if ('error' in detection) {
|
|
208
|
-
throw new Error(`Stack item "${item.name}" (${item.source}): ${detection.error}`);
|
|
209
|
-
}
|
|
210
|
-
const validation = validateManifest(detection.manifest, itemDir);
|
|
211
|
-
if (!validation.valid) {
|
|
212
|
-
throw new Error(`Stack item "${item.name}" validation failed: ${validation.errors.join(', ')}`);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
await tx`
|
|
216
|
-
INSERT INTO app_store (name, item_type, version, git_url, install_path, manifest)
|
|
217
|
-
VALUES (
|
|
218
|
-
${detection.manifest.name}, ${detection.manifest.type}, ${detection.manifest.version},
|
|
219
|
-
${item.source}, ${itemDir}, ${tx.json(detection.manifest)}
|
|
220
|
-
)
|
|
221
|
-
ON CONFLICT (name) DO NOTHING
|
|
222
|
-
`;
|
|
223
|
-
await registerByType(detection.manifest, itemDir);
|
|
224
|
-
return parsed.name;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
async function installStack(manifest: GenieManifest, _installPath: string): Promise<void> {
|
|
228
|
-
const stackItems = manifest.stack?.items;
|
|
229
|
-
if (!stackItems) return;
|
|
230
|
-
if (!(await isAvailable())) return;
|
|
231
|
-
|
|
232
|
-
const sql = await getConnection();
|
|
233
|
-
const installed: string[] = [];
|
|
234
|
-
|
|
235
|
-
try {
|
|
236
|
-
await sql.begin(async (tx: typeof sql) => {
|
|
237
|
-
for (const item of stackItems) {
|
|
238
|
-
if (item.inline) {
|
|
239
|
-
await handleInlineStackItem(item, manifest.version, tx);
|
|
240
|
-
installed.push(item.name);
|
|
241
|
-
} else if (item.source) {
|
|
242
|
-
const name = await handleExternalStackItem(item, tx);
|
|
243
|
-
installed.push(name);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
});
|
|
247
|
-
} catch (err) {
|
|
248
|
-
// Rollback: clean up cloned dirs
|
|
249
|
-
for (const name of installed) {
|
|
250
|
-
cleanupDir(join(ITEMS_DIR, name));
|
|
251
|
-
await removeItemFromStore(name).catch(() => {});
|
|
252
|
-
}
|
|
253
|
-
throw err;
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// ============================================================================
|
|
258
|
-
// Install command
|
|
259
|
-
// ============================================================================
|
|
260
|
-
|
|
261
|
-
interface InstallOptions {
|
|
262
|
-
force?: boolean;
|
|
263
|
-
full?: boolean;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
async function handleInstall(target: string, options: InstallOptions): Promise<void> {
|
|
267
|
-
const parsed = parseInstallTarget(target);
|
|
268
|
-
const installDir = join(ITEMS_DIR, parsed.name);
|
|
269
|
-
|
|
270
|
-
// Check for name conflict
|
|
271
|
-
const existing = await getItemFromStore(parsed.name).catch(() => null);
|
|
272
|
-
if (existing && !options.force) {
|
|
273
|
-
console.error(`Item "${parsed.name}" is already installed. Use --force to override.`);
|
|
274
|
-
process.exit(1);
|
|
275
|
-
}
|
|
276
|
-
if (existing && options.force) {
|
|
277
|
-
await removeItemFromStore(parsed.name).catch(() => {});
|
|
278
|
-
cleanupDir(installDir);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Clone
|
|
282
|
-
mkdirSync(ITEMS_DIR, { recursive: true });
|
|
283
|
-
console.log(`Cloning ${parsed.url}${parsed.version ? ` @ ${parsed.version}` : ''}...`);
|
|
284
|
-
try {
|
|
285
|
-
cloneRepo(parsed.url, installDir, { shallow: !options.full, version: parsed.version });
|
|
286
|
-
} catch (err) {
|
|
287
|
-
cleanupDir(installDir);
|
|
288
|
-
console.error(`Clone failed: ${(err as Error).message}`);
|
|
289
|
-
process.exit(1);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// Detect manifest
|
|
293
|
-
const detection = await detectManifest(installDir);
|
|
294
|
-
if ('error' in detection) {
|
|
295
|
-
cleanupDir(installDir);
|
|
296
|
-
console.error(`Manifest detection failed: ${detection.error}`);
|
|
297
|
-
process.exit(1);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const { manifest, source } = detection;
|
|
301
|
-
console.log(`Detected ${manifest.type} manifest from ${source}`);
|
|
302
|
-
|
|
303
|
-
// Validate
|
|
304
|
-
const validation = validateManifest(manifest, installDir);
|
|
305
|
-
for (const w of validation.warnings) {
|
|
306
|
-
console.log(` Warning: ${w}`);
|
|
307
|
-
}
|
|
308
|
-
if (!validation.valid) {
|
|
309
|
-
cleanupDir(installDir);
|
|
310
|
-
console.error(`Validation failed:\n${validation.errors.map((e) => ` - ${e}`).join('\n')}`);
|
|
311
|
-
process.exit(1);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// Register in app_store
|
|
315
|
-
try {
|
|
316
|
-
await registerItemInStore({
|
|
317
|
-
name: manifest.name,
|
|
318
|
-
itemType: manifest.type,
|
|
319
|
-
version: manifest.version,
|
|
320
|
-
description: manifest.description,
|
|
321
|
-
authorName: manifest.author?.name,
|
|
322
|
-
authorUrl: manifest.author?.url,
|
|
323
|
-
gitUrl: parsed.url,
|
|
324
|
-
installPath: installDir,
|
|
325
|
-
manifest: manifest as unknown as Record<string, unknown>,
|
|
326
|
-
tags: manifest.tags,
|
|
327
|
-
category: manifest.category,
|
|
328
|
-
license: manifest.license,
|
|
329
|
-
dependencies: manifest.dependencies,
|
|
330
|
-
});
|
|
331
|
-
} catch (err) {
|
|
332
|
-
cleanupDir(installDir);
|
|
333
|
-
console.error(`Registration failed: ${(err as Error).message}`);
|
|
334
|
-
process.exit(1);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// Type-specific registration
|
|
338
|
-
await registerByType(manifest, installDir);
|
|
339
|
-
|
|
340
|
-
// Audit
|
|
341
|
-
recordAuditEvent('item', manifest.name, 'item_installed', getActor(), {
|
|
342
|
-
type: manifest.type,
|
|
343
|
-
version: manifest.version,
|
|
344
|
-
source: parsed.url,
|
|
345
|
-
manifestSource: source,
|
|
346
|
-
}).catch(() => {});
|
|
347
|
-
|
|
348
|
-
console.log(`\nInstalled ${manifest.type} "${manifest.name}" v${manifest.version}`);
|
|
349
|
-
console.log(` Source: ${parsed.url}`);
|
|
350
|
-
console.log(` Path: ${installDir}`);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// ============================================================================
|
|
354
|
-
// Command registration
|
|
355
|
-
// ============================================================================
|
|
356
|
-
|
|
357
|
-
export function registerInstallCommand(program: Command): void {
|
|
358
|
-
program
|
|
359
|
-
.command('install <target>')
|
|
360
|
-
.description('Install a genie item from a git URL (e.g. github.com/user/repo[@version])')
|
|
361
|
-
.option('--force', 'Override existing item with same name')
|
|
362
|
-
.option('--full', 'Full git clone instead of shallow')
|
|
363
|
-
.action(async (target: string, options: InstallOptions) => {
|
|
364
|
-
try {
|
|
365
|
-
await handleInstall(target, options);
|
|
366
|
-
} catch (error) {
|
|
367
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
368
|
-
console.error(`Error: ${message}`);
|
|
369
|
-
process.exit(1);
|
|
370
|
-
}
|
|
371
|
-
});
|
|
372
|
-
}
|