@skillsgate/tui 0.1.1
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/bin/skillsgate-tui +28 -0
- package/bunfig.toml +3 -0
- package/package.json +24 -0
- package/src/app.tsx +18 -0
- package/src/components/agent-filter.tsx +162 -0
- package/src/components/confirm-dialog.tsx +56 -0
- package/src/components/help-overlay.tsx +101 -0
- package/src/components/layout.tsx +272 -0
- package/src/components/search-input.tsx +48 -0
- package/src/components/skill-list-item.tsx +45 -0
- package/src/components/skill-list.tsx +245 -0
- package/src/components/status-bar.tsx +34 -0
- package/src/data/api-client.ts +151 -0
- package/src/data/use-agents.ts +41 -0
- package/src/data/use-auth.ts +136 -0
- package/src/data/use-favorites.ts +147 -0
- package/src/data/use-installed-skills.ts +128 -0
- package/src/data/use-search.ts +118 -0
- package/src/data/use-skill-actions.ts +333 -0
- package/src/db/context.tsx +38 -0
- package/src/db/index.ts +19 -0
- package/src/db/migrations.ts +72 -0
- package/src/db/servers.ts +154 -0
- package/src/db/settings.ts +43 -0
- package/src/db/skills.ts +138 -0
- package/src/db/ssh.ts +319 -0
- package/src/index.tsx +37 -0
- package/src/store/context.tsx +26 -0
- package/src/store/reducers.ts +126 -0
- package/src/store/types.ts +124 -0
- package/src/utils/colors.ts +42 -0
- package/src/views/add-server.tsx +240 -0
- package/src/views/discover.tsx +419 -0
- package/src/views/favorites.tsx +358 -0
- package/src/views/home.tsx +218 -0
- package/src/views/login.tsx +202 -0
- package/src/views/server-skills.tsx +269 -0
- package/src/views/servers.tsx +449 -0
- package/src/views/settings.tsx +185 -0
- package/src/views/skill-detail.tsx +497 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { useCallback } from "react"
|
|
2
|
+
import { useStore, useDispatch } from "../store/context.js"
|
|
3
|
+
import type { EnrichedSkill } from "../store/types.js"
|
|
4
|
+
|
|
5
|
+
// CLI core imports -- these share the same Bun runtime
|
|
6
|
+
import { parseSource } from "../../../cli/src/core/source-parser.js"
|
|
7
|
+
import { cloneRepo, cleanupTempDir, fetchTreeSha } from "../../../cli/src/core/git.js"
|
|
8
|
+
import { discoverSkills } from "../../../cli/src/core/skill-discovery.js"
|
|
9
|
+
import {
|
|
10
|
+
installSkillForAgent,
|
|
11
|
+
removeSkillFromAgent,
|
|
12
|
+
removeCanonicalSkill,
|
|
13
|
+
sanitizeName,
|
|
14
|
+
} from "../../../cli/src/core/installer.js"
|
|
15
|
+
import {
|
|
16
|
+
addSkillToLock,
|
|
17
|
+
removeSkillFromLock,
|
|
18
|
+
readSkillLock,
|
|
19
|
+
} from "../../../cli/src/core/skill-lock.js"
|
|
20
|
+
import { agents, detectInstalledAgents } from "../../../cli/src/core/agents.js"
|
|
21
|
+
import { downloadSkill } from "../../../cli/src/core/skillsgate-client.js"
|
|
22
|
+
import { getToken } from "../../../cli/src/utils/auth-store.js"
|
|
23
|
+
import type { Skill, AgentConfig, ParsedSource } from "../../../cli/src/types.js"
|
|
24
|
+
|
|
25
|
+
interface UseSkillActionsResult {
|
|
26
|
+
installSkill: (skill: EnrichedSkill) => Promise<void>
|
|
27
|
+
removeSkill: (skill: EnrichedSkill) => Promise<void>
|
|
28
|
+
removeSkillFromOneAgent: (skill: EnrichedSkill, agentName: string) => Promise<void>
|
|
29
|
+
updateSkill: (skill: EnrichedSkill) => Promise<void>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Provides install, remove, and update actions for skills.
|
|
34
|
+
* Uses CLI core modules directly since they share the same Bun runtime.
|
|
35
|
+
*/
|
|
36
|
+
export function useSkillActions(): UseSkillActionsResult {
|
|
37
|
+
const state = useStore()
|
|
38
|
+
const dispatch = useDispatch()
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Install a skill from its source (GitHub URL, SkillsGate slug, or install command).
|
|
42
|
+
*/
|
|
43
|
+
const installSkill = useCallback(async (skill: EnrichedSkill) => {
|
|
44
|
+
dispatch({
|
|
45
|
+
type: "SHOW_NOTIFICATION",
|
|
46
|
+
notification: { type: "info", message: `Installing "${skill.name}"...` },
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
// Determine the source from metadata or lock entry
|
|
51
|
+
const sourceStr = resolveSource(skill)
|
|
52
|
+
if (!sourceStr) {
|
|
53
|
+
dispatch({
|
|
54
|
+
type: "SHOW_NOTIFICATION",
|
|
55
|
+
notification: { type: "error", message: `Cannot determine source for "${skill.name}"` },
|
|
56
|
+
})
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const source = parseSource(sourceStr)
|
|
61
|
+
|
|
62
|
+
// Detect installed agents to install to
|
|
63
|
+
const installedAgents = await detectInstalledAgents()
|
|
64
|
+
if (installedAgents.length === 0) {
|
|
65
|
+
dispatch({
|
|
66
|
+
type: "SHOW_NOTIFICATION",
|
|
67
|
+
notification: { type: "error", message: "No AI agents detected on this system" },
|
|
68
|
+
})
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let tmpDir: string
|
|
73
|
+
if (source.type === "skillsgate") {
|
|
74
|
+
// Download from SkillsGate API
|
|
75
|
+
const token = await getToken()
|
|
76
|
+
tmpDir = await downloadSkill(source.username!, source.slug!, token)
|
|
77
|
+
} else if (source.type === "github") {
|
|
78
|
+
// Clone from GitHub
|
|
79
|
+
tmpDir = await cloneRepo(source)
|
|
80
|
+
} else {
|
|
81
|
+
// Local path -- use directly
|
|
82
|
+
tmpDir = source.localPath!
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
// Discover skills in the cloned/downloaded directory
|
|
87
|
+
const skills = await discoverSkills(tmpDir, source.subpath)
|
|
88
|
+
|
|
89
|
+
if (skills.length === 0) {
|
|
90
|
+
dispatch({
|
|
91
|
+
type: "SHOW_NOTIFICATION",
|
|
92
|
+
notification: { type: "error", message: `No skills found in "${sourceStr}"` },
|
|
93
|
+
})
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Filter if a specific skill was requested
|
|
98
|
+
let targetSkills = skills
|
|
99
|
+
if (source.skillFilter) {
|
|
100
|
+
const filter = source.skillFilter.toLowerCase()
|
|
101
|
+
targetSkills = skills.filter((s) => s.name.toLowerCase() === filter)
|
|
102
|
+
if (targetSkills.length === 0) {
|
|
103
|
+
dispatch({
|
|
104
|
+
type: "SHOW_NOTIFICATION",
|
|
105
|
+
notification: { type: "error", message: `Skill "${source.skillFilter}" not found in "${sourceStr}"` },
|
|
106
|
+
})
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let installedCount = 0
|
|
112
|
+
|
|
113
|
+
for (const skillToInstall of targetSkills) {
|
|
114
|
+
// Install to all detected agents
|
|
115
|
+
for (const agent of installedAgents) {
|
|
116
|
+
const result = await installSkillForAgent(
|
|
117
|
+
skillToInstall,
|
|
118
|
+
agent,
|
|
119
|
+
"global",
|
|
120
|
+
"symlink",
|
|
121
|
+
)
|
|
122
|
+
if (result.success) {
|
|
123
|
+
installedCount++
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Update lock file
|
|
128
|
+
await addSkillToLock(sanitizeName(skillToInstall.name), {
|
|
129
|
+
source: sourceStr,
|
|
130
|
+
sourceType: source.type,
|
|
131
|
+
originalUrl: source.url,
|
|
132
|
+
skillFolderHash: "",
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Trigger refresh
|
|
137
|
+
dispatch({ type: "REFRESH_SKILLS" })
|
|
138
|
+
|
|
139
|
+
const skillNames = targetSkills.map((s) => s.name).join(", ")
|
|
140
|
+
dispatch({
|
|
141
|
+
type: "SHOW_NOTIFICATION",
|
|
142
|
+
notification: {
|
|
143
|
+
type: "success",
|
|
144
|
+
message: `Installed ${targetSkills.length} skill(s): ${skillNames} to ${installedAgents.length} agent(s)`,
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
} finally {
|
|
148
|
+
// Clean up temp directory (only if it was a temp clone/download)
|
|
149
|
+
if (source.type !== "local") {
|
|
150
|
+
await cleanupTempDir(tmpDir)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
} catch (err) {
|
|
154
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
155
|
+
dispatch({
|
|
156
|
+
type: "SHOW_NOTIFICATION",
|
|
157
|
+
notification: { type: "error", message: `Install failed: ${msg}` },
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
}, [dispatch])
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Remove a skill from all agents and the lock file.
|
|
164
|
+
*/
|
|
165
|
+
const removeSkill = useCallback(async (skill: EnrichedSkill) => {
|
|
166
|
+
dispatch({
|
|
167
|
+
type: "SHOW_NOTIFICATION",
|
|
168
|
+
notification: { type: "info", message: `Removing "${skill.name}"...` },
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const safeName = sanitizeName(skill.name)
|
|
173
|
+
|
|
174
|
+
// Remove from all agent directories
|
|
175
|
+
const installedAgents = await detectInstalledAgents()
|
|
176
|
+
for (const agent of installedAgents) {
|
|
177
|
+
await removeSkillFromAgent(safeName, agent, "global")
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Remove canonical copy
|
|
181
|
+
await removeCanonicalSkill(skill.name)
|
|
182
|
+
|
|
183
|
+
// Remove from lock file
|
|
184
|
+
await removeSkillFromLock(safeName)
|
|
185
|
+
|
|
186
|
+
// Trigger refresh
|
|
187
|
+
dispatch({ type: "REFRESH_SKILLS" })
|
|
188
|
+
|
|
189
|
+
dispatch({
|
|
190
|
+
type: "SHOW_NOTIFICATION",
|
|
191
|
+
notification: { type: "success", message: `Removed "${skill.name}"` },
|
|
192
|
+
})
|
|
193
|
+
} catch (err) {
|
|
194
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
195
|
+
dispatch({
|
|
196
|
+
type: "SHOW_NOTIFICATION",
|
|
197
|
+
notification: { type: "error", message: `Remove failed: ${msg}` },
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
}, [dispatch])
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Remove a skill from a single agent only (delete its symlink).
|
|
204
|
+
* If this was the last agent, also removes the canonical copy and lock entry.
|
|
205
|
+
*/
|
|
206
|
+
const removeSkillFromOneAgent = useCallback(async (skill: EnrichedSkill, agentName: string) => {
|
|
207
|
+
const agent = agents[agentName]
|
|
208
|
+
if (!agent) {
|
|
209
|
+
dispatch({
|
|
210
|
+
type: "SHOW_NOTIFICATION",
|
|
211
|
+
notification: { type: "error", message: `Unknown agent: ${agentName}` },
|
|
212
|
+
})
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
dispatch({
|
|
217
|
+
type: "SHOW_NOTIFICATION",
|
|
218
|
+
notification: { type: "info", message: `Removing "${skill.name}" from ${agent.displayName}...` },
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const safeName = sanitizeName(skill.name)
|
|
223
|
+
await removeSkillFromAgent(safeName, agent, "global")
|
|
224
|
+
|
|
225
|
+
// If this was the last agent, also remove canonical + lock
|
|
226
|
+
const remainingAgents = skill.agents.filter(a => a !== agentName)
|
|
227
|
+
if (remainingAgents.length === 0) {
|
|
228
|
+
await removeCanonicalSkill(skill.name)
|
|
229
|
+
await removeSkillFromLock(safeName)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
dispatch({ type: "REFRESH_SKILLS" })
|
|
233
|
+
dispatch({
|
|
234
|
+
type: "SHOW_NOTIFICATION",
|
|
235
|
+
notification: {
|
|
236
|
+
type: "success",
|
|
237
|
+
message: remainingAgents.length > 0
|
|
238
|
+
? `Removed "${skill.name}" from ${agent.displayName}`
|
|
239
|
+
: `Removed "${skill.name}" completely`,
|
|
240
|
+
},
|
|
241
|
+
})
|
|
242
|
+
} catch (err) {
|
|
243
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
244
|
+
dispatch({
|
|
245
|
+
type: "SHOW_NOTIFICATION",
|
|
246
|
+
notification: { type: "error", message: `Remove failed: ${msg}` },
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
}, [dispatch])
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Update a skill by re-fetching from its source.
|
|
253
|
+
* For GitHub skills: checks tree SHA for changes before re-installing.
|
|
254
|
+
* For SkillsGate skills: always re-downloads.
|
|
255
|
+
*/
|
|
256
|
+
const updateSkill = useCallback(async (skill: EnrichedSkill) => {
|
|
257
|
+
if (!skill.lock) {
|
|
258
|
+
dispatch({
|
|
259
|
+
type: "SHOW_NOTIFICATION",
|
|
260
|
+
notification: { type: "error", message: `No source information for "${skill.name}"` },
|
|
261
|
+
})
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
dispatch({
|
|
266
|
+
type: "SHOW_NOTIFICATION",
|
|
267
|
+
notification: { type: "info", message: `Checking for updates to "${skill.name}"...` },
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const source = parseSource(skill.lock.source)
|
|
272
|
+
|
|
273
|
+
if (source.type === "github") {
|
|
274
|
+
// Check if the tree SHA has changed
|
|
275
|
+
const newSha = await fetchTreeSha(source.owner, source.repo, "")
|
|
276
|
+
if (newSha && newSha === skill.lock.skillFolderHash) {
|
|
277
|
+
dispatch({
|
|
278
|
+
type: "SHOW_NOTIFICATION",
|
|
279
|
+
notification: { type: "info", message: `"${skill.name}" is already up to date` },
|
|
280
|
+
})
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Re-install (reuses the install flow)
|
|
286
|
+
await installSkill(skill)
|
|
287
|
+
|
|
288
|
+
dispatch({
|
|
289
|
+
type: "SHOW_NOTIFICATION",
|
|
290
|
+
notification: { type: "success", message: `Updated "${skill.name}"` },
|
|
291
|
+
})
|
|
292
|
+
} catch (err) {
|
|
293
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
294
|
+
dispatch({
|
|
295
|
+
type: "SHOW_NOTIFICATION",
|
|
296
|
+
notification: { type: "error", message: `Update failed: ${msg}` },
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
}, [dispatch, installSkill])
|
|
300
|
+
|
|
301
|
+
return { installSkill, removeSkill, removeSkillFromOneAgent, updateSkill }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---------- Helpers ----------
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Resolves the source string for a skill from its metadata.
|
|
308
|
+
* Checks: lock.source, metadata.githubUrl, metadata.installCommand
|
|
309
|
+
*/
|
|
310
|
+
function resolveSource(skill: EnrichedSkill): string | null {
|
|
311
|
+
// From lock file entry
|
|
312
|
+
if (skill.lock?.source) {
|
|
313
|
+
return skill.lock.source
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// From metadata (catalog skills)
|
|
317
|
+
const meta = skill.metadata
|
|
318
|
+
if (meta?.githubUrl && typeof meta.githubUrl === "string") {
|
|
319
|
+
return meta.githubUrl
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// From install command (e.g. "skillsgate add @user/slug")
|
|
323
|
+
if (meta?.installCommand && typeof meta.installCommand === "string") {
|
|
324
|
+
const cmd = meta.installCommand as string
|
|
325
|
+
// Extract the source from "skillsgate add <source>" or "skillsgate install <source>"
|
|
326
|
+
const match = cmd.match(/skillsgate\s+(?:add|install)\s+(.+)/)
|
|
327
|
+
if (match) {
|
|
328
|
+
return match[1].trim()
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return null
|
|
333
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react"
|
|
2
|
+
import type { Database } from "bun:sqlite"
|
|
3
|
+
import { SettingsStore } from "./settings.js"
|
|
4
|
+
import { RemoteServerStore } from "./servers.js"
|
|
5
|
+
import { RemoteSkillStore } from "./skills.js"
|
|
6
|
+
|
|
7
|
+
export interface DbContext {
|
|
8
|
+
db: Database
|
|
9
|
+
settings: SettingsStore
|
|
10
|
+
servers: RemoteServerStore
|
|
11
|
+
skills: RemoteSkillStore
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DbCtx = createContext<DbContext | null>(null)
|
|
15
|
+
|
|
16
|
+
interface DbProviderProps {
|
|
17
|
+
db: Database
|
|
18
|
+
children: ReactNode
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function DbProvider({ db, children }: DbProviderProps) {
|
|
22
|
+
const ctx: DbContext = {
|
|
23
|
+
db,
|
|
24
|
+
settings: new SettingsStore(db),
|
|
25
|
+
servers: new RemoteServerStore(db),
|
|
26
|
+
skills: new RemoteSkillStore(db),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return <DbCtx.Provider value={ctx}>{children}</DbCtx.Provider>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function useDb(): DbContext {
|
|
33
|
+
const ctx = useContext(DbCtx)
|
|
34
|
+
if (!ctx) {
|
|
35
|
+
throw new Error("useDb must be used within a DbProvider")
|
|
36
|
+
}
|
|
37
|
+
return ctx
|
|
38
|
+
}
|
package/src/db/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite"
|
|
2
|
+
import os from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import fs from "node:fs"
|
|
5
|
+
import { runMigrations } from "./migrations.js"
|
|
6
|
+
|
|
7
|
+
const DB_DIR = path.join(os.homedir(), ".skillsgate")
|
|
8
|
+
const DB_PATH = path.join(DB_DIR, "skillsgate.db")
|
|
9
|
+
|
|
10
|
+
export function openDb(): Database {
|
|
11
|
+
fs.mkdirSync(DB_DIR, { recursive: true })
|
|
12
|
+
const db = new Database(DB_PATH)
|
|
13
|
+
db.exec("PRAGMA journal_mode=WAL")
|
|
14
|
+
db.exec("PRAGMA foreign_keys=ON")
|
|
15
|
+
runMigrations(db)
|
|
16
|
+
return db
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type { Database } from "bun:sqlite"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
|
|
3
|
+
interface Migration {
|
|
4
|
+
version: number
|
|
5
|
+
up: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const MIGRATIONS: Migration[] = [
|
|
9
|
+
{
|
|
10
|
+
version: 1,
|
|
11
|
+
up: `
|
|
12
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
13
|
+
version INTEGER PRIMARY KEY
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
17
|
+
key TEXT PRIMARY KEY,
|
|
18
|
+
value TEXT NOT NULL,
|
|
19
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
CREATE TABLE IF NOT EXISTS remote_servers (
|
|
23
|
+
id TEXT PRIMARY KEY,
|
|
24
|
+
label TEXT NOT NULL,
|
|
25
|
+
host TEXT NOT NULL,
|
|
26
|
+
port INTEGER NOT NULL DEFAULT 22,
|
|
27
|
+
username TEXT NOT NULL,
|
|
28
|
+
skills_base_path TEXT NOT NULL DEFAULT '~/.agents/skills',
|
|
29
|
+
ssh_key_path TEXT,
|
|
30
|
+
last_sync_at TEXT,
|
|
31
|
+
last_sync_error TEXT,
|
|
32
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
33
|
+
UNIQUE(host, port, username)
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
CREATE TABLE IF NOT EXISTS remote_skills (
|
|
37
|
+
id TEXT PRIMARY KEY,
|
|
38
|
+
server_id TEXT NOT NULL REFERENCES remote_servers(id) ON DELETE CASCADE,
|
|
39
|
+
name TEXT NOT NULL,
|
|
40
|
+
description TEXT,
|
|
41
|
+
remote_path TEXT NOT NULL,
|
|
42
|
+
content TEXT,
|
|
43
|
+
content_hash TEXT,
|
|
44
|
+
synced_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
45
|
+
UNIQUE(server_id, remote_path)
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_remote_skills_server ON remote_skills(server_id);
|
|
49
|
+
|
|
50
|
+
INSERT OR IGNORE INTO schema_version VALUES (1);
|
|
51
|
+
`,
|
|
52
|
+
},
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
function getCurrentVersion(db: Database): number {
|
|
56
|
+
try {
|
|
57
|
+
const row = db.query("SELECT MAX(version) as v FROM schema_version").get() as { v: number | null } | null
|
|
58
|
+
return row?.v ?? 0
|
|
59
|
+
} catch {
|
|
60
|
+
// Table doesn't exist yet
|
|
61
|
+
return 0
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function runMigrations(db: Database): void {
|
|
66
|
+
const current = getCurrentVersion(db)
|
|
67
|
+
for (const migration of MIGRATIONS) {
|
|
68
|
+
if (migration.version > current) {
|
|
69
|
+
db.exec(migration.up)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
import crypto from "node:crypto"
|
|
3
|
+
|
|
4
|
+
export interface RemoteServer {
|
|
5
|
+
id: string
|
|
6
|
+
label: string
|
|
7
|
+
host: string
|
|
8
|
+
port: number
|
|
9
|
+
username: string
|
|
10
|
+
skillsBasePath: string
|
|
11
|
+
sshKeyPath: string | null
|
|
12
|
+
lastSyncAt: string | null
|
|
13
|
+
lastSyncError: string | null
|
|
14
|
+
createdAt: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ServerRow {
|
|
18
|
+
id: string
|
|
19
|
+
label: string
|
|
20
|
+
host: string
|
|
21
|
+
port: number
|
|
22
|
+
username: string
|
|
23
|
+
skills_base_path: string
|
|
24
|
+
ssh_key_path: string | null
|
|
25
|
+
last_sync_at: string | null
|
|
26
|
+
last_sync_error: string | null
|
|
27
|
+
created_at: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function rowToServer(row: ServerRow): RemoteServer {
|
|
31
|
+
return {
|
|
32
|
+
id: row.id,
|
|
33
|
+
label: row.label,
|
|
34
|
+
host: row.host,
|
|
35
|
+
port: row.port,
|
|
36
|
+
username: row.username,
|
|
37
|
+
skillsBasePath: row.skills_base_path,
|
|
38
|
+
sshKeyPath: row.ssh_key_path,
|
|
39
|
+
lastSyncAt: row.last_sync_at,
|
|
40
|
+
lastSyncError: row.last_sync_error,
|
|
41
|
+
createdAt: row.created_at,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type CreateServerInput = {
|
|
46
|
+
label: string
|
|
47
|
+
host: string
|
|
48
|
+
port: number
|
|
49
|
+
username: string
|
|
50
|
+
skillsBasePath: string
|
|
51
|
+
sshKeyPath?: string | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class RemoteServerStore {
|
|
55
|
+
private db: Database
|
|
56
|
+
|
|
57
|
+
constructor(db: Database) {
|
|
58
|
+
this.db = db
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
list(): RemoteServer[] {
|
|
62
|
+
const rows = this.db
|
|
63
|
+
.query("SELECT * FROM remote_servers ORDER BY label ASC")
|
|
64
|
+
.all() as ServerRow[]
|
|
65
|
+
return rows.map(rowToServer)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get(id: string): RemoteServer | null {
|
|
69
|
+
const row = this.db
|
|
70
|
+
.query("SELECT * FROM remote_servers WHERE id = ?")
|
|
71
|
+
.get(id) as ServerRow | null
|
|
72
|
+
return row ? rowToServer(row) : null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
create(input: CreateServerInput): RemoteServer {
|
|
76
|
+
const id = crypto.randomUUID()
|
|
77
|
+
this.db
|
|
78
|
+
.query(
|
|
79
|
+
`INSERT INTO remote_servers (id, label, host, port, username, skills_base_path, ssh_key_path)
|
|
80
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
81
|
+
)
|
|
82
|
+
.run(
|
|
83
|
+
id,
|
|
84
|
+
input.label,
|
|
85
|
+
input.host,
|
|
86
|
+
input.port,
|
|
87
|
+
input.username,
|
|
88
|
+
input.skillsBasePath,
|
|
89
|
+
input.sshKeyPath ?? null,
|
|
90
|
+
)
|
|
91
|
+
return this.get(id)!
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
update(id: string, fields: Partial<CreateServerInput>): void {
|
|
95
|
+
const sets: string[] = []
|
|
96
|
+
const params: unknown[] = []
|
|
97
|
+
|
|
98
|
+
if (fields.label !== undefined) {
|
|
99
|
+
sets.push("label = ?")
|
|
100
|
+
params.push(fields.label)
|
|
101
|
+
}
|
|
102
|
+
if (fields.host !== undefined) {
|
|
103
|
+
sets.push("host = ?")
|
|
104
|
+
params.push(fields.host)
|
|
105
|
+
}
|
|
106
|
+
if (fields.port !== undefined) {
|
|
107
|
+
sets.push("port = ?")
|
|
108
|
+
params.push(fields.port)
|
|
109
|
+
}
|
|
110
|
+
if (fields.username !== undefined) {
|
|
111
|
+
sets.push("username = ?")
|
|
112
|
+
params.push(fields.username)
|
|
113
|
+
}
|
|
114
|
+
if (fields.skillsBasePath !== undefined) {
|
|
115
|
+
sets.push("skills_base_path = ?")
|
|
116
|
+
params.push(fields.skillsBasePath)
|
|
117
|
+
}
|
|
118
|
+
if (fields.sshKeyPath !== undefined) {
|
|
119
|
+
sets.push("ssh_key_path = ?")
|
|
120
|
+
params.push(fields.sshKeyPath)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (sets.length === 0) return
|
|
124
|
+
|
|
125
|
+
params.push(id)
|
|
126
|
+
this.db.query(`UPDATE remote_servers SET ${sets.join(", ")} WHERE id = ?`).run(...params)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
delete(id: string): void {
|
|
130
|
+
this.db.query("DELETE FROM remote_servers WHERE id = ?").run(id)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
updateSyncStatus(id: string, error: string | null): void {
|
|
134
|
+
if (error) {
|
|
135
|
+
this.db
|
|
136
|
+
.query("UPDATE remote_servers SET last_sync_error = ? WHERE id = ?")
|
|
137
|
+
.run(error, id)
|
|
138
|
+
} else {
|
|
139
|
+
this.db
|
|
140
|
+
.query(
|
|
141
|
+
"UPDATE remote_servers SET last_sync_at = datetime('now'), last_sync_error = NULL WHERE id = ?"
|
|
142
|
+
)
|
|
143
|
+
.run(id)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Returns the count of remote skills for a given server. */
|
|
148
|
+
skillCount(serverId: string): number {
|
|
149
|
+
const row = this.db
|
|
150
|
+
.query("SELECT COUNT(*) as cnt FROM remote_skills WHERE server_id = ?")
|
|
151
|
+
.get(serverId) as { cnt: number } | null
|
|
152
|
+
return row?.cnt ?? 0
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
|
|
3
|
+
export class SettingsStore {
|
|
4
|
+
private db: Database
|
|
5
|
+
|
|
6
|
+
constructor(db: Database) {
|
|
7
|
+
this.db = db
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
get<T>(key: string, defaultValue: T): T {
|
|
11
|
+
const row = this.db.query("SELECT value FROM settings WHERE key = ?").get(key) as { value: string } | null
|
|
12
|
+
if (!row) return defaultValue
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(row.value) as T
|
|
15
|
+
} catch {
|
|
16
|
+
return defaultValue
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
set<T>(key: string, value: T): void {
|
|
21
|
+
this.db
|
|
22
|
+
.query(
|
|
23
|
+
`INSERT INTO settings (key, value, updated_at)
|
|
24
|
+
VALUES (?, ?, datetime('now'))
|
|
25
|
+
ON CONFLICT(key)
|
|
26
|
+
DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`
|
|
27
|
+
)
|
|
28
|
+
.run(key, JSON.stringify(value))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
getAll(): Record<string, unknown> {
|
|
32
|
+
const rows = this.db.query("SELECT key, value FROM settings").all() as { key: string; value: string }[]
|
|
33
|
+
const result: Record<string, unknown> = {}
|
|
34
|
+
for (const row of rows) {
|
|
35
|
+
try {
|
|
36
|
+
result[row.key] = JSON.parse(row.value)
|
|
37
|
+
} catch {
|
|
38
|
+
result[row.key] = row.value
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return result
|
|
42
|
+
}
|
|
43
|
+
}
|