@remotedraw/cli 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.
@@ -0,0 +1,469 @@
1
+ import { spawn } from "node:child_process";
2
+ import { emitKeypressEvents } from "node:readline";
3
+ export function initialWizardState(definition) {
4
+ return {
5
+ screen: "field",
6
+ fieldIndex: 0,
7
+ values: { ...definition.defaults },
8
+ editedFields: [],
9
+ confirmCreate: false,
10
+ };
11
+ }
12
+ function activeField(state, definition) {
13
+ return definition.fields[state.fieldIndex];
14
+ }
15
+ function fieldError(state, definition) {
16
+ const field = activeField(state, definition);
17
+ return field.validate?.(state.values[field.key]);
18
+ }
19
+ function updateField(state, definition, value) {
20
+ const field = activeField(state, definition);
21
+ const values = definition.normalize({ ...state.values, [field.key]: value }, field.key);
22
+ return { ...state, values, error: undefined };
23
+ }
24
+ export function transitionWizard(state, action, definition) {
25
+ if (["success", "cancelled", "interrupted"].includes(state.screen)) {
26
+ return state;
27
+ }
28
+ if (action.type === "interrupt")
29
+ return { ...state, screen: "interrupted" };
30
+ if (action.type === "cancel" && state.screen !== "executing") {
31
+ return { ...state, screen: "cancelled" };
32
+ }
33
+ if (action.type === "executionSucceeded" && state.screen === "executing") {
34
+ return {
35
+ ...state,
36
+ screen: "success",
37
+ createdPath: action.path,
38
+ nextCommand: action.nextCommand,
39
+ };
40
+ }
41
+ if (action.type === "executionFailed" && state.screen === "executing") {
42
+ return { ...state, screen: "error", error: action.message };
43
+ }
44
+ if (state.screen === "error")
45
+ return state;
46
+ if (state.screen === "review") {
47
+ if (action.type === "back") {
48
+ return {
49
+ ...state,
50
+ screen: "field",
51
+ fieldIndex: definition.fields.length - 1,
52
+ };
53
+ }
54
+ if (action.type === "forward") {
55
+ return { ...state, screen: "confirm", confirmCreate: false };
56
+ }
57
+ return state;
58
+ }
59
+ if (state.screen === "confirm") {
60
+ if (action.type === "back")
61
+ return { ...state, screen: "review" };
62
+ if (action.type === "previousChoice" || action.type === "nextChoice") {
63
+ return { ...state, confirmCreate: !state.confirmCreate };
64
+ }
65
+ if (action.type === "forward") {
66
+ return state.confirmCreate
67
+ ? { ...state, screen: "executing" }
68
+ : { ...state, screen: "cancelled" };
69
+ }
70
+ return state;
71
+ }
72
+ if (state.screen !== "field")
73
+ return state;
74
+ const field = activeField(state, definition);
75
+ if (action.type === "character" &&
76
+ action.value.length > 0 &&
77
+ field.kind === "text") {
78
+ const editedFields = state.editedFields.includes(field.key)
79
+ ? state.editedFields
80
+ : [...state.editedFields, field.key];
81
+ const next = updateField(state, definition, state.editedFields.includes(field.key)
82
+ ? state.values[field.key] + action.value
83
+ : action.value);
84
+ return { ...next, editedFields };
85
+ }
86
+ if (action.type === "backspace" && field.kind === "text") {
87
+ const wasEdited = state.editedFields.includes(field.key);
88
+ const next = updateField(state, definition, wasEdited ? state.values[field.key].slice(0, -1) : "");
89
+ return {
90
+ ...next,
91
+ editedFields: wasEdited
92
+ ? state.editedFields
93
+ : [...state.editedFields, field.key],
94
+ };
95
+ }
96
+ if ((action.type === "previousChoice" || action.type === "nextChoice") &&
97
+ field.kind === "select") {
98
+ const choices = field.choices?.(state.values) ?? [];
99
+ if (choices.length === 0)
100
+ return state;
101
+ const current = Math.max(0, choices.findIndex((choice) => choice.value === state.values[field.key]));
102
+ const offset = action.type === "nextChoice" ? 1 : -1;
103
+ const next = (current + offset + choices.length) % choices.length;
104
+ return updateField(state, definition, choices[next].value);
105
+ }
106
+ if (action.type === "back") {
107
+ return state.fieldIndex === 0
108
+ ? { ...state, screen: "cancelled" }
109
+ : { ...state, fieldIndex: state.fieldIndex - 1, error: undefined };
110
+ }
111
+ if (action.type === "forward") {
112
+ const validation = fieldError(state, definition);
113
+ if (validation)
114
+ return { ...state, error: validation };
115
+ return state.fieldIndex === definition.fields.length - 1
116
+ ? { ...state, screen: "review", error: undefined }
117
+ : { ...state, fieldIndex: state.fieldIndex + 1, error: undefined };
118
+ }
119
+ return state;
120
+ }
121
+ export function wizardSemanticView(state, definition) {
122
+ if (state.screen === "field") {
123
+ const field = activeField(state, definition);
124
+ const choices = field.choices?.(state.values);
125
+ const selectedChoice = choices?.find((choice) => choice.value === state.values[field.key]);
126
+ return {
127
+ screen: state.screen,
128
+ title: "Een RemoteDraw-project maken",
129
+ progress: {
130
+ current: state.fieldIndex + 1,
131
+ total: definition.fields.length,
132
+ },
133
+ activeLabel: field.label,
134
+ value: state.values[field.key],
135
+ valueIsDefault: field.kind === "text" && !state.editedFields.includes(field.key),
136
+ choices,
137
+ selectedChoice: state.values[field.key],
138
+ selectedDetails: selectedChoice?.details,
139
+ validation: state.error,
140
+ controls: field.kind === "text"
141
+ ? [
142
+ !state.editedFields.includes(field.key)
143
+ ? "Typ om de standaardnaam te vervangen"
144
+ : "Typ om te wijzigen",
145
+ "←/→ stappen",
146
+ "Enter volgende",
147
+ "Esc terug",
148
+ "Ctrl+C stoppen",
149
+ ]
150
+ : [
151
+ "↑/↓ kiezen",
152
+ ...(selectedChoice?.details ? ["d docs openen"] : []),
153
+ "←/→ stappen",
154
+ "Enter volgende",
155
+ "Esc terug",
156
+ "q stoppen",
157
+ ],
158
+ };
159
+ }
160
+ if (state.screen === "review") {
161
+ return {
162
+ screen: state.screen,
163
+ title: "Project controleren",
164
+ review: definition.review(state.values),
165
+ controls: ["→/Enter doorgaan", "←/Esc terug", "q stoppen"],
166
+ };
167
+ }
168
+ if (state.screen === "confirm") {
169
+ return {
170
+ screen: state.screen,
171
+ title: "Projectbestanden maken?",
172
+ choices: [
173
+ { value: "no", label: "Nee, annuleren" },
174
+ { value: "yes", label: "Ja, project maken" },
175
+ ],
176
+ selectedChoice: state.confirmCreate ? "yes" : "no",
177
+ controls: ["↑/↓ kiezen", "Enter bevestigen", "←/Esc terug"],
178
+ };
179
+ }
180
+ return {
181
+ screen: state.screen,
182
+ title: state.screen === "executing"
183
+ ? "Project wordt gemaakt…"
184
+ : state.screen === "success"
185
+ ? "RemoteDraw-project gemaakt"
186
+ : state.screen === "error"
187
+ ? "Project maken mislukt"
188
+ : state.screen === "interrupted"
189
+ ? "Installatie onderbroken"
190
+ : "Installatie geannuleerd",
191
+ controls: [],
192
+ createdPath: state.createdPath,
193
+ nextCommand: state.nextCommand,
194
+ error: state.error,
195
+ };
196
+ }
197
+ export async function runSetupWizard(options) {
198
+ let state = initialWizardState(options.definition);
199
+ try {
200
+ await options.terminal.enter();
201
+ if (options.terminal.color) {
202
+ for (let frame = 0; frame < BANNER_ANIMATION_FRAMES; frame += 1) {
203
+ options.terminal.write(renderWizard(state, options.definition, options.terminal, frame));
204
+ if (frame < BANNER_ANIMATION_FRAMES - 1) {
205
+ await pause(BANNER_ANIMATION_DELAY_MS);
206
+ }
207
+ }
208
+ }
209
+ while (true) {
210
+ options.terminal.write(renderWizard(state, options.definition, options.terminal, BANNER_ANIMATION_FRAMES - 1));
211
+ if (state.screen === "executing") {
212
+ try {
213
+ const result = await options.execute(state.values);
214
+ state = transitionWizard(state, { type: "executionSucceeded", ...result }, options.definition);
215
+ }
216
+ catch (error) {
217
+ state = transitionWizard(state, {
218
+ type: "executionFailed",
219
+ message: `${error instanceof Error ? error.message : String(error)}\nControleer de doelmap en de toegangsrechten.`,
220
+ }, options.definition);
221
+ }
222
+ continue;
223
+ }
224
+ if (state.screen === "success" || state.screen === "cancelled") {
225
+ return { exitCode: 0, state };
226
+ }
227
+ if (state.screen === "error")
228
+ return { exitCode: 1, state };
229
+ if (state.screen === "interrupted")
230
+ return { exitCode: 130, state };
231
+ const key = await options.terminal.readKey();
232
+ const selectedDetails = wizardSemanticView(state, options.definition).selectedDetails;
233
+ if (key.name === "d" && selectedDetails) {
234
+ if (!options.terminal.openUrl) {
235
+ state = {
236
+ ...state,
237
+ error: `Open de documentatie via ${selectedDetails.docsUrl}`,
238
+ };
239
+ continue;
240
+ }
241
+ try {
242
+ await options.terminal.openUrl(selectedDetails.docsUrl);
243
+ }
244
+ catch {
245
+ state = {
246
+ ...state,
247
+ error: `Kon de documentatie niet openen. Gebruik ${selectedDetails.docsUrl}`,
248
+ };
249
+ }
250
+ continue;
251
+ }
252
+ state = transitionWizard(state, actionForKey(key, state), options.definition);
253
+ }
254
+ }
255
+ finally {
256
+ await options.terminal.restore();
257
+ }
258
+ }
259
+ export function actionForKey(key, state) {
260
+ if (key.ctrl && key.name === "c")
261
+ return { type: "interrupt" };
262
+ if (key.name === "escape")
263
+ return { type: "back" };
264
+ if (key.name === "return" || key.name === "enter")
265
+ return { type: "forward" };
266
+ if (key.name === "left") {
267
+ return state.screen === "field" && state.fieldIndex === 0
268
+ ? { type: "character", value: "" }
269
+ : { type: "back" };
270
+ }
271
+ if (key.name === "right")
272
+ return { type: "forward" };
273
+ if (key.name === "up")
274
+ return { type: "previousChoice" };
275
+ if (key.name === "down")
276
+ return { type: "nextChoice" };
277
+ if (key.name === "backspace")
278
+ return { type: "backspace" };
279
+ if (key.name === "q" && (state.screen !== "field" || state.fieldIndex > 0)) {
280
+ return { type: "cancel" };
281
+ }
282
+ if (state.screen === "field" &&
283
+ key.sequence &&
284
+ !key.ctrl &&
285
+ key.sequence >= " ") {
286
+ return { type: "character", value: key.sequence };
287
+ }
288
+ return { type: "character", value: "" };
289
+ }
290
+ function paint(enabled, code, value) {
291
+ return enabled ? `\x1b[${code}m${value}\x1b[0m` : value;
292
+ }
293
+ function hyperlink(enabled, url, label) {
294
+ return enabled ? `\x1b]8;;${url}\x1b\\${label}\x1b]8;;\x1b\\` : label;
295
+ }
296
+ async function openExternalUrl(url) {
297
+ const parsed = new URL(url);
298
+ if (parsed.protocol !== "https:") {
299
+ throw new Error("Only HTTPS documentation links can be opened.");
300
+ }
301
+ const [command, args] = process.platform === "darwin"
302
+ ? ["open", [url]]
303
+ : process.platform === "win32"
304
+ ? ["cmd", ["/c", "start", "", url]]
305
+ : ["xdg-open", [url]];
306
+ await new Promise((resolve, reject) => {
307
+ const child = spawn(command, args, {
308
+ detached: true,
309
+ stdio: "ignore",
310
+ windowsHide: true,
311
+ });
312
+ child.once("error", reject);
313
+ child.once("spawn", () => {
314
+ child.unref();
315
+ resolve();
316
+ });
317
+ });
318
+ }
319
+ function wrapText(value, width) {
320
+ const words = value.trim().split(/\s+/);
321
+ const lines = [];
322
+ let line = "";
323
+ for (const word of words) {
324
+ if (word.length > width) {
325
+ if (line) {
326
+ lines.push(line);
327
+ line = "";
328
+ }
329
+ let remainder = word;
330
+ while (remainder.length > width) {
331
+ lines.push(remainder.slice(0, width));
332
+ remainder = remainder.slice(width);
333
+ }
334
+ line = remainder;
335
+ continue;
336
+ }
337
+ if (line.length > 0 && line.length + word.length + 1 > width) {
338
+ lines.push(line);
339
+ line = word;
340
+ }
341
+ else {
342
+ line = line ? `${line} ${word}` : word;
343
+ }
344
+ }
345
+ if (line)
346
+ lines.push(line);
347
+ return lines;
348
+ }
349
+ function renderChoiceDetails(details, terminal) {
350
+ const width = Math.max(20, Math.min(72, terminal.columns - 4));
351
+ const border = (value) => paint(terminal.color, "36", value);
352
+ const contentLine = (value = "", rendered = value) => `${border("│")} ${rendered}${" ".repeat(Math.max(0, width - value.length))} ${border("│")}`;
353
+ const button = `[ ${details.docsLabel} ↗ ]`;
354
+ const header = "─ Keuzehulp ";
355
+ const lines = [
356
+ `${border("┌")}${paint(terminal.color, "1;36", header)}${border("─".repeat(width + 2 - header.length))}${border("┐")}`,
357
+ ...wrapText(details.summary, width).map((line) => contentLine(line, paint(terminal.color, "1", line))),
358
+ contentLine(),
359
+ ...wrapText(details.explanation, width).map((line) => contentLine(line)),
360
+ contentLine(),
361
+ contentLine(button, hyperlink(terminal.hyperlinks === true, details.docsUrl, paint(terminal.color, "1;96", button))),
362
+ ];
363
+ if (terminal.hyperlinks !== true) {
364
+ lines.push(...wrapText(details.docsUrl, width).map((line) => contentLine(line, paint(terminal.color, "2", line))));
365
+ }
366
+ lines.push(`${border("└")}${border("─".repeat(width + 2))}${border("┘")}`);
367
+ return lines;
368
+ }
369
+ const BANNER_COLORS = ["36", "96", "94", "34"];
370
+ const BANNER_ANIMATION_FRAMES = BANNER_COLORS.length;
371
+ const BANNER_ANIMATION_DELAY_MS = 45;
372
+ function pause(milliseconds) {
373
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
374
+ }
375
+ export function renderWizard(state, definition, terminal, bannerFrame = 0) {
376
+ const view = wizardSemanticView(state, definition);
377
+ const narrow = terminal.columns < 60;
378
+ const wordmark = narrow
379
+ ? ["RemoteDraw", "telefoon → canvas"]
380
+ : [
381
+ " ____ _ ____ ",
382
+ "| _ \\ ___ _ __ ___ ___ | |_ ___| _ \\ _ __ __ ___ __",
383
+ "| |_) / _ \\ '_ ` _ \\ / _ \\| __/ _ \\ | | | '__/ _` \\ \\ /\\ / /",
384
+ "| _ < __/ | | | | | (_) | || __/ |_| | | | (_| |\\ V V / ",
385
+ "|_| \\_\\___|_| |_| |_|\\___/ \\__\\___|____/|_| \\__,_| \\_/\\_/ ",
386
+ ];
387
+ const lines = [
388
+ "\x1b[2J\x1b[H",
389
+ ...wordmark.map((line, index) => paint(terminal.color, BANNER_COLORS[(index + bannerFrame) % BANNER_COLORS.length], line)),
390
+ "",
391
+ ];
392
+ if (view.progress) {
393
+ lines.push(`Stap ${view.progress.current}/${view.progress.total} · ${view.title}`, "");
394
+ }
395
+ else {
396
+ lines.push(view.title, "");
397
+ }
398
+ if (view.activeLabel) {
399
+ if (view.choices) {
400
+ lines.push(paint(terminal.color, "1;36", view.activeLabel));
401
+ for (const choice of view.choices) {
402
+ const selected = choice.value === view.selectedChoice;
403
+ lines.push(paint(terminal.color && selected, "1;96", `${selected ? ">" : " "} [${selected ? "x" : " "}] ${choice.label}`));
404
+ if (!narrow && choice.hint)
405
+ lines.push(` ${choice.hint}`);
406
+ }
407
+ }
408
+ else {
409
+ lines.push(paint(terminal.color, "1;36", view.activeLabel), `${paint(terminal.color, "36", "│")} ${paint(terminal.color, view.valueIsDefault ? "2" : "1", view.value || "_")}${paint(terminal.color, "96", "▌")}${view.valueIsDefault
410
+ ? ` ${paint(terminal.color, "2", "(standaard)")}`
411
+ : ""}`);
412
+ }
413
+ if (view.selectedDetails) {
414
+ lines.push("", ...renderChoiceDetails(view.selectedDetails, terminal));
415
+ }
416
+ if (view.validation)
417
+ lines.push("", `! ${view.validation}`);
418
+ }
419
+ if (view.review) {
420
+ for (const [label, value] of view.review)
421
+ lines.push(`${label}: ${value}`);
422
+ }
423
+ if (view.choices && !view.activeLabel) {
424
+ for (const choice of view.choices) {
425
+ const selected = choice.value === view.selectedChoice;
426
+ lines.push(`${selected ? ">" : " "} [${selected ? "x" : " "}] ${choice.label}`);
427
+ }
428
+ }
429
+ if (view.createdPath)
430
+ lines.push(`Gemaakt in: ${view.createdPath}`);
431
+ if (view.nextCommand)
432
+ lines.push("", "Volgende opdracht:", ` ${view.nextCommand}`);
433
+ if (view.error)
434
+ lines.push(view.error);
435
+ if (view.controls.length)
436
+ lines.push("", view.controls.join(" · "));
437
+ return `${lines.join("\n")}\n`;
438
+ }
439
+ export function createWizardTerminal(input, output, env) {
440
+ const wasRaw = input.isRaw === true;
441
+ const wasFlowing = input.readableFlowing === true;
442
+ return {
443
+ columns: output.columns || 80,
444
+ color: env.NO_COLOR == null && output.hasColors?.() !== false,
445
+ hyperlinks: env.TERM !== "dumb" && env.NO_HYPERLINKS == null,
446
+ write(value) {
447
+ output.write(value);
448
+ },
449
+ enter() {
450
+ emitKeypressEvents(input);
451
+ input.setRawMode?.(true);
452
+ input.resume();
453
+ output.write("\x1b[?25l");
454
+ },
455
+ async readKey() {
456
+ return await new Promise((resolve) => {
457
+ input.once("keypress", (_text, key) => resolve(key));
458
+ });
459
+ },
460
+ openUrl: openExternalUrl,
461
+ restore() {
462
+ if (!wasRaw)
463
+ input.setRawMode?.(false);
464
+ if (!wasFlowing)
465
+ input.pause();
466
+ output.write("\x1b[?25h\x1b[0m\n");
467
+ },
468
+ };
469
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@remotedraw/cli",
3
+ "version": "0.1.0",
4
+ "description": "Command-line tools for creating and inspecting RemoteDraw integrations.",
5
+ "type": "module",
6
+ "bin": {
7
+ "remotedraw": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/AxioSOzo/RemoteDraw.git",
19
+ "directory": "packages/cli"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/AxioSOzo/RemoteDraw/issues"
23
+ },
24
+ "homepage": "https://github.com/AxioSOzo/RemoteDraw#readme",
25
+ "keywords": [
26
+ "remotedraw",
27
+ "drawing",
28
+ "cli"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public",
32
+ "registry": "https://registry.npmjs.org/"
33
+ },
34
+ "scripts": {
35
+ "build": "tsc -p tsconfig.json",
36
+ "dev": "bun run src/index.ts",
37
+ "test": "bun test tests/*.test.ts",
38
+ "typecheck": "tsc -p tsconfig.json --noEmit",
39
+ "verify:publish": "bun run build && bun run typecheck && bun run test && node dist/index.js --help && node dist/index.js --version",
40
+ "prepack": "bun run verify:publish"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "25.6.2",
44
+ "typescript": "6.0.3"
45
+ }
46
+ }