kokoirc 0.2.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/README.md +227 -0
- package/docs/commands/alias.md +42 -0
- package/docs/commands/ban.md +26 -0
- package/docs/commands/close.md +25 -0
- package/docs/commands/connect.md +26 -0
- package/docs/commands/deop.md +24 -0
- package/docs/commands/devoice.md +24 -0
- package/docs/commands/disconnect.md +26 -0
- package/docs/commands/help.md +28 -0
- package/docs/commands/ignore.md +47 -0
- package/docs/commands/items.md +95 -0
- package/docs/commands/join.md +25 -0
- package/docs/commands/kb.md +26 -0
- package/docs/commands/kick.md +25 -0
- package/docs/commands/log.md +82 -0
- package/docs/commands/me.md +24 -0
- package/docs/commands/mode.md +29 -0
- package/docs/commands/msg.md +26 -0
- package/docs/commands/nick.md +24 -0
- package/docs/commands/notice.md +24 -0
- package/docs/commands/op.md +24 -0
- package/docs/commands/part.md +25 -0
- package/docs/commands/quit.md +24 -0
- package/docs/commands/reload.md +19 -0
- package/docs/commands/script.md +126 -0
- package/docs/commands/server.md +61 -0
- package/docs/commands/set.md +37 -0
- package/docs/commands/topic.md +24 -0
- package/docs/commands/unalias.md +22 -0
- package/docs/commands/unban.md +25 -0
- package/docs/commands/unignore.md +25 -0
- package/docs/commands/voice.md +25 -0
- package/docs/commands/whois.md +24 -0
- package/docs/commands/wii.md +23 -0
- package/package.json +38 -0
- package/src/app/App.tsx +205 -0
- package/src/core/commands/docs.ts +183 -0
- package/src/core/commands/execution.ts +114 -0
- package/src/core/commands/help-formatter.ts +185 -0
- package/src/core/commands/helpers.ts +168 -0
- package/src/core/commands/index.ts +7 -0
- package/src/core/commands/parser.ts +33 -0
- package/src/core/commands/registry.ts +1394 -0
- package/src/core/commands/types.ts +19 -0
- package/src/core/config/defaults.ts +66 -0
- package/src/core/config/loader.ts +209 -0
- package/src/core/constants.ts +20 -0
- package/src/core/init.ts +32 -0
- package/src/core/irc/antiflood.ts +244 -0
- package/src/core/irc/client.ts +145 -0
- package/src/core/irc/events.ts +1031 -0
- package/src/core/irc/formatting.ts +132 -0
- package/src/core/irc/ignore.ts +84 -0
- package/src/core/irc/index.ts +2 -0
- package/src/core/irc/netsplit.ts +292 -0
- package/src/core/scripts/api.ts +240 -0
- package/src/core/scripts/event-bus.ts +82 -0
- package/src/core/scripts/index.ts +26 -0
- package/src/core/scripts/manager.ts +154 -0
- package/src/core/scripts/types.ts +256 -0
- package/src/core/state/selectors.ts +39 -0
- package/src/core/state/sorting.ts +30 -0
- package/src/core/state/store.ts +242 -0
- package/src/core/storage/crypto.ts +78 -0
- package/src/core/storage/db.ts +107 -0
- package/src/core/storage/index.ts +80 -0
- package/src/core/storage/query.ts +204 -0
- package/src/core/storage/types.ts +37 -0
- package/src/core/storage/writer.ts +130 -0
- package/src/core/theme/index.ts +3 -0
- package/src/core/theme/loader.ts +45 -0
- package/src/core/theme/parser.ts +518 -0
- package/src/core/theme/renderer.tsx +25 -0
- package/src/index.tsx +17 -0
- package/src/types/config.ts +126 -0
- package/src/types/index.ts +107 -0
- package/src/types/irc-framework.d.ts +569 -0
- package/src/types/theme.ts +37 -0
- package/src/ui/ErrorBoundary.tsx +42 -0
- package/src/ui/chat/ChatView.tsx +39 -0
- package/src/ui/chat/MessageLine.tsx +92 -0
- package/src/ui/hooks/useStatusbarColors.ts +23 -0
- package/src/ui/input/CommandInput.tsx +273 -0
- package/src/ui/layout/AppLayout.tsx +126 -0
- package/src/ui/layout/TopicBar.tsx +46 -0
- package/src/ui/sidebar/BufferList.tsx +55 -0
- package/src/ui/sidebar/NickList.tsx +96 -0
- package/src/ui/splash/SplashScreen.tsx +100 -0
- package/src/ui/statusbar/StatusLine.tsx +205 -0
- package/themes/.gitkeep +0 -0
- package/themes/default.theme +57 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { useStore } from "@/core/state/store"
|
|
2
|
+
import { getClient } from "@/core/irc"
|
|
3
|
+
import { makeBufferId } from "@/types"
|
|
4
|
+
import { eventBus } from "./event-bus"
|
|
5
|
+
import { EventPriority } from "./types"
|
|
6
|
+
import type {
|
|
7
|
+
KokoAPI,
|
|
8
|
+
ScriptMeta,
|
|
9
|
+
ScriptCommandDef,
|
|
10
|
+
EventHandler,
|
|
11
|
+
TimerHandle,
|
|
12
|
+
StoreAccess,
|
|
13
|
+
IrcAccess,
|
|
14
|
+
UiAccess,
|
|
15
|
+
ScriptConfigAccess,
|
|
16
|
+
} from "./types"
|
|
17
|
+
|
|
18
|
+
/** Registry of script-defined commands. Checked in execution.ts between built-ins and aliases. */
|
|
19
|
+
export const scriptCommands = new Map<string, { def: ScriptCommandDef; owner: string }>()
|
|
20
|
+
|
|
21
|
+
export function createScriptAPI(meta: ScriptMeta, scriptDefaults: Record<string, any>): {
|
|
22
|
+
api: KokoAPI
|
|
23
|
+
cleanup: () => void
|
|
24
|
+
} {
|
|
25
|
+
const scriptName = meta.name
|
|
26
|
+
const unsubs: Array<() => void> = []
|
|
27
|
+
const timers: Array<TimerHandle> = []
|
|
28
|
+
const registeredCommands: string[] = []
|
|
29
|
+
|
|
30
|
+
// ─── Store Access ────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const store: StoreAccess = {
|
|
33
|
+
getConnections: () => useStore.getState().connections,
|
|
34
|
+
getBuffers: () => useStore.getState().buffers,
|
|
35
|
+
getActiveBufferId: () => useStore.getState().activeBufferId,
|
|
36
|
+
getConfig: () => useStore.getState().config,
|
|
37
|
+
getConnection: (id) => useStore.getState().connections.get(id),
|
|
38
|
+
getBuffer: (id) => useStore.getState().buffers.get(id),
|
|
39
|
+
subscribe: (listener) => useStore.subscribe(listener),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── IRC Access ──────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
function resolveConnId(explicit?: string): string | undefined {
|
|
45
|
+
if (explicit) return explicit
|
|
46
|
+
const s = useStore.getState()
|
|
47
|
+
const bufId = s.activeBufferId
|
|
48
|
+
if (!bufId) return undefined
|
|
49
|
+
const buf = s.buffers.get(bufId)
|
|
50
|
+
return buf?.connectionId
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const irc: IrcAccess = {
|
|
54
|
+
say(target, message, connectionId) {
|
|
55
|
+
const connId = resolveConnId(connectionId)
|
|
56
|
+
if (!connId) return
|
|
57
|
+
getClient(connId)?.say(target, message)
|
|
58
|
+
},
|
|
59
|
+
action(target, message, connectionId) {
|
|
60
|
+
const connId = resolveConnId(connectionId)
|
|
61
|
+
if (!connId) return
|
|
62
|
+
getClient(connId)?.action(target, message)
|
|
63
|
+
},
|
|
64
|
+
notice(target, message, connectionId) {
|
|
65
|
+
const connId = resolveConnId(connectionId)
|
|
66
|
+
if (!connId) return
|
|
67
|
+
getClient(connId)?.notice(target, message)
|
|
68
|
+
},
|
|
69
|
+
join(channel, key, connectionId) {
|
|
70
|
+
const connId = resolveConnId(connectionId)
|
|
71
|
+
if (!connId) return
|
|
72
|
+
getClient(connId)?.join(channel, key)
|
|
73
|
+
},
|
|
74
|
+
part(channel, message, connectionId) {
|
|
75
|
+
const connId = resolveConnId(connectionId)
|
|
76
|
+
if (!connId) return
|
|
77
|
+
getClient(connId)?.part(channel, message)
|
|
78
|
+
},
|
|
79
|
+
raw(line, connectionId) {
|
|
80
|
+
const connId = resolveConnId(connectionId)
|
|
81
|
+
if (!connId) return
|
|
82
|
+
getClient(connId)?.raw(line)
|
|
83
|
+
},
|
|
84
|
+
changeNick(nick, connectionId) {
|
|
85
|
+
const connId = resolveConnId(connectionId)
|
|
86
|
+
if (!connId) return
|
|
87
|
+
getClient(connId)?.changeNick(nick)
|
|
88
|
+
},
|
|
89
|
+
whois(nick, connectionId) {
|
|
90
|
+
const connId = resolveConnId(connectionId)
|
|
91
|
+
if (!connId) return
|
|
92
|
+
getClient(connId)?.whois(nick)
|
|
93
|
+
},
|
|
94
|
+
getClient(connectionId) {
|
|
95
|
+
const connId = resolveConnId(connectionId)
|
|
96
|
+
if (!connId) return undefined
|
|
97
|
+
return getClient(connId)
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── UI Access ───────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
const ui: UiAccess = {
|
|
104
|
+
addLocalEvent(text) {
|
|
105
|
+
const s = useStore.getState()
|
|
106
|
+
const buf = s.activeBufferId
|
|
107
|
+
if (!buf) return
|
|
108
|
+
s.addMessage(buf, {
|
|
109
|
+
id: crypto.randomUUID(),
|
|
110
|
+
timestamp: new Date(),
|
|
111
|
+
type: "event",
|
|
112
|
+
text,
|
|
113
|
+
highlight: false,
|
|
114
|
+
})
|
|
115
|
+
},
|
|
116
|
+
addMessage(bufferId, partial) {
|
|
117
|
+
useStore.getState().addMessage(bufferId, {
|
|
118
|
+
id: crypto.randomUUID(),
|
|
119
|
+
timestamp: new Date(),
|
|
120
|
+
...partial,
|
|
121
|
+
})
|
|
122
|
+
},
|
|
123
|
+
switchBuffer(bufferId) {
|
|
124
|
+
useStore.getState().setActiveBuffer(bufferId)
|
|
125
|
+
},
|
|
126
|
+
makeBufferId,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── Config Access ───────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
const config: ScriptConfigAccess = {
|
|
132
|
+
get<T = any>(key: string, defaultValue: T): T {
|
|
133
|
+
const appConfig = useStore.getState().config
|
|
134
|
+
const scriptConfig = (appConfig as any)?.scripts?.[scriptName]
|
|
135
|
+
if (scriptConfig && key in scriptConfig) return scriptConfig[key]
|
|
136
|
+
if (key in scriptDefaults) return scriptDefaults[key] as T
|
|
137
|
+
return defaultValue
|
|
138
|
+
},
|
|
139
|
+
set(key: string, value: any) {
|
|
140
|
+
const s = useStore.getState()
|
|
141
|
+
const appConfig = s.config
|
|
142
|
+
if (!appConfig) return
|
|
143
|
+
// Mutate scripts config in-place (same pattern as /set command)
|
|
144
|
+
if (!(appConfig as any).scripts) (appConfig as any).scripts = {}
|
|
145
|
+
if (!(appConfig as any).scripts[scriptName]) {
|
|
146
|
+
(appConfig as any).scripts[scriptName] = { ...scriptDefaults }
|
|
147
|
+
}
|
|
148
|
+
;(appConfig as any).scripts[scriptName][key] = value
|
|
149
|
+
s.setConfig({ ...appConfig })
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── KokoAPI ─────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
const api: KokoAPI = {
|
|
156
|
+
meta,
|
|
157
|
+
|
|
158
|
+
on(event, handler, priority = EventPriority.NORMAL) {
|
|
159
|
+
const unsub = eventBus.on(event, handler, priority, scriptName)
|
|
160
|
+
unsubs.push(unsub)
|
|
161
|
+
return unsub
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
once(event, handler, priority = EventPriority.NORMAL) {
|
|
165
|
+
const unsub = eventBus.once(event, handler, priority, scriptName)
|
|
166
|
+
unsubs.push(unsub)
|
|
167
|
+
return unsub
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
emit(event, data) {
|
|
171
|
+
return eventBus.emit(`script.${event}`, data)
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
command(name, def) {
|
|
175
|
+
const lower = name.toLowerCase()
|
|
176
|
+
scriptCommands.set(lower, { def, owner: scriptName })
|
|
177
|
+
registeredCommands.push(lower)
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
removeCommand(name) {
|
|
181
|
+
const lower = name.toLowerCase()
|
|
182
|
+
const entry = scriptCommands.get(lower)
|
|
183
|
+
if (entry?.owner === scriptName) {
|
|
184
|
+
scriptCommands.delete(lower)
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
timer(ms, handler) {
|
|
189
|
+
const id = setInterval(handler, ms)
|
|
190
|
+
const handle: TimerHandle = { clear: () => clearInterval(id) }
|
|
191
|
+
timers.push(handle)
|
|
192
|
+
return handle
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
timeout(ms, handler) {
|
|
196
|
+
const id = setTimeout(handler, ms)
|
|
197
|
+
const handle: TimerHandle = { clear: () => clearTimeout(id) }
|
|
198
|
+
timers.push(handle)
|
|
199
|
+
return handle
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
store,
|
|
203
|
+
irc,
|
|
204
|
+
ui,
|
|
205
|
+
config,
|
|
206
|
+
EventPriority,
|
|
207
|
+
|
|
208
|
+
log(...args) {
|
|
209
|
+
const appConfig = useStore.getState().config
|
|
210
|
+
const debug = (appConfig as any)?.scripts?.debug ?? false
|
|
211
|
+
if (debug) {
|
|
212
|
+
console.log(`[script:${scriptName}]`, ...args)
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ─── Cleanup ─────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
function cleanup() {
|
|
220
|
+
// Remove all event handlers
|
|
221
|
+
eventBus.removeAll(scriptName)
|
|
222
|
+
for (const unsub of unsubs) unsub()
|
|
223
|
+
unsubs.length = 0
|
|
224
|
+
|
|
225
|
+
// Clear all timers
|
|
226
|
+
for (const t of timers) t.clear()
|
|
227
|
+
timers.length = 0
|
|
228
|
+
|
|
229
|
+
// Remove all commands
|
|
230
|
+
for (const name of registeredCommands) {
|
|
231
|
+
const entry = scriptCommands.get(name)
|
|
232
|
+
if (entry?.owner === scriptName) {
|
|
233
|
+
scriptCommands.delete(name)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
registeredCommands.length = 0
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return { api, cleanup }
|
|
240
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { EventPriority } from "./types"
|
|
2
|
+
import type { EventHandler, EventRegistration, EventContext } from "./types"
|
|
3
|
+
|
|
4
|
+
export class EventBus {
|
|
5
|
+
private handlers: EventRegistration[] = []
|
|
6
|
+
|
|
7
|
+
/** Subscribe to an event. Returns an unsubscribe function. */
|
|
8
|
+
on(event: string, handler: EventHandler, priority = EventPriority.NORMAL, owner = ""): () => void {
|
|
9
|
+
const reg: EventRegistration = { event, handler, priority, once: false, owner }
|
|
10
|
+
this.insert(reg)
|
|
11
|
+
return () => this.remove(reg)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Subscribe to an event for a single firing. */
|
|
15
|
+
once(event: string, handler: EventHandler, priority = EventPriority.NORMAL, owner = ""): () => void {
|
|
16
|
+
const reg: EventRegistration = { event, handler, priority, once: true, owner }
|
|
17
|
+
this.insert(reg)
|
|
18
|
+
return () => this.remove(reg)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Emit an event. Returns false if a handler called stop(). */
|
|
22
|
+
emit(event: string, data?: any): boolean {
|
|
23
|
+
const ctx: EventContext = {
|
|
24
|
+
stopped: false,
|
|
25
|
+
stop() { this.stopped = true },
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Snapshot matching handlers (allows safe removal during iteration)
|
|
29
|
+
const matching = this.handlers.filter((h) => h.event === event)
|
|
30
|
+
|
|
31
|
+
for (const reg of matching) {
|
|
32
|
+
reg.handler(data, ctx)
|
|
33
|
+
|
|
34
|
+
if (reg.once) {
|
|
35
|
+
this.remove(reg)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (ctx.stopped) {
|
|
39
|
+
// Remove remaining once handlers that matched but didn't fire
|
|
40
|
+
for (const r of matching) {
|
|
41
|
+
if (r.once && r !== reg) this.remove(r)
|
|
42
|
+
}
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Remove all registrations for a given owner. */
|
|
51
|
+
removeAll(owner: string): void {
|
|
52
|
+
this.handlers = this.handlers.filter((h) => h.owner !== owner)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Remove all handlers. */
|
|
56
|
+
clear(): void {
|
|
57
|
+
this.handlers = []
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Number of registered handlers (for testing). */
|
|
61
|
+
get size(): number {
|
|
62
|
+
return this.handlers.length
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Insert a registration in priority-sorted position (descending). */
|
|
66
|
+
private insert(reg: EventRegistration): void {
|
|
67
|
+
let i = 0
|
|
68
|
+
while (i < this.handlers.length && this.handlers[i].priority >= reg.priority) {
|
|
69
|
+
i++
|
|
70
|
+
}
|
|
71
|
+
this.handlers.splice(i, 0, reg)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Remove a specific registration by reference. */
|
|
75
|
+
private remove(reg: EventRegistration): void {
|
|
76
|
+
const idx = this.handlers.indexOf(reg)
|
|
77
|
+
if (idx !== -1) this.handlers.splice(idx, 1)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Singleton event bus shared across the app and all scripts. */
|
|
82
|
+
export const eventBus = new EventBus()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export { EventBus, eventBus } from "./event-bus"
|
|
2
|
+
export { createScriptAPI, scriptCommands } from "./api"
|
|
3
|
+
export {
|
|
4
|
+
loadScript,
|
|
5
|
+
unloadScript,
|
|
6
|
+
reloadScript,
|
|
7
|
+
getLoadedScripts,
|
|
8
|
+
getAvailableScripts,
|
|
9
|
+
autoloadScripts,
|
|
10
|
+
isLoaded,
|
|
11
|
+
} from "./manager"
|
|
12
|
+
export { EventPriority } from "./types"
|
|
13
|
+
export type {
|
|
14
|
+
KokoAPI,
|
|
15
|
+
ScriptMeta,
|
|
16
|
+
ScriptModule,
|
|
17
|
+
ScriptCommandDef,
|
|
18
|
+
EventHandler,
|
|
19
|
+
EventContext,
|
|
20
|
+
EventRegistration,
|
|
21
|
+
TimerHandle,
|
|
22
|
+
StoreAccess,
|
|
23
|
+
IrcAccess,
|
|
24
|
+
UiAccess,
|
|
25
|
+
ScriptConfigAccess,
|
|
26
|
+
} from "./types"
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { join } from "node:path"
|
|
2
|
+
import { useStore } from "@/core/state/store"
|
|
3
|
+
import { SCRIPTS_DIR } from "@/core/constants"
|
|
4
|
+
import { createScriptAPI } from "./api"
|
|
5
|
+
import type { ScriptMeta, ScriptModule } from "./types"
|
|
6
|
+
|
|
7
|
+
interface LoadedScript {
|
|
8
|
+
name: string
|
|
9
|
+
path: string
|
|
10
|
+
meta: ScriptMeta
|
|
11
|
+
cleanup: () => void
|
|
12
|
+
scriptCleanup?: () => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const loaded = new Map<string, LoadedScript>()
|
|
16
|
+
|
|
17
|
+
/** Resolve a script name or path to an absolute file path. */
|
|
18
|
+
function resolvePath(nameOrPath: string): string {
|
|
19
|
+
if (nameOrPath.startsWith("/") || nameOrPath.startsWith("./") || nameOrPath.startsWith("~")) {
|
|
20
|
+
return nameOrPath
|
|
21
|
+
}
|
|
22
|
+
// Strip .ts extension if given, we'll add it back
|
|
23
|
+
const base = nameOrPath.replace(/\.ts$/, "")
|
|
24
|
+
return join(SCRIPTS_DIR, `${base}.ts`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Load a script by name or path. */
|
|
28
|
+
export async function loadScript(nameOrPath: string): Promise<{ ok: true; name: string } | { ok: false; error: string }> {
|
|
29
|
+
const path = resolvePath(nameOrPath)
|
|
30
|
+
|
|
31
|
+
// Check file exists
|
|
32
|
+
const file = Bun.file(path)
|
|
33
|
+
if (!(await file.exists())) {
|
|
34
|
+
return { ok: false, error: `Script not found: ${path}` }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let mod: ScriptModule
|
|
38
|
+
try {
|
|
39
|
+
mod = await import(`file://${path}?t=${Date.now()}`)
|
|
40
|
+
} catch (err: any) {
|
|
41
|
+
return { ok: false, error: `Failed to import: ${err.message}` }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof mod.default !== "function") {
|
|
45
|
+
return { ok: false, error: `Script has no default export function` }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const meta: ScriptMeta = mod.meta ?? {
|
|
49
|
+
name: nameOrPath.replace(/\.ts$/, "").split("/").pop() ?? nameOrPath,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Unload existing version if already loaded
|
|
53
|
+
if (loaded.has(meta.name)) {
|
|
54
|
+
unloadScript(meta.name)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const scriptDefaults = mod.config ?? {}
|
|
58
|
+
const { api, cleanup } = createScriptAPI(meta, scriptDefaults)
|
|
59
|
+
|
|
60
|
+
let scriptCleanup: (() => void) | undefined
|
|
61
|
+
try {
|
|
62
|
+
const result = mod.default(api)
|
|
63
|
+
if (typeof result === "function") {
|
|
64
|
+
scriptCleanup = result
|
|
65
|
+
}
|
|
66
|
+
} catch (err: any) {
|
|
67
|
+
cleanup()
|
|
68
|
+
return { ok: false, error: `init() threw: ${err.message}` }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
loaded.set(meta.name, { name: meta.name, path, meta, cleanup, scriptCleanup })
|
|
72
|
+
return { ok: true, name: meta.name }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Unload a script by name. */
|
|
76
|
+
export function unloadScript(name: string): boolean {
|
|
77
|
+
const script = loaded.get(name)
|
|
78
|
+
if (!script) return false
|
|
79
|
+
|
|
80
|
+
// Call script's own cleanup first
|
|
81
|
+
try {
|
|
82
|
+
script.scriptCleanup?.()
|
|
83
|
+
} catch {
|
|
84
|
+
// Ignore cleanup errors
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Then clean up all tracked registrations
|
|
88
|
+
script.cleanup()
|
|
89
|
+
loaded.delete(name)
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Reload a script (unload + load with cache bust). */
|
|
94
|
+
export async function reloadScript(name: string): Promise<{ ok: true; name: string } | { ok: false; error: string }> {
|
|
95
|
+
const script = loaded.get(name)
|
|
96
|
+
if (!script) {
|
|
97
|
+
return { ok: false, error: `Script '${name}' is not loaded` }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const path = script.path
|
|
101
|
+
unloadScript(name)
|
|
102
|
+
|
|
103
|
+
// Re-import with cache bust
|
|
104
|
+
return loadScript(path)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Get list of loaded scripts. */
|
|
108
|
+
export function getLoadedScripts(): Array<{ name: string; path: string; meta: ScriptMeta }> {
|
|
109
|
+
return Array.from(loaded.values()).map(({ name, path, meta }) => ({ name, path, meta }))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Get list of available script files in SCRIPTS_DIR. */
|
|
113
|
+
export async function getAvailableScripts(): Promise<Array<{ name: string; path: string; loaded: boolean }>> {
|
|
114
|
+
const glob = new Bun.Glob("*.ts")
|
|
115
|
+
const results: Array<{ name: string; path: string; loaded: boolean }> = []
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
for await (const file of glob.scan({ cwd: SCRIPTS_DIR })) {
|
|
119
|
+
const name = file.replace(/\.ts$/, "")
|
|
120
|
+
results.push({
|
|
121
|
+
name,
|
|
122
|
+
path: join(SCRIPTS_DIR, file),
|
|
123
|
+
loaded: loaded.has(name),
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// Directory might not exist yet
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return results.sort((a, b) => a.name.localeCompare(b.name))
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Load all scripts listed in config.scripts.autoload. */
|
|
134
|
+
export async function autoloadScripts(): Promise<void> {
|
|
135
|
+
const config = useStore.getState().config
|
|
136
|
+
const autoload: string[] = (config as any)?.scripts?.autoload ?? []
|
|
137
|
+
const debug: boolean = (config as any)?.scripts?.debug ?? false
|
|
138
|
+
|
|
139
|
+
for (const name of autoload) {
|
|
140
|
+
const result = await loadScript(name)
|
|
141
|
+
if (debug) {
|
|
142
|
+
if (result.ok) {
|
|
143
|
+
console.log(`[scripts] autoloaded: ${result.name}`)
|
|
144
|
+
} else {
|
|
145
|
+
console.error(`[scripts] autoload failed for '${name}': ${result.error}`)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Check if a script is loaded. */
|
|
152
|
+
export function isLoaded(name: string): boolean {
|
|
153
|
+
return loaded.has(name)
|
|
154
|
+
}
|