@drael/code 0.1.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/package.json +25 -0
- package/src/code.js +469 -0
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@drael/code",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Point your coding client at Drael.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"drael": "src/code.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"ci": "node --check src/code.js"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@clack/core": "^1.0.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/code.js
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Drael, into the editor you already use.
|
|
4
|
+
*
|
|
5
|
+
* npx @drael/code interactive
|
|
6
|
+
* npx @drael/code --key … --client … answered up front
|
|
7
|
+
* npx @drael/code --list what it knows how to configure
|
|
8
|
+
* npx @drael/code --dry-run … print what it would write, change nothing
|
|
9
|
+
* npx @drael/code --uninstall restore every file it changed
|
|
10
|
+
*
|
|
11
|
+
* It runs through npx rather than `curl | sh` deliberately. Piping a URL into a
|
|
12
|
+
* shell is exactly the pattern this product's own users are right to refuse, and
|
|
13
|
+
* npx is already on the machine of anybody with an editor extension. It also makes
|
|
14
|
+
* Windows work without a second implementation.
|
|
15
|
+
*
|
|
16
|
+
* It writes nothing but the target client's own configuration file, copies what it
|
|
17
|
+
* touches before touching it, and asks for no privilege. The docs link to this file
|
|
18
|
+
* on purpose, because an installer earns trust by being short enough to read.
|
|
19
|
+
*/
|
|
20
|
+
import {
|
|
21
|
+
chmodSync,
|
|
22
|
+
copyFileSync,
|
|
23
|
+
existsSync,
|
|
24
|
+
mkdirSync,
|
|
25
|
+
readdirSync,
|
|
26
|
+
readFileSync,
|
|
27
|
+
rmSync,
|
|
28
|
+
writeFileSync,
|
|
29
|
+
} from 'node:fs'
|
|
30
|
+
import { homedir, platform } from 'node:os'
|
|
31
|
+
import { dirname, join } from 'node:path'
|
|
32
|
+
import process from 'node:process'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `@clack/core` rather than `@clack/prompts`: the latter draws its own rail and
|
|
36
|
+
* colours its own symbols, and both are its identity rather than ours. Core is the
|
|
37
|
+
* half underneath, which reads raw mode, tracks the keys, validates and carries the
|
|
38
|
+
* cancel symbol, and asks each prompt to draw itself. The drawing here is Drael's.
|
|
39
|
+
*/
|
|
40
|
+
import { isCancel, PasswordPrompt, SelectPrompt } from '@clack/core'
|
|
41
|
+
|
|
42
|
+
/* ───────────────────────────────────────────────────────────── where things live ── */
|
|
43
|
+
|
|
44
|
+
const CONFIG_HOME =
|
|
45
|
+
process.env.XDG_CONFIG_HOME ??
|
|
46
|
+
(platform() === 'win32'
|
|
47
|
+
? join(process.env.APPDATA ?? homedir(), 'Config')
|
|
48
|
+
: join(homedir(), '.config'))
|
|
49
|
+
|
|
50
|
+
const STATE_HOME =
|
|
51
|
+
process.env.XDG_STATE_HOME ??
|
|
52
|
+
(platform() === 'win32'
|
|
53
|
+
? join(process.env.LOCALAPPDATA ?? homedir(), 'State')
|
|
54
|
+
: join(homedir(), '.local', 'state'))
|
|
55
|
+
|
|
56
|
+
const BACKUPS = join(STATE_HOME, 'drael', 'installer')
|
|
57
|
+
|
|
58
|
+
// Kilo documents this literal path on every platform, Windows included, so this one
|
|
59
|
+
// does not go through CONFIG_HOME the way the others do.
|
|
60
|
+
const KILO_CONFIG = join(homedir(), '.config', 'kilo', 'kilo.jsonc')
|
|
61
|
+
|
|
62
|
+
// There is one origin (ADR 0017), and this package is published by the people who run
|
|
63
|
+
// it. Asking for the address was the installer failing to know its own address.
|
|
64
|
+
const DRAEL = 'https://drael.sh'
|
|
65
|
+
|
|
66
|
+
/* ────────────────────────────────────────────────────────────────── the clients ── */
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What it knows how to configure. A client is added when real usage justifies it, not
|
|
70
|
+
* pre-emptively: each one is a format that can change under us, and a client nobody
|
|
71
|
+
* here runs is a client we would break silently.
|
|
72
|
+
*
|
|
73
|
+
* `merges` is the difference between a file that is ours and a file we are a guest in.
|
|
74
|
+
*/
|
|
75
|
+
const clients = {
|
|
76
|
+
opencode: {
|
|
77
|
+
label: 'OpenCode',
|
|
78
|
+
file: () => join(CONFIG_HOME, 'opencode', 'opencode.json'),
|
|
79
|
+
contents: (host, key) =>
|
|
80
|
+
json({
|
|
81
|
+
$schema: 'https://opencode.ai/config.json',
|
|
82
|
+
provider: {
|
|
83
|
+
drael: {
|
|
84
|
+
npm: '@ai-sdk/openai-compatible',
|
|
85
|
+
name: 'Drael',
|
|
86
|
+
options: { baseURL: `${host}/v1`, apiKey: key },
|
|
87
|
+
models: { drael: { name: 'Drael' } },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
model: 'drael/drael',
|
|
91
|
+
}),
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
kilo: {
|
|
95
|
+
label: 'Kilo Code',
|
|
96
|
+
merges: true,
|
|
97
|
+
file: () => KILO_CONFIG,
|
|
98
|
+
contents: (host, key) => {
|
|
99
|
+
const existing = readJson(KILO_CONFIG)
|
|
100
|
+
return json({
|
|
101
|
+
...existing,
|
|
102
|
+
$schema: 'https://app.kilo.ai/config.json',
|
|
103
|
+
provider: {
|
|
104
|
+
...existing.provider,
|
|
105
|
+
drael: {
|
|
106
|
+
options: { baseURL: `${host}/v1`, apiKey: key },
|
|
107
|
+
models: { drael: { name: 'Drael' } },
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
model: 'drael/drael',
|
|
111
|
+
})
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
env: {
|
|
116
|
+
label: 'anything reading OPENAI_BASE_URL',
|
|
117
|
+
file: () => join(CONFIG_HOME, 'drael', 'env.sh'),
|
|
118
|
+
contents: (host, key) =>
|
|
119
|
+
[
|
|
120
|
+
'# Drael. Source this, or add it to your shell profile.',
|
|
121
|
+
`export OPENAI_BASE_URL="${host}/v1"`,
|
|
122
|
+
`export OPENAI_API_KEY="${key}"`,
|
|
123
|
+
'# Some clients read the older name.',
|
|
124
|
+
`export OPENAI_API_BASE="${host}/v1"`,
|
|
125
|
+
'',
|
|
126
|
+
].join('\n'),
|
|
127
|
+
after: (file) => `add this to your shell profile\n${dim(`. ${sourceable(file)}`)}`,
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const json = (value) => `${JSON.stringify(value, null, 2)}\n`
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Kilo's file is the whole extension's configuration and not ours, so ours is merged
|
|
135
|
+
* into what is already there. A file that does not parse is treated as absent rather
|
|
136
|
+
* than guessed at: JSONC allows comments, and a parser guessing at them writes back a
|
|
137
|
+
* mangled config. The copy under BACKUPS still holds the original, and `parses` below
|
|
138
|
+
* is what lets the frame say so before it writes.
|
|
139
|
+
*/
|
|
140
|
+
function readJson(file) {
|
|
141
|
+
try {
|
|
142
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
143
|
+
} catch {
|
|
144
|
+
return {}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function parses(file) {
|
|
149
|
+
if (!existsSync(file)) {
|
|
150
|
+
return true
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
JSON.parse(readFileSync(file, 'utf8'))
|
|
154
|
+
return true
|
|
155
|
+
} catch {
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* ────────────────────────────────────────────────────────────────── the drawing ── */
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The system is monochrome and its structure is a ruled grid with markers at the
|
|
164
|
+
* crossings, never a card and never a fill (`packages/ui/DESIGN.md`). A page has three
|
|
165
|
+
* values for that, `--figure`, `--secondary` and `--tertiary`; a terminal has weight
|
|
166
|
+
* instead, so these three stand in for them in the same order.
|
|
167
|
+
*
|
|
168
|
+
* All of it goes plain when stdout is not a terminal or NO_COLOR is set, because a
|
|
169
|
+
* piped log is read by something that does not draw.
|
|
170
|
+
*/
|
|
171
|
+
const styled = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
|
|
172
|
+
const bold = (text) => (styled ? `\u001b[1m${text}\u001b[22m` : text)
|
|
173
|
+
const dim = (text) => (styled ? `\u001b[2m${text}\u001b[22m` : text)
|
|
174
|
+
|
|
175
|
+
// The label column, wide enough for the longest label with a gutter after it.
|
|
176
|
+
const LABEL = 13
|
|
177
|
+
const WIDTH = Math.max(48, Math.min(process.stdout.columns || 80, 78))
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A row of the grid: the label on the left rail, what it names on the right. Content
|
|
181
|
+
* that runs to a second line is indented into the same column, so the rail stays a rail.
|
|
182
|
+
*/
|
|
183
|
+
function row(label, content = '') {
|
|
184
|
+
const gutter = ' '.repeat(LABEL + 2)
|
|
185
|
+
return ` ${dim(label.padEnd(LABEL))}${String(content).replaceAll('\n', `\n${gutter}`)}`
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** A row with no label, sitting under the one above it. */
|
|
189
|
+
const under = (content) => row('', content)
|
|
190
|
+
|
|
191
|
+
/** A hairline, marked where the label column crosses it. */
|
|
192
|
+
const rule = () => dim(` ${'─'.repeat(LABEL - 1)}┬${'─'.repeat(WIDTH - LABEL - 3)}`)
|
|
193
|
+
|
|
194
|
+
/** A home-relative path is what the reader recognises; the absolute one is a wall. */
|
|
195
|
+
const tilde = (path) => (path.startsWith(homedir()) ? `~${path.slice(homedir().length)}` : path)
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* For a line that gets pasted into a shell. A `~` inside quotes is not expanded and an
|
|
199
|
+
* unquoted path breaks on a space; `$HOME` survives both.
|
|
200
|
+
*/
|
|
201
|
+
const sourceable = (path) =>
|
|
202
|
+
path.startsWith(homedir()) ? `"$HOME${path.slice(homedir().length)}"` : `"${path}"`
|
|
203
|
+
|
|
204
|
+
function masthead(caption) {
|
|
205
|
+
console.log()
|
|
206
|
+
console.log(` ${bold('Drael.sh'.padEnd(LABEL))}${dim(caption)}`)
|
|
207
|
+
console.log(rule())
|
|
208
|
+
console.log()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* ────────────────────────────────────────────────────────────────── the prompts ── */
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Each prompt draws its own block and core redraws it on every key. `state` is what
|
|
215
|
+
* the prompt is doing, and the only two frames that differ are the one being answered
|
|
216
|
+
* and the one already answered, which collapses to the answer alone.
|
|
217
|
+
*/
|
|
218
|
+
async function ask(prompt) {
|
|
219
|
+
if (!process.stdin.isTTY) {
|
|
220
|
+
// Without a terminal a prompt waits forever, node exits on the unsettled await,
|
|
221
|
+
// and nothing is said about the flags that would have avoided the question.
|
|
222
|
+
fail('there is no terminal to ask on: pass --key and --client')
|
|
223
|
+
}
|
|
224
|
+
const answer = await prompt.prompt()
|
|
225
|
+
if (isCancel(answer)) {
|
|
226
|
+
console.log(row('', dim('cancelled, nothing was written')))
|
|
227
|
+
console.log()
|
|
228
|
+
process.exit(1)
|
|
229
|
+
}
|
|
230
|
+
return answer
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const askKey = (host) =>
|
|
234
|
+
ask(
|
|
235
|
+
new PasswordPrompt({
|
|
236
|
+
mask: '▪',
|
|
237
|
+
validate: (value) => (value.startsWith('dk-') ? undefined : 'a Drael key starts with dk-'),
|
|
238
|
+
render() {
|
|
239
|
+
if (this.state === 'submit') {
|
|
240
|
+
return row('api key', dim(this.masked))
|
|
241
|
+
}
|
|
242
|
+
return [
|
|
243
|
+
row('api key', this.userInputWithCursor),
|
|
244
|
+
under(dim(this.error || `create one at ${host}/developers`)),
|
|
245
|
+
].join('\n')
|
|
246
|
+
},
|
|
247
|
+
}),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
const askClient = () =>
|
|
251
|
+
ask(
|
|
252
|
+
new SelectPrompt({
|
|
253
|
+
options: Object.entries(clients).map(([value, client]) => ({ value, client })),
|
|
254
|
+
initialValue: detected(),
|
|
255
|
+
render() {
|
|
256
|
+
if (this.state === 'submit') {
|
|
257
|
+
return row('client', dim(this.value))
|
|
258
|
+
}
|
|
259
|
+
return this.options
|
|
260
|
+
.map(({ value, client }, index) => {
|
|
261
|
+
const chosen = index === this.cursor
|
|
262
|
+
const name = `${chosen ? '▸ ' : ' '}${value.padEnd(9)} `
|
|
263
|
+
const said = `${client.label}${found(client) ? ' · on this machine' : ''}`
|
|
264
|
+
return row(index === 0 ? 'client' : '', chosen ? bold(name) + said : dim(name + said))
|
|
265
|
+
})
|
|
266
|
+
.join('\n')
|
|
267
|
+
},
|
|
268
|
+
}),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Whether the client's own directory is on this machine. It is a hint and never a gate:
|
|
273
|
+
* a fresh install may not have written its directory yet, so every client stays on the
|
|
274
|
+
* list and detection only decides which one the cursor starts on.
|
|
275
|
+
*/
|
|
276
|
+
function found(client) {
|
|
277
|
+
return existsSync(dirname(client.file()))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function detected() {
|
|
281
|
+
return Object.entries(clients).find(([, client]) => found(client))?.[0] ?? 'opencode'
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/* ──────────────────────────────────────────────────────────────────── installing ── */
|
|
285
|
+
|
|
286
|
+
async function install(args) {
|
|
287
|
+
masthead('into the editor you already use')
|
|
288
|
+
|
|
289
|
+
const host = (args.host ?? process.env.DRAEL_HOST ?? DRAEL).replace(/\/+$/, '')
|
|
290
|
+
if (!/^https?:\/\//.test(host)) {
|
|
291
|
+
fail('the host must start with https:// or http://')
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const key = (args.key ?? process.env.DRAEL_KEY ?? (await askKey(host))).trim()
|
|
295
|
+
if (!key.startsWith('dk-')) {
|
|
296
|
+
fail('that does not look like a Drael key; they start with dk-')
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const name = (args.client ?? (await askClient())).trim()
|
|
300
|
+
const client = clients[name]
|
|
301
|
+
if (!client) {
|
|
302
|
+
fail(`unknown client: ${name} (try --list)`)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const file = client.file()
|
|
306
|
+
const contents = client.contents(host, key)
|
|
307
|
+
|
|
308
|
+
// An answered prompt leaves its frame on screen and needs a gap after it. With every
|
|
309
|
+
// answer given as a flag there was no frame, and the masthead's own gap is already it.
|
|
310
|
+
if (!(args.key ?? process.env.DRAEL_KEY) || !args.client) {
|
|
311
|
+
console.log()
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (args['dry-run']) {
|
|
315
|
+
console.log(row(client.merges ? 'would merge' : 'would write', tilde(file)))
|
|
316
|
+
console.log()
|
|
317
|
+
console.log(contents.trimEnd().replace(/^/gm, ' '.repeat(LABEL + 2)))
|
|
318
|
+
console.log()
|
|
319
|
+
console.log(row('', dim('nothing was written')))
|
|
320
|
+
console.log()
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const kept = backup(file)
|
|
325
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
326
|
+
writeFileSync(file, contents, { mode: 0o600 })
|
|
327
|
+
chmodSync(file, 0o600)
|
|
328
|
+
|
|
329
|
+
console.log(row(client.merges ? 'merged' : 'wrote', tilde(file)))
|
|
330
|
+
if (client.merges && !parses(file)) {
|
|
331
|
+
console.log(under(dim('it was not plain JSON, so it was replaced rather than merged')))
|
|
332
|
+
}
|
|
333
|
+
console.log(row(kept.label, dim(kept.detail)))
|
|
334
|
+
if (client.after) {
|
|
335
|
+
console.log()
|
|
336
|
+
console.log(row('and then', client.after(file)))
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
console.log()
|
|
340
|
+
console.log(rule())
|
|
341
|
+
console.log()
|
|
342
|
+
console.log(row('check it', dim(`curl ${host}/v1/models -H "Authorization: Bearer ${key}"`)))
|
|
343
|
+
console.log(row('undo it', dim('npx @drael/code --uninstall')))
|
|
344
|
+
console.log()
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Every write is preceded by a copy. An uninstall that cannot put the file back is not
|
|
349
|
+
* an uninstall, it is a deletion with better manners. It reports what it found rather
|
|
350
|
+
* than printing it, so the frame says the path once.
|
|
351
|
+
*/
|
|
352
|
+
function backup(file) {
|
|
353
|
+
mkdirSync(BACKUPS, { recursive: true })
|
|
354
|
+
const slug = file.replace(/[\\/:]/g, '_')
|
|
355
|
+
|
|
356
|
+
// Only the FIRST record is kept. What an uninstall restores is the state before this
|
|
357
|
+
// installer ever touched the file, so running it twice must not overwrite the
|
|
358
|
+
// original with our own output, and must not leave two records that disagree about
|
|
359
|
+
// whether the file existed.
|
|
360
|
+
if (existsSync(join(BACKUPS, slug)) || existsSync(join(BACKUPS, `${slug}.absent`))) {
|
|
361
|
+
return { label: 'kept', detail: 'the original was already recorded, from an earlier run' }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (existsSync(file)) {
|
|
365
|
+
copyFileSync(file, join(BACKUPS, slug))
|
|
366
|
+
// The original path beside the copy, because a slug is not reversible into a path
|
|
367
|
+
// and guessing one is how an uninstall writes to the wrong place.
|
|
368
|
+
writeFileSync(join(BACKUPS, `${slug}.path`), file)
|
|
369
|
+
return { label: 'copied', detail: `the original, to ${tilde(BACKUPS)}` }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// The file did not exist, so restoring means removing what we created.
|
|
373
|
+
writeFileSync(join(BACKUPS, `${slug}.absent`), file)
|
|
374
|
+
return { label: 'new', detail: 'it did not exist before, and uninstall removes it' }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function uninstall(args) {
|
|
378
|
+
masthead('putting back what it changed')
|
|
379
|
+
|
|
380
|
+
if (!existsSync(BACKUPS)) {
|
|
381
|
+
fail(`nothing to restore: no record at ${tilde(BACKUPS)}`)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
let restored = 0
|
|
385
|
+
for (const entry of readdirSync(BACKUPS)) {
|
|
386
|
+
if (entry.endsWith('.path')) continue
|
|
387
|
+
const saved = join(BACKUPS, entry)
|
|
388
|
+
|
|
389
|
+
if (entry.endsWith('.absent')) {
|
|
390
|
+
const file = read(saved)
|
|
391
|
+
if (existsSync(file)) {
|
|
392
|
+
if (!args['dry-run']) rmSync(file)
|
|
393
|
+
console.log(row('removed', tilde(file)))
|
|
394
|
+
restored++
|
|
395
|
+
}
|
|
396
|
+
if (!args['dry-run']) rmSync(saved)
|
|
397
|
+
continue
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// The slug is not reversible into a path on its own, so the original path is stored
|
|
401
|
+
// beside the copy rather than guessed from the name.
|
|
402
|
+
const file = read(`${saved}.path`)
|
|
403
|
+
if (!file) continue
|
|
404
|
+
if (!args['dry-run']) copyFileSync(saved, file)
|
|
405
|
+
console.log(row('restored', tilde(file)))
|
|
406
|
+
restored++
|
|
407
|
+
if (!args['dry-run']) {
|
|
408
|
+
rmSync(saved)
|
|
409
|
+
rmSync(`${saved}.path`)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
console.log()
|
|
414
|
+
console.log(row('', dim(`${restored} ${restored === 1 ? 'file' : 'files'} put back`)))
|
|
415
|
+
console.log()
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function list() {
|
|
419
|
+
console.log()
|
|
420
|
+
for (const [name, client] of Object.entries(clients)) {
|
|
421
|
+
console.log(row(name, `${dim(tilde(client.file()).padEnd(34))} ${client.label}`))
|
|
422
|
+
}
|
|
423
|
+
console.log()
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/* ─────────────────────────────────────────────────────────────────────── running ── */
|
|
427
|
+
|
|
428
|
+
function read(path) {
|
|
429
|
+
try {
|
|
430
|
+
return readFileSync(path, 'utf8').trim()
|
|
431
|
+
} catch {
|
|
432
|
+
return ''
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function fail(message) {
|
|
437
|
+
console.log(row('', bold(message)))
|
|
438
|
+
console.log()
|
|
439
|
+
process.exit(1)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function parse(argv) {
|
|
443
|
+
const parsed = {}
|
|
444
|
+
for (let i = 0; i < argv.length; i++) {
|
|
445
|
+
const arg = argv[i]
|
|
446
|
+
if (!arg.startsWith('--')) continue
|
|
447
|
+
const name = arg.slice(2)
|
|
448
|
+
if (['list', 'uninstall', 'dry-run'].includes(name)) {
|
|
449
|
+
parsed[name] = true
|
|
450
|
+
continue
|
|
451
|
+
}
|
|
452
|
+
parsed[name] = argv[++i]
|
|
453
|
+
}
|
|
454
|
+
return parsed
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// A prompt hides the cursor while it is up, and an exit that is not the prompt's own
|
|
458
|
+
// leaves it hidden in the terminal the reader goes back to.
|
|
459
|
+
process.on('exit', () => process.stdout.write('\u001b[?25h'))
|
|
460
|
+
|
|
461
|
+
const args = parse(process.argv.slice(2))
|
|
462
|
+
|
|
463
|
+
if (args.list) {
|
|
464
|
+
list()
|
|
465
|
+
} else if (args.uninstall) {
|
|
466
|
+
uninstall(args)
|
|
467
|
+
} else {
|
|
468
|
+
await install(args)
|
|
469
|
+
}
|