@abraca/cli 1.5.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/dist/abracadabra-cli.cjs +3301 -0
- package/dist/abracadabra-cli.cjs.map +1 -0
- package/dist/abracadabra-cli.esm.js +3272 -0
- package/dist/abracadabra-cli.esm.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/package.json +39 -0
- package/src/command.ts +29 -0
- package/src/commands/awareness.ts +122 -0
- package/src/commands/content.ts +135 -0
- package/src/commands/documents.ts +419 -0
- package/src/commands/files.ts +117 -0
- package/src/commands/help.ts +72 -0
- package/src/commands/meta.ts +149 -0
- package/src/commands/permissions.ts +94 -0
- package/src/commands/spaces.ts +100 -0
- package/src/commands/tree.ts +158 -0
- package/src/connection.ts +321 -0
- package/src/converters/index.ts +10 -0
- package/src/converters/markdownToYjs.ts +3 -0
- package/src/converters/types.ts +3 -0
- package/src/converters/yjsToMarkdown.ts +3 -0
- package/src/crypto.ts +69 -0
- package/src/index.ts +114 -0
- package/src/output.ts +109 -0
- package/src/parser.ts +80 -0
- package/src/resolve.ts +174 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document CRUD commands: read, create, rename, move, delete, type, doc.
|
|
3
|
+
*/
|
|
4
|
+
import * as Y from 'yjs'
|
|
5
|
+
import { registerCommand } from '../command.ts'
|
|
6
|
+
import type { CLIConnection } from '../connection.ts'
|
|
7
|
+
import type { ParsedArgs } from '../parser.ts'
|
|
8
|
+
import { readEntries, childrenOf, normalizeRootId, resolveDocument, descendantsOf } from '../resolve.ts'
|
|
9
|
+
import { printJson, relativeTime } from '../output.ts'
|
|
10
|
+
import { yjsToMarkdown } from '../converters/yjsToMarkdown.ts'
|
|
11
|
+
import { populateYDocFromMarkdown, parseFrontmatter } from '../converters/markdownToYjs.ts'
|
|
12
|
+
|
|
13
|
+
/** Safely read a tree map value, converting Y.Map to plain object if needed. */
|
|
14
|
+
function toPlain(val: any): any {
|
|
15
|
+
return val instanceof Y.Map ? val.toJSON() : val
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
registerCommand({
|
|
19
|
+
name: 'doc',
|
|
20
|
+
aliases: ['info:doc'],
|
|
21
|
+
description: 'Show document metadata (label, type, meta, dates).',
|
|
22
|
+
usage: 'doc id=<docId> | name=<label> | path=<a/b/c>',
|
|
23
|
+
async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
|
|
24
|
+
if (!conn) return 'Not connected'
|
|
25
|
+
|
|
26
|
+
const docId = resolveDocument(conn, args.params, args.positional)
|
|
27
|
+
if (!docId) return 'Document not found. Specify id=, name=, or path= to identify the document.'
|
|
28
|
+
|
|
29
|
+
const treeMap = conn.getTreeMap()
|
|
30
|
+
if (!treeMap) return 'Not connected'
|
|
31
|
+
|
|
32
|
+
const raw = treeMap.get(docId)
|
|
33
|
+
if (!raw) return `Document ${docId} not found in tree.`
|
|
34
|
+
|
|
35
|
+
const entry = toPlain(raw)
|
|
36
|
+
|
|
37
|
+
const lines = [
|
|
38
|
+
`id: ${docId}`,
|
|
39
|
+
`label: ${entry.label || 'Untitled'}`,
|
|
40
|
+
`type: ${entry.type ?? '—'}`,
|
|
41
|
+
`parent: ${entry.parentId ?? '(root)'}`,
|
|
42
|
+
`order: ${entry.order ?? 0}`,
|
|
43
|
+
`created: ${entry.createdAt ? new Date(entry.createdAt).toISOString() : '—'}`,
|
|
44
|
+
`updated: ${entry.updatedAt ? new Date(entry.updatedAt).toISOString() : '—'} (${relativeTime(entry.updatedAt)})`,
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
if (entry.meta && Object.keys(entry.meta).length > 0) {
|
|
48
|
+
lines.push(`meta:`)
|
|
49
|
+
for (const [k, v] of Object.entries(entry.meta)) {
|
|
50
|
+
lines.push(` ${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Count children
|
|
55
|
+
const entries = readEntries(treeMap)
|
|
56
|
+
const children = childrenOf(entries, docId)
|
|
57
|
+
lines.push(`children: ${children.length}`)
|
|
58
|
+
|
|
59
|
+
return lines.join('\n')
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
registerCommand({
|
|
64
|
+
name: 'read',
|
|
65
|
+
aliases: ['cat', 'r'],
|
|
66
|
+
description: 'Read document content as markdown.',
|
|
67
|
+
usage: 'read id=<docId> | name=<label> | path=<a/b/c> [--format=json|md]',
|
|
68
|
+
async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
|
|
69
|
+
if (!conn) return 'Not connected'
|
|
70
|
+
|
|
71
|
+
const docId = resolveDocument(conn, args.params, args.positional)
|
|
72
|
+
if (!docId) return 'Document not found. Specify id=, name=, or path= to identify the document.'
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const provider = await conn.getChildProvider(docId)
|
|
76
|
+
const fragment = provider.document.getXmlFragment('default')
|
|
77
|
+
const { title, markdown } = yjsToMarkdown(fragment)
|
|
78
|
+
|
|
79
|
+
if (args.flags.has('json') || args.params['format'] === 'json') {
|
|
80
|
+
// Include tree metadata + children
|
|
81
|
+
const treeMap = conn.getTreeMap()
|
|
82
|
+
let label = title
|
|
83
|
+
let type: string | undefined
|
|
84
|
+
let meta: Record<string, unknown> | undefined
|
|
85
|
+
let children: Array<{ id: string; label: string; type?: string }> = []
|
|
86
|
+
if (treeMap) {
|
|
87
|
+
const entry = treeMap.get(docId)
|
|
88
|
+
if (entry) {
|
|
89
|
+
label = entry.label || title
|
|
90
|
+
type = entry.type
|
|
91
|
+
meta = entry.meta
|
|
92
|
+
}
|
|
93
|
+
treeMap.forEach((value: any, id: string) => {
|
|
94
|
+
const v = toPlain(value)
|
|
95
|
+
if (v.parentId === docId) {
|
|
96
|
+
children.push({ id, label: v.label || 'Untitled', type: v.type })
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
children.sort((a: any, b: any) => {
|
|
100
|
+
const va = treeMap.get(a.id)
|
|
101
|
+
const vb = treeMap.get(b.id)
|
|
102
|
+
return ((va?.order ?? 0) - (vb?.order ?? 0))
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
return printJson({ label, type, meta, markdown, children })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return markdown || '(empty document)'
|
|
109
|
+
} catch (error: any) {
|
|
110
|
+
return `Error reading document: ${error.message}`
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
registerCommand({
|
|
116
|
+
name: 'create',
|
|
117
|
+
aliases: ['new'],
|
|
118
|
+
description: 'Create a new document in the tree.',
|
|
119
|
+
usage: 'create label=<name> [parent=<docId>] [type=<pageType>] [content=<markdown>] [--format=json]',
|
|
120
|
+
async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
|
|
121
|
+
if (!conn) return 'Not connected'
|
|
122
|
+
|
|
123
|
+
const label = args.params['label'] || args.params['name'] || args.positional[0]
|
|
124
|
+
if (!label) return 'Missing required parameter: label=<name>'
|
|
125
|
+
|
|
126
|
+
const treeMap = conn.getTreeMap()
|
|
127
|
+
const rootDoc = conn.rootDoc
|
|
128
|
+
if (!treeMap || !rootDoc) return 'Not connected'
|
|
129
|
+
|
|
130
|
+
// Resolve parent
|
|
131
|
+
let parentId: string | null = null
|
|
132
|
+
if (args.params['parent']) {
|
|
133
|
+
parentId = resolveDocument(conn, { id: args.params['parent'], name: args.params['parent'] }, [])
|
|
134
|
+
if (!parentId) parentId = args.params['parent'] // Use raw value as ID
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const normalizedParent = normalizeRootId(parentId || conn.rootDocId, conn)
|
|
138
|
+
const type = args.params['type']
|
|
139
|
+
|
|
140
|
+
const id = crypto.randomUUID()
|
|
141
|
+
const now = Date.now()
|
|
142
|
+
|
|
143
|
+
// Parse optional meta from params
|
|
144
|
+
const meta: Record<string, unknown> = {}
|
|
145
|
+
if (args.params['icon']) meta['icon'] = args.params['icon']
|
|
146
|
+
if (args.params['color']) meta['color'] = args.params['color']
|
|
147
|
+
|
|
148
|
+
rootDoc.transact(() => {
|
|
149
|
+
treeMap.set(id, {
|
|
150
|
+
label,
|
|
151
|
+
parentId: normalizedParent,
|
|
152
|
+
order: now,
|
|
153
|
+
type,
|
|
154
|
+
meta: Object.keys(meta).length > 0 ? meta : undefined,
|
|
155
|
+
createdAt: now,
|
|
156
|
+
updatedAt: now,
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// Write content if provided
|
|
161
|
+
const content = args.params['content']
|
|
162
|
+
if (content) {
|
|
163
|
+
try {
|
|
164
|
+
const provider = await conn.getChildProvider(id)
|
|
165
|
+
const fragment = provider.document.getXmlFragment('default')
|
|
166
|
+
const contentToWrite = content.replace(/\\n/g, '\n').replace(/\\t/g, '\t')
|
|
167
|
+
populateYDocFromMarkdown(fragment, contentToWrite, label)
|
|
168
|
+
} catch (error: any) {
|
|
169
|
+
return `Created ${id} "${label}" but failed to write content: ${error.message}`
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (args.params['format'] === 'json') {
|
|
174
|
+
return printJson({ id, label, parentId: normalizedParent, type })
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return `Created: ${id.slice(0, 8)}… "${label}"${type ? ` (${type})` : ''}`
|
|
178
|
+
},
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
registerCommand({
|
|
182
|
+
name: 'rename',
|
|
183
|
+
description: 'Rename a document.',
|
|
184
|
+
usage: 'rename id=<docId> | name=<oldLabel> label=<newLabel>',
|
|
185
|
+
async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
|
|
186
|
+
if (!conn) return 'Not connected'
|
|
187
|
+
|
|
188
|
+
const docId = resolveDocument(conn, args.params, args.positional)
|
|
189
|
+
if (!docId) return 'Document not found.'
|
|
190
|
+
|
|
191
|
+
const newLabel = args.params['label'] || args.params['to']
|
|
192
|
+
if (!newLabel) return 'Missing required parameter: label=<newLabel>'
|
|
193
|
+
|
|
194
|
+
const treeMap = conn.getTreeMap()
|
|
195
|
+
if (!treeMap) return 'Not connected'
|
|
196
|
+
|
|
197
|
+
const raw = treeMap.get(docId)
|
|
198
|
+
if (!raw) return `Document ${docId} not found.`
|
|
199
|
+
|
|
200
|
+
const entry = toPlain(raw)
|
|
201
|
+
treeMap.set(docId, { ...entry, label: newLabel, updatedAt: Date.now() })
|
|
202
|
+
|
|
203
|
+
return `Renamed to "${newLabel}"`
|
|
204
|
+
},
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
registerCommand({
|
|
208
|
+
name: 'move',
|
|
209
|
+
aliases: ['mv'],
|
|
210
|
+
description: 'Move a document to a new parent.',
|
|
211
|
+
usage: 'move id=<docId> | name=<label> to=<parentId> [order=<n>]',
|
|
212
|
+
async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
|
|
213
|
+
if (!conn) return 'Not connected'
|
|
214
|
+
|
|
215
|
+
const docId = resolveDocument(conn, args.params, args.positional)
|
|
216
|
+
if (!docId) return 'Document not found.'
|
|
217
|
+
|
|
218
|
+
const newParentId = args.params['to'] || args.params['parent']
|
|
219
|
+
if (!newParentId) return 'Missing required parameter: to=<parentId>'
|
|
220
|
+
|
|
221
|
+
const treeMap = conn.getTreeMap()
|
|
222
|
+
if (!treeMap) return 'Not connected'
|
|
223
|
+
|
|
224
|
+
const raw = treeMap.get(docId)
|
|
225
|
+
if (!raw) return `Document ${docId} not found.`
|
|
226
|
+
|
|
227
|
+
const entry = toPlain(raw)
|
|
228
|
+
const order = args.params['order'] ? parseInt(args.params['order'], 10) : Date.now()
|
|
229
|
+
treeMap.set(docId, {
|
|
230
|
+
...entry,
|
|
231
|
+
parentId: normalizeRootId(newParentId, conn),
|
|
232
|
+
order,
|
|
233
|
+
updatedAt: Date.now(),
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
return `Moved ${docId.slice(0, 8)}… to parent ${newParentId.slice(0, 8)}…`
|
|
237
|
+
},
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
registerCommand({
|
|
241
|
+
name: 'delete',
|
|
242
|
+
aliases: ['rm', 'del'],
|
|
243
|
+
description: 'Soft-delete a document (moves to trash).',
|
|
244
|
+
usage: 'delete id=<docId> | name=<label>',
|
|
245
|
+
async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
|
|
246
|
+
if (!conn) return 'Not connected'
|
|
247
|
+
|
|
248
|
+
const docId = resolveDocument(conn, args.params, args.positional)
|
|
249
|
+
if (!docId) return 'Document not found.'
|
|
250
|
+
|
|
251
|
+
const treeMap = conn.getTreeMap()
|
|
252
|
+
const trashMap = conn.getTrashMap()
|
|
253
|
+
const rootDoc = conn.rootDoc
|
|
254
|
+
if (!treeMap || !trashMap || !rootDoc) return 'Not connected'
|
|
255
|
+
|
|
256
|
+
const entries = readEntries(treeMap)
|
|
257
|
+
const toDelete = [docId, ...descendantsOf(entries, docId).map(e => e.id)]
|
|
258
|
+
|
|
259
|
+
const now = Date.now()
|
|
260
|
+
rootDoc.transact(() => {
|
|
261
|
+
for (const nid of toDelete) {
|
|
262
|
+
const raw = treeMap.get(nid)
|
|
263
|
+
if (!raw) continue
|
|
264
|
+
const entry = toPlain(raw)
|
|
265
|
+
trashMap.set(nid, {
|
|
266
|
+
label: entry.label || 'Untitled',
|
|
267
|
+
parentId: entry.parentId ?? null,
|
|
268
|
+
order: entry.order ?? 0,
|
|
269
|
+
type: entry.type,
|
|
270
|
+
meta: entry.meta,
|
|
271
|
+
deletedAt: now,
|
|
272
|
+
})
|
|
273
|
+
treeMap.delete(nid)
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
return `Deleted ${toDelete.length} document(s)`
|
|
278
|
+
},
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
registerCommand({
|
|
282
|
+
name: 'type',
|
|
283
|
+
description: 'Change the page type view of a document.',
|
|
284
|
+
usage: 'type id=<docId> | name=<label> type=<pageType>',
|
|
285
|
+
async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
|
|
286
|
+
if (!conn) return 'Not connected'
|
|
287
|
+
|
|
288
|
+
const docId = resolveDocument(conn, args.params, args.positional)
|
|
289
|
+
if (!docId) return 'Document not found.'
|
|
290
|
+
|
|
291
|
+
const newType = args.params['type'] || args.positional[1]
|
|
292
|
+
if (!newType) return 'Missing required parameter: type=<pageType>'
|
|
293
|
+
|
|
294
|
+
const treeMap = conn.getTreeMap()
|
|
295
|
+
if (!treeMap) return 'Not connected'
|
|
296
|
+
|
|
297
|
+
const raw = treeMap.get(docId)
|
|
298
|
+
if (!raw) return `Document ${docId} not found.`
|
|
299
|
+
|
|
300
|
+
const entry = toPlain(raw)
|
|
301
|
+
treeMap.set(docId, { ...entry, type: newType, updatedAt: Date.now() })
|
|
302
|
+
|
|
303
|
+
return `Changed type to "${newType}"`
|
|
304
|
+
},
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
registerCommand({
|
|
308
|
+
name: 'write',
|
|
309
|
+
description: 'Write markdown content to a document (replace or append).',
|
|
310
|
+
usage: 'write id=<docId> | name=<label> content=<markdown> [mode=replace|append]',
|
|
311
|
+
async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
|
|
312
|
+
if (!conn) return 'Not connected'
|
|
313
|
+
|
|
314
|
+
const docId = resolveDocument(conn, args.params, args.positional)
|
|
315
|
+
if (!docId) return 'Document not found.'
|
|
316
|
+
|
|
317
|
+
// Content from param, or read from stdin if piped
|
|
318
|
+
let content = args.params['content']
|
|
319
|
+
|
|
320
|
+
if (!content) {
|
|
321
|
+
// Check if stdin is piped (not a TTY)
|
|
322
|
+
if (!process.stdin.isTTY) {
|
|
323
|
+
content = await readStdin()
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (!content) return 'Missing required parameter: content=<markdown> (or pipe via stdin)'
|
|
328
|
+
|
|
329
|
+
// Unescape \n and \t
|
|
330
|
+
content = content.replace(/\\n/g, '\n').replace(/\\t/g, '\t')
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
const writeMode = args.params['mode'] ?? 'replace'
|
|
334
|
+
const provider = await conn.getChildProvider(docId)
|
|
335
|
+
const doc = provider.document
|
|
336
|
+
const fragment = doc.getXmlFragment('default')
|
|
337
|
+
|
|
338
|
+
// Parse optional frontmatter
|
|
339
|
+
const { title, meta, body } = parseFrontmatter(content)
|
|
340
|
+
|
|
341
|
+
// Update tree metadata if frontmatter provided
|
|
342
|
+
if (title || Object.keys(meta).length > 0) {
|
|
343
|
+
const treeMap = conn.getTreeMap()
|
|
344
|
+
const rootDoc = conn.rootDoc
|
|
345
|
+
if (treeMap && rootDoc) {
|
|
346
|
+
const entry = treeMap.get(docId)
|
|
347
|
+
if (entry) {
|
|
348
|
+
const e = toPlain(entry)
|
|
349
|
+
rootDoc.transact(() => {
|
|
350
|
+
const updates: Record<string, unknown> = { ...e, updatedAt: Date.now() }
|
|
351
|
+
if (title) updates.label = title
|
|
352
|
+
if (Object.keys(meta).length > 0) {
|
|
353
|
+
updates.meta = { ...(e.meta ?? {}), ...meta }
|
|
354
|
+
}
|
|
355
|
+
treeMap.set(docId, updates)
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (writeMode === 'replace') {
|
|
362
|
+
doc.transact(() => {
|
|
363
|
+
while (fragment.length > 0) {
|
|
364
|
+
fragment.delete(0)
|
|
365
|
+
}
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const contentToWrite = body || content
|
|
370
|
+
populateYDocFromMarkdown(fragment, contentToWrite, title || 'Untitled')
|
|
371
|
+
|
|
372
|
+
return `Document ${docId.slice(0, 8)}… updated (${writeMode} mode)`
|
|
373
|
+
} catch (error: any) {
|
|
374
|
+
return `Error writing document: ${error.message}`
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
registerCommand({
|
|
380
|
+
name: 'duplicate',
|
|
381
|
+
aliases: ['dup'],
|
|
382
|
+
description: 'Shallow-clone a document.',
|
|
383
|
+
usage: 'duplicate id=<docId> | name=<label>',
|
|
384
|
+
async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
|
|
385
|
+
if (!conn) return 'Not connected'
|
|
386
|
+
|
|
387
|
+
const docId = resolveDocument(conn, args.params, args.positional)
|
|
388
|
+
if (!docId) return 'Document not found.'
|
|
389
|
+
|
|
390
|
+
const treeMap = conn.getTreeMap()
|
|
391
|
+
if (!treeMap) return 'Not connected'
|
|
392
|
+
|
|
393
|
+
const raw = treeMap.get(docId)
|
|
394
|
+
if (!raw) return `Document ${docId} not found.`
|
|
395
|
+
|
|
396
|
+
const entry = toPlain(raw)
|
|
397
|
+
const newId = crypto.randomUUID()
|
|
398
|
+
treeMap.set(newId, {
|
|
399
|
+
...entry,
|
|
400
|
+
label: (entry.label || 'Untitled') + ' (copy)',
|
|
401
|
+
order: Date.now(),
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
return `Duplicated: ${newId.slice(0, 8)}… "${entry.label} (copy)"`
|
|
405
|
+
},
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
/** Helper to read all of stdin as a string. */
|
|
409
|
+
function readStdin(): Promise<string> {
|
|
410
|
+
return new Promise((resolve, reject) => {
|
|
411
|
+
let data = ''
|
|
412
|
+
process.stdin.setEncoding('utf-8')
|
|
413
|
+
process.stdin.on('data', (chunk: string) => { data += chunk })
|
|
414
|
+
process.stdin.on('end', () => resolve(data))
|
|
415
|
+
process.stdin.on('error', reject)
|
|
416
|
+
// Safety timeout — don't hang forever
|
|
417
|
+
setTimeout(() => resolve(data), 5000)
|
|
418
|
+
})
|
|
419
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File attachment commands: upload, uploads.
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from 'node:fs'
|
|
5
|
+
import * as nodePath from 'node:path'
|
|
6
|
+
import { registerCommand } from '../command.ts'
|
|
7
|
+
import type { CLIConnection } from '../connection.ts'
|
|
8
|
+
import type { ParsedArgs } from '../parser.ts'
|
|
9
|
+
import { resolveDocument } from '../resolve.ts'
|
|
10
|
+
import { printJson, printTable, getFormat } from '../output.ts'
|
|
11
|
+
|
|
12
|
+
registerCommand({
|
|
13
|
+
name: 'uploads',
|
|
14
|
+
aliases: ['attachments'],
|
|
15
|
+
description: 'List file attachments for a document.',
|
|
16
|
+
usage: 'uploads id=<docId> | name=<label> [--format=json|tsv] [--total]',
|
|
17
|
+
async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
|
|
18
|
+
if (!conn) return 'Not connected'
|
|
19
|
+
|
|
20
|
+
const docId = resolveDocument(conn, args.params, args.positional)
|
|
21
|
+
if (!docId) return 'Document not found.'
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const uploads = await conn.client.listUploads(docId)
|
|
25
|
+
|
|
26
|
+
if (args.flags.has('total')) {
|
|
27
|
+
return String(uploads.length)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const format = getFormat(args, 'text')
|
|
31
|
+
|
|
32
|
+
if (format === 'json') {
|
|
33
|
+
return printJson(uploads)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (uploads.length === 0) {
|
|
37
|
+
return 'No attachments.'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const rows = uploads.map(u => [
|
|
41
|
+
u.id.slice(0, 8) + '…',
|
|
42
|
+
u.filename,
|
|
43
|
+
u.mime_type ?? '—',
|
|
44
|
+
u.size ? formatBytes(u.size) : '—',
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
return printTable(rows, ['ID', 'FILENAME', 'TYPE', 'SIZE'])
|
|
48
|
+
} catch (error: any) {
|
|
49
|
+
return `Error: ${error.message}`
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
registerCommand({
|
|
55
|
+
name: 'upload',
|
|
56
|
+
description: 'Upload a local file to a document.',
|
|
57
|
+
usage: 'upload id=<docId> | name=<label> file=<localPath> [filename=<override>]',
|
|
58
|
+
async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
|
|
59
|
+
if (!conn) return 'Not connected'
|
|
60
|
+
|
|
61
|
+
const docId = resolveDocument(conn, args.params, args.positional)
|
|
62
|
+
if (!docId) return 'Document not found.'
|
|
63
|
+
|
|
64
|
+
const filePath = args.params['file'] || args.params['path']
|
|
65
|
+
if (!filePath) return 'Missing required parameter: file=<localPath>'
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const resolvedPath = nodePath.resolve(filePath)
|
|
69
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
70
|
+
return `File not found: ${resolvedPath}`
|
|
71
|
+
}
|
|
72
|
+
const data = fs.readFileSync(resolvedPath)
|
|
73
|
+
const filename = args.params['filename'] ?? nodePath.basename(resolvedPath)
|
|
74
|
+
const blob = new Blob([data])
|
|
75
|
+
const result = await conn.client.upload(docId, blob, filename)
|
|
76
|
+
|
|
77
|
+
return `Uploaded: ${filename} (${formatBytes(data.length)}) → ${result.id}`
|
|
78
|
+
} catch (error: any) {
|
|
79
|
+
return `Error: ${error.message}`
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
registerCommand({
|
|
85
|
+
name: 'download',
|
|
86
|
+
description: 'Download a file attachment from a document.',
|
|
87
|
+
usage: 'download id=<docId> | name=<label> upload=<uploadId> output=<localPath>',
|
|
88
|
+
async run(conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
|
|
89
|
+
if (!conn) return 'Not connected'
|
|
90
|
+
|
|
91
|
+
const docId = resolveDocument(conn, args.params, args.positional)
|
|
92
|
+
if (!docId) return 'Document not found.'
|
|
93
|
+
|
|
94
|
+
const uploadId = args.params['upload'] || args.params['uploadId']
|
|
95
|
+
if (!uploadId) return 'Missing required parameter: upload=<uploadId>'
|
|
96
|
+
|
|
97
|
+
const outputPath = args.params['output'] || args.params['to']
|
|
98
|
+
if (!outputPath) return 'Missing required parameter: output=<localPath>'
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const blob = await conn.client.getUpload(docId, uploadId)
|
|
102
|
+
const buffer = Buffer.from(await blob.arrayBuffer())
|
|
103
|
+
const resolvedPath = nodePath.resolve(outputPath)
|
|
104
|
+
fs.writeFileSync(resolvedPath, buffer)
|
|
105
|
+
|
|
106
|
+
return `Downloaded to ${resolvedPath} (${formatBytes(buffer.length)})`
|
|
107
|
+
} catch (error: any) {
|
|
108
|
+
return `Error: ${error.message}`
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
function formatBytes(bytes: number): string {
|
|
114
|
+
if (bytes < 1024) return `${bytes} B`
|
|
115
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
116
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
117
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* help + version commands.
|
|
3
|
+
*/
|
|
4
|
+
import { registerCommand, getAllCommands } from '../command.ts'
|
|
5
|
+
import type { CLIConnection } from '../connection.ts'
|
|
6
|
+
import type { ParsedArgs } from '../parser.ts'
|
|
7
|
+
import { pad } from '../output.ts'
|
|
8
|
+
|
|
9
|
+
registerCommand({
|
|
10
|
+
name: 'help',
|
|
11
|
+
aliases: ['h', '?'],
|
|
12
|
+
description: 'Show list of all available commands.',
|
|
13
|
+
usage: 'help [<command>]',
|
|
14
|
+
async run(_conn: CLIConnection | null, args: ParsedArgs): Promise<string> {
|
|
15
|
+
const target = args.positional[0]
|
|
16
|
+
|
|
17
|
+
if (target) {
|
|
18
|
+
const cmd = getAllCommands().find(c =>
|
|
19
|
+
c.name === target || c.aliases?.includes(target)
|
|
20
|
+
)
|
|
21
|
+
if (!cmd) {
|
|
22
|
+
return `Unknown command: ${target}\n\nRun 'abracadabra help' to see all commands.`
|
|
23
|
+
}
|
|
24
|
+
const aliasStr = cmd.aliases?.length ? ` (aliases: ${cmd.aliases.join(', ')})` : ''
|
|
25
|
+
let text = `${cmd.name}${aliasStr}\n ${cmd.description}`
|
|
26
|
+
if (cmd.usage) text += `\n\n Usage: abracadabra ${cmd.usage}`
|
|
27
|
+
return text
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const cmds = getAllCommands()
|
|
31
|
+
const maxLen = Math.max(...cmds.map(c => c.name.length))
|
|
32
|
+
|
|
33
|
+
const lines = [
|
|
34
|
+
'Abracadabra CLI — interact with CRDT document workspaces from the terminal.',
|
|
35
|
+
'',
|
|
36
|
+
'Usage: abracadabra <command> [key=value ...] [--flags]',
|
|
37
|
+
'',
|
|
38
|
+
'Commands:',
|
|
39
|
+
...cmds.map(c => {
|
|
40
|
+
const aliasStr = c.aliases?.length ? ` (${c.aliases.join(', ')})` : ''
|
|
41
|
+
return ` ${pad(c.name, maxLen + 2)}${c.description}${aliasStr}`
|
|
42
|
+
}),
|
|
43
|
+
'',
|
|
44
|
+
'Environment:',
|
|
45
|
+
' ABRA_URL Server URL (required)',
|
|
46
|
+
' ABRA_KEY_FILE Path to Ed25519 key file',
|
|
47
|
+
' ABRA_INVITE_CODE Invite code for first-run registration',
|
|
48
|
+
' ABRA_NAME Display name',
|
|
49
|
+
' ABRA_COLOR Presence color',
|
|
50
|
+
'',
|
|
51
|
+
'Run "abracadabra help <command>" for command-specific help.',
|
|
52
|
+
]
|
|
53
|
+
return lines.join('\n')
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
registerCommand({
|
|
58
|
+
name: 'version',
|
|
59
|
+
aliases: ['v'],
|
|
60
|
+
description: 'Show CLI and server version.',
|
|
61
|
+
async run(conn: CLIConnection | null): Promise<string> {
|
|
62
|
+
const cliVersion = '1.0.0'
|
|
63
|
+
const lines = [`Abracadabra CLI v${cliVersion}`]
|
|
64
|
+
if (conn?.serverInfo) {
|
|
65
|
+
const si = conn.serverInfo
|
|
66
|
+
if (si.version) lines.push(`Server v${si.version}`)
|
|
67
|
+
if (si.name) lines.push(`Server name ${si.name}`)
|
|
68
|
+
if (si.protocol_version) lines.push(`Protocol ${si.protocol_version}`)
|
|
69
|
+
}
|
|
70
|
+
return lines.join('\n')
|
|
71
|
+
},
|
|
72
|
+
})
|