datool 0.0.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/README.md +218 -0
- package/client-dist/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
- package/client-dist/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
- package/client-dist/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
- package/client-dist/assets/index-BeRNeRUq.css +1 -0
- package/client-dist/assets/index-uoZ4c_I8.js +164 -0
- package/client-dist/index.html +13 -0
- package/index.html +12 -0
- package/package.json +55 -0
- package/src/client/App.tsx +885 -0
- package/src/client/components/connection-status.tsx +43 -0
- package/src/client/components/data-table-cell.tsx +235 -0
- package/src/client/components/data-table-col-icon.tsx +73 -0
- package/src/client/components/data-table-header-col.tsx +225 -0
- package/src/client/components/data-table-search-input.tsx +729 -0
- package/src/client/components/data-table.tsx +2014 -0
- package/src/client/components/stream-controls.tsx +157 -0
- package/src/client/components/theme-provider.tsx +230 -0
- package/src/client/components/ui/button.tsx +68 -0
- package/src/client/components/ui/combobox.tsx +308 -0
- package/src/client/components/ui/context-menu.tsx +261 -0
- package/src/client/components/ui/dropdown-menu.tsx +267 -0
- package/src/client/components/ui/input-group.tsx +153 -0
- package/src/client/components/ui/input.tsx +19 -0
- package/src/client/components/ui/textarea.tsx +18 -0
- package/src/client/components/viewer-settings.tsx +185 -0
- package/src/client/index.css +192 -0
- package/src/client/lib/data-table-search.ts +750 -0
- package/src/client/lib/datool-icons.ts +37 -0
- package/src/client/lib/datool-url-state.ts +159 -0
- package/src/client/lib/filterable-table.ts +146 -0
- package/src/client/lib/table-search-persistence.ts +94 -0
- package/src/client/lib/utils.ts +6 -0
- package/src/client/main.tsx +14 -0
- package/src/index.ts +19 -0
- package/src/node/cli.ts +54 -0
- package/src/node/config.ts +231 -0
- package/src/node/lines.ts +82 -0
- package/src/node/runtime.ts +102 -0
- package/src/node/server.ts +403 -0
- package/src/node/sources/command.ts +82 -0
- package/src/node/sources/file.ts +116 -0
- package/src/node/sources/ssh.ts +59 -0
- package/src/shared/columns.ts +41 -0
- package/src/shared/types.ts +188 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import fs from "fs"
|
|
2
|
+
import fsPromises from "fs/promises"
|
|
3
|
+
import path from "path"
|
|
4
|
+
|
|
5
|
+
import { getStreamFromConfig, loadDatoolConfig, toClientConfig } from "./config"
|
|
6
|
+
import { openStreamRuntime } from "./runtime"
|
|
7
|
+
import type {
|
|
8
|
+
DatoolActionRequest,
|
|
9
|
+
DatoolActionRowChange,
|
|
10
|
+
DatoolActionResolveResult,
|
|
11
|
+
DatoolActionResponse,
|
|
12
|
+
DatoolConfig,
|
|
13
|
+
DatoolSseErrorEvent,
|
|
14
|
+
} from "../shared/types"
|
|
15
|
+
|
|
16
|
+
type StartServerOptions = {
|
|
17
|
+
configPath?: string
|
|
18
|
+
cwd?: string
|
|
19
|
+
host?: string
|
|
20
|
+
port?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type DatoolServerHandle = {
|
|
24
|
+
config: DatoolConfig
|
|
25
|
+
configPath: string
|
|
26
|
+
host: string
|
|
27
|
+
port: number
|
|
28
|
+
stop: () => void
|
|
29
|
+
url: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SSE_HEADERS = {
|
|
33
|
+
"Cache-Control": "no-cache, no-transform",
|
|
34
|
+
Connection: "keep-alive",
|
|
35
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
36
|
+
} as const
|
|
37
|
+
|
|
38
|
+
function packageRootFromImportMeta() {
|
|
39
|
+
return path.resolve(import.meta.dir, "..", "..")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function loadClientIndexHtml(packageRoot: string) {
|
|
43
|
+
const indexPath = path.join(packageRoot, "client-dist", "index.html")
|
|
44
|
+
|
|
45
|
+
return fsPromises.readFile(indexPath, "utf8")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getClientAssetPath(packageRoot: string, pathname: string) {
|
|
49
|
+
const clientRoot = path.join(packageRoot, "client-dist")
|
|
50
|
+
const absolutePath = path.resolve(clientRoot, pathname.replace(/^\/+/, ""))
|
|
51
|
+
|
|
52
|
+
if (!absolutePath.startsWith(clientRoot)) {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return absolutePath
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function jsonResponse(body: unknown, status = 200) {
|
|
60
|
+
return Response.json(body, {
|
|
61
|
+
status,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function toErrorPayload(error: unknown): DatoolSseErrorEvent {
|
|
66
|
+
if (error instanceof Error) {
|
|
67
|
+
return {
|
|
68
|
+
message: error.message,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
message: String(error),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function encodeSseEvent(event: string, data: unknown) {
|
|
78
|
+
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
82
|
+
return Boolean(value) && typeof value === "object"
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function toActionResponsePayload(
|
|
86
|
+
result: DatoolActionResolveResult<Record<string, unknown>>,
|
|
87
|
+
rowCount: number
|
|
88
|
+
): DatoolActionResponse {
|
|
89
|
+
if (result === undefined || result === true) {
|
|
90
|
+
return {}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (result === null || result === false) {
|
|
94
|
+
return {
|
|
95
|
+
rowChanges: Array<boolean>(rowCount).fill(false),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (Array.isArray(result)) {
|
|
100
|
+
if (result.length === 0) {
|
|
101
|
+
return {}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (result.length !== rowCount) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Action results must return either an empty array or exactly ${rowCount} item${rowCount === 1 ? "" : "s"}.`
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
rowChanges: result.map<boolean | Record<string, unknown>>((change, index) =>
|
|
112
|
+
normalizeActionRowChange(change, index)
|
|
113
|
+
),
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
throw new Error(
|
|
118
|
+
"Action results must return true, false, null, undefined, or an array of row changes."
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function normalizeActionRowChange(
|
|
123
|
+
change: DatoolActionRowChange<Record<string, unknown>>,
|
|
124
|
+
index: number
|
|
125
|
+
) {
|
|
126
|
+
if (change === undefined || change === true) {
|
|
127
|
+
return true
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (change === null || change === false) {
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (isRecord(change)) {
|
|
135
|
+
return change
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
throw new Error(
|
|
139
|
+
`Action result at index ${index} must be a row object, true, false, null, or undefined.`
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function resolveActionResponsePayload(
|
|
144
|
+
result: DatoolActionResolveResult<Record<string, unknown>>,
|
|
145
|
+
rowCount: number
|
|
146
|
+
) {
|
|
147
|
+
try {
|
|
148
|
+
return toActionResponsePayload(result, rowCount)
|
|
149
|
+
} catch (error) {
|
|
150
|
+
return {
|
|
151
|
+
error: error instanceof Error ? error.message : String(error),
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function createActionResponse(
|
|
157
|
+
config: DatoolConfig,
|
|
158
|
+
streamId: string,
|
|
159
|
+
actionId: string,
|
|
160
|
+
query: URLSearchParams,
|
|
161
|
+
request: Request
|
|
162
|
+
) {
|
|
163
|
+
const stream = getStreamFromConfig(config, streamId)
|
|
164
|
+
|
|
165
|
+
if (!stream) {
|
|
166
|
+
return jsonResponse(
|
|
167
|
+
{
|
|
168
|
+
error: `Unknown stream "${streamId}".`,
|
|
169
|
+
},
|
|
170
|
+
404
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const action = stream.actions?.[actionId]
|
|
175
|
+
|
|
176
|
+
if (!action) {
|
|
177
|
+
return jsonResponse(
|
|
178
|
+
{
|
|
179
|
+
error: `Unknown action "${actionId}" for stream "${streamId}".`,
|
|
180
|
+
},
|
|
181
|
+
404
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let body: DatoolActionRequest
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
body = (await request.json()) as DatoolActionRequest
|
|
189
|
+
} catch {
|
|
190
|
+
return jsonResponse(
|
|
191
|
+
{
|
|
192
|
+
error: "Invalid JSON body.",
|
|
193
|
+
},
|
|
194
|
+
400
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!Array.isArray(body.rows) || body.rows.length === 0) {
|
|
199
|
+
return jsonResponse(
|
|
200
|
+
{
|
|
201
|
+
error: "Action requests must include a non-empty rows array.",
|
|
202
|
+
},
|
|
203
|
+
400
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const result = await action.resolve({
|
|
209
|
+
actionId,
|
|
210
|
+
query,
|
|
211
|
+
rows: body.rows,
|
|
212
|
+
streamId,
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
const payload = await resolveActionResponsePayload(result, body.rows.length)
|
|
216
|
+
|
|
217
|
+
if ("error" in payload) {
|
|
218
|
+
return jsonResponse(
|
|
219
|
+
{
|
|
220
|
+
error: payload.error,
|
|
221
|
+
},
|
|
222
|
+
500
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return jsonResponse(payload)
|
|
227
|
+
} catch (error) {
|
|
228
|
+
return jsonResponse(
|
|
229
|
+
{
|
|
230
|
+
error: error instanceof Error ? error.message : String(error),
|
|
231
|
+
},
|
|
232
|
+
500
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function createSseResponse(
|
|
238
|
+
config: DatoolConfig,
|
|
239
|
+
streamId: string,
|
|
240
|
+
query: URLSearchParams,
|
|
241
|
+
requestSignal: AbortSignal
|
|
242
|
+
) {
|
|
243
|
+
const stream = getStreamFromConfig(config, streamId)
|
|
244
|
+
|
|
245
|
+
if (!stream) {
|
|
246
|
+
return jsonResponse(
|
|
247
|
+
{
|
|
248
|
+
error: `Unknown stream "${streamId}".`,
|
|
249
|
+
},
|
|
250
|
+
404
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const responseStream = new ReadableStream<Uint8Array>({
|
|
255
|
+
start(controller) {
|
|
256
|
+
const encoder = new TextEncoder()
|
|
257
|
+
const abortController = new AbortController()
|
|
258
|
+
|
|
259
|
+
const forwardAbort = () => abortController.abort()
|
|
260
|
+
|
|
261
|
+
requestSignal.addEventListener("abort", forwardAbort, {
|
|
262
|
+
once: true,
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
const send = (event: string, payload: unknown) => {
|
|
266
|
+
controller.enqueue(encoder.encode(encodeSseEvent(event, payload)))
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const heartbeat = setInterval(() => {
|
|
270
|
+
if (!abortController.signal.aborted) {
|
|
271
|
+
send("heartbeat", { ok: true })
|
|
272
|
+
}
|
|
273
|
+
}, 15_000)
|
|
274
|
+
|
|
275
|
+
void openStreamRuntime(
|
|
276
|
+
streamId,
|
|
277
|
+
stream,
|
|
278
|
+
query,
|
|
279
|
+
abortController.signal,
|
|
280
|
+
{
|
|
281
|
+
async onError(error) {
|
|
282
|
+
send("error", toErrorPayload(error))
|
|
283
|
+
},
|
|
284
|
+
async onRow(payload) {
|
|
285
|
+
send("row", payload)
|
|
286
|
+
},
|
|
287
|
+
}
|
|
288
|
+
)
|
|
289
|
+
.catch((error) => {
|
|
290
|
+
send("error", toErrorPayload(error))
|
|
291
|
+
})
|
|
292
|
+
.finally(() => {
|
|
293
|
+
clearInterval(heartbeat)
|
|
294
|
+
requestSignal.removeEventListener("abort", forwardAbort)
|
|
295
|
+
try {
|
|
296
|
+
controller.close()
|
|
297
|
+
} catch {
|
|
298
|
+
// The client may have already closed the SSE stream.
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
},
|
|
302
|
+
cancel() {
|
|
303
|
+
// The request signal already drives stream shutdown.
|
|
304
|
+
},
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
return new Response(responseStream, {
|
|
308
|
+
headers: SSE_HEADERS,
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export async function startDatoolServer(
|
|
313
|
+
options: StartServerOptions = {}
|
|
314
|
+
): Promise<DatoolServerHandle> {
|
|
315
|
+
const cwd = options.cwd ?? process.cwd()
|
|
316
|
+
const packageRoot = packageRootFromImportMeta()
|
|
317
|
+
const { config, configPath } = await loadDatoolConfig({
|
|
318
|
+
configPath: options.configPath,
|
|
319
|
+
cwd,
|
|
320
|
+
})
|
|
321
|
+
const host = options.host ?? config.server?.host ?? "127.0.0.1"
|
|
322
|
+
const port = options.port ?? config.server?.port ?? 3210
|
|
323
|
+
const indexHtml = await loadClientIndexHtml(packageRoot)
|
|
324
|
+
const server = Bun.serve({
|
|
325
|
+
async fetch(request) {
|
|
326
|
+
const url = new URL(request.url)
|
|
327
|
+
|
|
328
|
+
if (url.pathname === "/api/config") {
|
|
329
|
+
return jsonResponse(toClientConfig(config))
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (
|
|
333
|
+
url.pathname.startsWith("/api/streams/") &&
|
|
334
|
+
url.pathname.endsWith("/events")
|
|
335
|
+
) {
|
|
336
|
+
const streamId = decodeURIComponent(
|
|
337
|
+
url.pathname
|
|
338
|
+
.slice("/api/streams/".length, -"/events".length)
|
|
339
|
+
.replace(/\/+$/, "")
|
|
340
|
+
)
|
|
341
|
+
const query = new URLSearchParams(url.searchParams)
|
|
342
|
+
|
|
343
|
+
query.delete("stream")
|
|
344
|
+
|
|
345
|
+
return createSseResponse(config, streamId, query, request.signal)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (
|
|
349
|
+
request.method === "POST" &&
|
|
350
|
+
url.pathname.startsWith("/api/streams/") &&
|
|
351
|
+
url.pathname.includes("/actions/")
|
|
352
|
+
) {
|
|
353
|
+
const pathMatch = url.pathname.match(
|
|
354
|
+
/^\/api\/streams\/(.+?)\/actions\/(.+?)\/?$/
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
if (!pathMatch) {
|
|
358
|
+
return new Response("Not found", {
|
|
359
|
+
status: 404,
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const streamId = decodeURIComponent(pathMatch[1] ?? "")
|
|
364
|
+
const actionId = decodeURIComponent(pathMatch[2] ?? "")
|
|
365
|
+
const query = new URLSearchParams(url.searchParams)
|
|
366
|
+
|
|
367
|
+
query.delete("stream")
|
|
368
|
+
|
|
369
|
+
return createActionResponse(config, streamId, actionId, query, request)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
373
|
+
return new Response(indexHtml, {
|
|
374
|
+
headers: {
|
|
375
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
376
|
+
},
|
|
377
|
+
})
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const assetPath = getClientAssetPath(packageRoot, url.pathname)
|
|
381
|
+
|
|
382
|
+
if (assetPath && fs.existsSync(assetPath)) {
|
|
383
|
+
return new Response(Bun.file(assetPath))
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return new Response("Not found", {
|
|
387
|
+
status: 404,
|
|
388
|
+
})
|
|
389
|
+
},
|
|
390
|
+
hostname: host,
|
|
391
|
+
port,
|
|
392
|
+
})
|
|
393
|
+
const resolvedPort = server.port ?? port
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
config,
|
|
397
|
+
configPath,
|
|
398
|
+
host,
|
|
399
|
+
port: resolvedPort,
|
|
400
|
+
stop: () => server.stop(true),
|
|
401
|
+
url: `http://${host}:${resolvedPort}`,
|
|
402
|
+
}
|
|
403
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readLinesFromStream } from "../lines"
|
|
2
|
+
import type { DatoolSource } from "../../shared/types"
|
|
3
|
+
|
|
4
|
+
type Resolver<T> = T | ((context: { query: URLSearchParams }) => T)
|
|
5
|
+
|
|
6
|
+
export type CommandSourceOptions = {
|
|
7
|
+
args?: Resolver<string[]>
|
|
8
|
+
command: Resolver<string>
|
|
9
|
+
cwd?: Resolver<string>
|
|
10
|
+
env?: Resolver<Record<string, string | undefined>>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function resolveValue<T>(value: Resolver<T>, query: URLSearchParams) {
|
|
14
|
+
if (typeof value === "function") {
|
|
15
|
+
return (value as (context: { query: URLSearchParams }) => T)({
|
|
16
|
+
query,
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return value
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function toSpawnArgs(options: CommandSourceOptions, query: URLSearchParams) {
|
|
24
|
+
const command = resolveValue(options.command, query)
|
|
25
|
+
const args = options.args ? resolveValue(options.args, query) : undefined
|
|
26
|
+
|
|
27
|
+
if (args && args.length > 0) {
|
|
28
|
+
return [command, ...args]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return ["/bin/sh", "-lc", command]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function commandSource(options: CommandSourceOptions): DatoolSource {
|
|
35
|
+
return {
|
|
36
|
+
async open(context) {
|
|
37
|
+
const cmd = toSpawnArgs(options, context.query)
|
|
38
|
+
const env = options.env ? resolveValue(options.env, context.query) : undefined
|
|
39
|
+
const cwd = options.cwd ? resolveValue(options.cwd, context.query) : undefined
|
|
40
|
+
const child = Bun.spawn(cmd, {
|
|
41
|
+
cwd,
|
|
42
|
+
env,
|
|
43
|
+
stderr: "pipe",
|
|
44
|
+
stdout: "pipe",
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const abortChild = () => {
|
|
48
|
+
try {
|
|
49
|
+
child.kill()
|
|
50
|
+
} catch {
|
|
51
|
+
// Ignore process termination races during shutdown.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
context.signal.addEventListener("abort", abortChild, {
|
|
56
|
+
once: true,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
await Promise.all([
|
|
61
|
+
readLinesFromStream(child.stdout, {
|
|
62
|
+
onLine: context.emit,
|
|
63
|
+
signal: context.signal,
|
|
64
|
+
}),
|
|
65
|
+
child.exited,
|
|
66
|
+
])
|
|
67
|
+
|
|
68
|
+
const exitCode = await child.exited
|
|
69
|
+
|
|
70
|
+
if (exitCode !== 0 && !context.signal.aborted) {
|
|
71
|
+
const stderrText = await new Response(child.stderr).text()
|
|
72
|
+
|
|
73
|
+
throw new Error(
|
|
74
|
+
stderrText.trim() || `Command exited with code ${exitCode}.`
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
} finally {
|
|
78
|
+
context.signal.removeEventListener("abort", abortChild)
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import fs from "fs/promises"
|
|
2
|
+
|
|
3
|
+
import { waitForDelay } from "../lines"
|
|
4
|
+
import type { DatoolSource } from "../../shared/types"
|
|
5
|
+
|
|
6
|
+
type Resolver<T> = T | ((context: { query: URLSearchParams }) => T)
|
|
7
|
+
|
|
8
|
+
export type FileSourceOptions = {
|
|
9
|
+
historyParam?: string
|
|
10
|
+
path: Resolver<string>
|
|
11
|
+
pollIntervalMs?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function resolveValue<T>(value: Resolver<T>, query: URLSearchParams) {
|
|
15
|
+
if (typeof value === "function") {
|
|
16
|
+
return (value as (context: { query: URLSearchParams }) => T)({
|
|
17
|
+
query,
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return value
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function toHistoryLineCount(query: URLSearchParams, historyParam: string) {
|
|
25
|
+
const rawValue = query.get(historyParam)
|
|
26
|
+
|
|
27
|
+
if (!rawValue) {
|
|
28
|
+
return 0
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const parsedValue = Number.parseInt(rawValue, 10)
|
|
32
|
+
|
|
33
|
+
if (!Number.isFinite(parsedValue) || parsedValue <= 0) {
|
|
34
|
+
return 0
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return parsedValue
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeLines(content: string) {
|
|
41
|
+
return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function emitHistoryLines(
|
|
45
|
+
filePath: string,
|
|
46
|
+
lineCount: number,
|
|
47
|
+
emit: (line: string) => void
|
|
48
|
+
) {
|
|
49
|
+
if (lineCount <= 0) {
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const content = await fs.readFile(filePath, "utf8")
|
|
54
|
+
const lines = normalizeLines(content).filter(Boolean)
|
|
55
|
+
|
|
56
|
+
for (const line of lines.slice(-lineCount)) {
|
|
57
|
+
emit(line)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readAppendedText(filePath: string, start: number, end: number) {
|
|
62
|
+
const fileHandle = await fs.open(filePath, "r")
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const length = end - start
|
|
66
|
+
const buffer = Buffer.alloc(length)
|
|
67
|
+
|
|
68
|
+
await fileHandle.read(buffer, 0, length, start)
|
|
69
|
+
|
|
70
|
+
return buffer.toString("utf8")
|
|
71
|
+
} finally {
|
|
72
|
+
await fileHandle.close()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function fileSource(options: FileSourceOptions): DatoolSource {
|
|
77
|
+
return {
|
|
78
|
+
async open(context) {
|
|
79
|
+
const historyParam = options.historyParam ?? "history"
|
|
80
|
+
const pollIntervalMs = options.pollIntervalMs ?? 250
|
|
81
|
+
const filePath = resolveValue(options.path, context.query)
|
|
82
|
+
const historyLineCount = toHistoryLineCount(context.query, historyParam)
|
|
83
|
+
let stat = await fs.stat(filePath)
|
|
84
|
+
let position = stat.size
|
|
85
|
+
let remainder = ""
|
|
86
|
+
|
|
87
|
+
await emitHistoryLines(filePath, historyLineCount, context.emit)
|
|
88
|
+
|
|
89
|
+
while (!context.signal.aborted) {
|
|
90
|
+
stat = await fs.stat(filePath)
|
|
91
|
+
|
|
92
|
+
if (stat.size < position) {
|
|
93
|
+
position = 0
|
|
94
|
+
remainder = ""
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (stat.size > position) {
|
|
98
|
+
const appendedText = await readAppendedText(filePath, position, stat.size)
|
|
99
|
+
const normalized = (remainder + appendedText).replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
|
100
|
+
const parts = normalized.split("\n")
|
|
101
|
+
|
|
102
|
+
remainder = parts.pop() ?? ""
|
|
103
|
+
position = stat.size
|
|
104
|
+
|
|
105
|
+
for (const line of parts) {
|
|
106
|
+
if (line.length > 0) {
|
|
107
|
+
context.emit(line)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await waitForDelay(pollIntervalMs, context.signal)
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { commandSource } from "./command"
|
|
2
|
+
import type { DatoolSource } from "../../shared/types"
|
|
3
|
+
|
|
4
|
+
type Resolver<T> = T | ((context: { query: URLSearchParams }) => T)
|
|
5
|
+
|
|
6
|
+
export type SshSourceOptions = {
|
|
7
|
+
command: Resolver<string>
|
|
8
|
+
host: string
|
|
9
|
+
identityFile?: string
|
|
10
|
+
options?: Record<string, string>
|
|
11
|
+
port?: number
|
|
12
|
+
user?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function resolveValue<T>(value: Resolver<T>, query: URLSearchParams) {
|
|
16
|
+
if (typeof value === "function") {
|
|
17
|
+
return (value as (context: { query: URLSearchParams }) => T)({
|
|
18
|
+
query,
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return value
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function buildSshArgs(
|
|
26
|
+
options: SshSourceOptions,
|
|
27
|
+
query: URLSearchParams
|
|
28
|
+
) {
|
|
29
|
+
const sshArgs: string[] = []
|
|
30
|
+
|
|
31
|
+
if (options.port) {
|
|
32
|
+
sshArgs.push("-p", String(options.port))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (options.identityFile) {
|
|
36
|
+
sshArgs.push("-i", options.identityFile)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const [key, value] of Object.entries(options.options ?? {})) {
|
|
40
|
+
sshArgs.push("-o", `${key}=${value}`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const target = options.user ? `${options.user}@${options.host}` : options.host
|
|
44
|
+
|
|
45
|
+
sshArgs.push(target, resolveValue(options.command, query))
|
|
46
|
+
|
|
47
|
+
return sshArgs
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function sshSource(options: SshSourceOptions): DatoolSource {
|
|
51
|
+
return {
|
|
52
|
+
open(context) {
|
|
53
|
+
return commandSource({
|
|
54
|
+
args: buildSshArgs(options, context.query),
|
|
55
|
+
command: "ssh",
|
|
56
|
+
}).open(context)
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { DatoolColumn } from "./types"
|
|
2
|
+
|
|
3
|
+
export function resolveDatoolColumnId(
|
|
4
|
+
column: DatoolColumn,
|
|
5
|
+
index: number
|
|
6
|
+
) {
|
|
7
|
+
return (
|
|
8
|
+
column.id ??
|
|
9
|
+
column.accessorKey ??
|
|
10
|
+
(column.header
|
|
11
|
+
? column.header.toLowerCase().replace(/\s+/g, "-")
|
|
12
|
+
: `column-${index}`)
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getValueAtPath(
|
|
17
|
+
value: Record<string, unknown>,
|
|
18
|
+
accessorKey: string
|
|
19
|
+
): unknown {
|
|
20
|
+
if (!accessorKey.includes(".")) {
|
|
21
|
+
return value[accessorKey]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return accessorKey
|
|
25
|
+
.split(".")
|
|
26
|
+
.reduce<unknown>((currentValue, segment) => {
|
|
27
|
+
if (
|
|
28
|
+
currentValue === null ||
|
|
29
|
+
currentValue === undefined ||
|
|
30
|
+
typeof currentValue !== "object"
|
|
31
|
+
) {
|
|
32
|
+
return undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (currentValue as Record<string, unknown>)[segment]
|
|
36
|
+
}, value)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isNestedAccessorKey(accessorKey: string) {
|
|
40
|
+
return accessorKey.includes(".")
|
|
41
|
+
}
|