@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.
- package/README.md +79 -0
- package/dist/cli.d.ts +141 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +2985 -0
- package/dist/cloud.d.ts +63 -0
- package/dist/cloud.d.ts.map +1 -0
- package/dist/cloud.js +302 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/setup-wizard.d.ts +122 -0
- package/dist/setup-wizard.d.ts.map +1 -0
- package/dist/setup-wizard.js +469 -0
- package/package.json +46 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2985 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
import { emitKeypressEvents } from "node:readline";
|
|
7
|
+
import { createInterface } from "node:readline/promises";
|
|
8
|
+
import { createWizardTerminal, runSetupWizard, } from "./setup-wizard.js";
|
|
9
|
+
import { assertEnvCanAcceptProvisioning, currentCliAccount, globalConfigPath, loginToRemoteDraw, logoutFromRemoteDraw, provisionRemoteDrawProject, remoteDrawApiBaseUrl, writeProvisionedProjectSetup, } from "./cloud.js";
|
|
10
|
+
const packageMetadata = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
11
|
+
export const CLI_VERSION = typeof packageMetadata.version === "string"
|
|
12
|
+
? packageMetadata.version
|
|
13
|
+
: "unknown";
|
|
14
|
+
const targetChoices = ["web", "desktop", "ios", "headless"];
|
|
15
|
+
const senderChoices = [
|
|
16
|
+
"remotedraw-ios",
|
|
17
|
+
"embedded-web",
|
|
18
|
+
"own-ios",
|
|
19
|
+
"headless",
|
|
20
|
+
];
|
|
21
|
+
const sdkChoices = ["react", "svelte", "js", "swift", "headless"];
|
|
22
|
+
const presetChoices = [
|
|
23
|
+
"signature",
|
|
24
|
+
"initials",
|
|
25
|
+
"approval",
|
|
26
|
+
"sketch",
|
|
27
|
+
"photoMarkup",
|
|
28
|
+
"pdfMarkup",
|
|
29
|
+
"mapMarkup",
|
|
30
|
+
"screenMarkup",
|
|
31
|
+
"designReview",
|
|
32
|
+
"pointer",
|
|
33
|
+
];
|
|
34
|
+
const exampleChoices = [
|
|
35
|
+
"react-ios",
|
|
36
|
+
"react-owned-sender",
|
|
37
|
+
"raw-http",
|
|
38
|
+
"ios-owned-sender",
|
|
39
|
+
];
|
|
40
|
+
const packageManagerChoices = ["npm", "pnpm", "yarn", "bun"];
|
|
41
|
+
const apiEndpointChoices = ["deployment", "local"];
|
|
42
|
+
export function createNodeRuntime() {
|
|
43
|
+
const cwd = process.cwd();
|
|
44
|
+
const env = cliEnvironmentForWorkspace(cwd, process.env);
|
|
45
|
+
return {
|
|
46
|
+
cwd,
|
|
47
|
+
env,
|
|
48
|
+
isInteractive: Boolean(process.stdin.isTTY && process.stderr.isTTY),
|
|
49
|
+
exists: existsSync,
|
|
50
|
+
readFile: async (filePath) => {
|
|
51
|
+
return await readFile(filePath, "utf8");
|
|
52
|
+
},
|
|
53
|
+
writeFile,
|
|
54
|
+
writePrivateFile: async (filePath, contents) => {
|
|
55
|
+
await writeFile(filePath, contents, { encoding: "utf8", mode: 0o600 });
|
|
56
|
+
},
|
|
57
|
+
mkdir: async (dirPath) => {
|
|
58
|
+
await mkdir(dirPath, { recursive: true });
|
|
59
|
+
},
|
|
60
|
+
chmod,
|
|
61
|
+
fetch: globalThis.fetch.bind(globalThis),
|
|
62
|
+
openUrl: openExternalUrl,
|
|
63
|
+
notify(message) {
|
|
64
|
+
process.stderr.write(`${message}\n`);
|
|
65
|
+
},
|
|
66
|
+
prompts: createTerminalPrompter(process.stdin, process.stderr),
|
|
67
|
+
wizardTerminal: process.stdin.isTTY && process.stdout.isTTY
|
|
68
|
+
? createWizardTerminal(process.stdin, process.stdout, env)
|
|
69
|
+
: undefined,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function cliEnvironmentForWorkspace(cwd, env, exists = existsSync) {
|
|
73
|
+
if (env.REMOTEDRAW_API_BASE_URL || !env.CONVEX_SITE_URL)
|
|
74
|
+
return env;
|
|
75
|
+
const isRemoteDrawSourceWorkspace = exists(path.join(cwd, "convex/http.ts")) &&
|
|
76
|
+
exists(path.join(cwd, "packages/cli/src/index.ts"));
|
|
77
|
+
return isRemoteDrawSourceWorkspace
|
|
78
|
+
? { ...env, REMOTEDRAW_API_BASE_URL: env.CONVEX_SITE_URL }
|
|
79
|
+
: env;
|
|
80
|
+
}
|
|
81
|
+
async function openExternalUrl(url) {
|
|
82
|
+
const parsed = new URL(url);
|
|
83
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
84
|
+
throw new Error("Only HTTP(S) URLs can be opened.");
|
|
85
|
+
}
|
|
86
|
+
const [command, args] = process.platform === "darwin"
|
|
87
|
+
? ["open", [url]]
|
|
88
|
+
: process.platform === "win32"
|
|
89
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
90
|
+
: ["xdg-open", [url]];
|
|
91
|
+
await new Promise((resolve, reject) => {
|
|
92
|
+
const child = spawn(command, args, {
|
|
93
|
+
detached: true,
|
|
94
|
+
stdio: "ignore",
|
|
95
|
+
windowsHide: true,
|
|
96
|
+
});
|
|
97
|
+
child.once("error", reject);
|
|
98
|
+
child.once("spawn", () => {
|
|
99
|
+
child.unref();
|
|
100
|
+
resolve();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
function createTerminalPrompter(input, output) {
|
|
105
|
+
return {
|
|
106
|
+
intro(title) {
|
|
107
|
+
output.write(`\n${style("bold", title)}\n`);
|
|
108
|
+
},
|
|
109
|
+
async text(options) {
|
|
110
|
+
const rl = createInterface({ input, output, terminal: true });
|
|
111
|
+
try {
|
|
112
|
+
while (true) {
|
|
113
|
+
const defaultText = options.defaultValue == null
|
|
114
|
+
? ""
|
|
115
|
+
: ` ${style("dim", `(${options.defaultValue})`)}`;
|
|
116
|
+
const answer = await rl.question(`${style("cyan", "?")} ${options.message}${defaultText}: `);
|
|
117
|
+
const value = answer.trim() || options.defaultValue || "";
|
|
118
|
+
const validationError = options.validate?.(value);
|
|
119
|
+
if (validationError == null)
|
|
120
|
+
return value;
|
|
121
|
+
output.write(`${style("dim", validationError)}\n`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
rl.close();
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
async select(options) {
|
|
129
|
+
return await terminalSelect(input, output, options);
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function terminalSelect(input, output, options) {
|
|
134
|
+
if (options.choices.length === 0) {
|
|
135
|
+
throw new Error(`No choices available for ${options.message}.`);
|
|
136
|
+
}
|
|
137
|
+
if (!input.isTTY || !output.isTTY || !input.setRawMode) {
|
|
138
|
+
throw new Error("Interactive setup requires a TTY.");
|
|
139
|
+
}
|
|
140
|
+
const defaultIndex = Math.max(0, options.choices.findIndex((choiceOption) => choiceOption.value === options.defaultValue));
|
|
141
|
+
let selectedIndex = defaultIndex;
|
|
142
|
+
let renderedLines = 0;
|
|
143
|
+
const wasRaw = input.isRaw === true;
|
|
144
|
+
const wasFlowing = input.readableFlowing === true;
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
const cleanup = () => {
|
|
147
|
+
input.off("keypress", onKeypress);
|
|
148
|
+
if (!wasRaw)
|
|
149
|
+
input.setRawMode?.(false);
|
|
150
|
+
if (!wasFlowing)
|
|
151
|
+
input.pause();
|
|
152
|
+
};
|
|
153
|
+
const finish = () => {
|
|
154
|
+
const selected = options.choices[selectedIndex];
|
|
155
|
+
cleanup();
|
|
156
|
+
clearRendered(output, renderedLines);
|
|
157
|
+
output.write(`${style("cyan", "?")} ${options.message} ${style("green", selected.label)}\n`);
|
|
158
|
+
resolve(selected.value);
|
|
159
|
+
};
|
|
160
|
+
const fail = () => {
|
|
161
|
+
cleanup();
|
|
162
|
+
clearRendered(output, renderedLines);
|
|
163
|
+
output.write("\n");
|
|
164
|
+
reject(new Error("Prompt cancelled."));
|
|
165
|
+
};
|
|
166
|
+
const render = () => {
|
|
167
|
+
clearRendered(output, renderedLines);
|
|
168
|
+
const lines = selectPromptLines(options, selectedIndex);
|
|
169
|
+
output.write(`${lines.join("\n")}\n`);
|
|
170
|
+
renderedLines = lines.length;
|
|
171
|
+
};
|
|
172
|
+
function onKeypress(_inputText, key) {
|
|
173
|
+
if (key.sequence === "\u0003" || (key.ctrl && key.name === "c")) {
|
|
174
|
+
fail();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (key.name === "return" || key.name === "enter") {
|
|
178
|
+
finish();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (key.name === "down" || key.name === "j") {
|
|
182
|
+
selectedIndex = (selectedIndex + 1) % options.choices.length;
|
|
183
|
+
render();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (key.name === "up" || key.name === "k") {
|
|
187
|
+
selectedIndex =
|
|
188
|
+
(selectedIndex - 1 + options.choices.length) % options.choices.length;
|
|
189
|
+
render();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
emitKeypressEvents(input);
|
|
193
|
+
input.setRawMode(true);
|
|
194
|
+
input.resume();
|
|
195
|
+
input.on("keypress", onKeypress);
|
|
196
|
+
render();
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
function selectPromptLines(options, selectedIndex) {
|
|
200
|
+
const lines = [`${style("cyan", "?")} ${options.message}`];
|
|
201
|
+
if (options.helperText)
|
|
202
|
+
lines.push(style("dim", options.helperText));
|
|
203
|
+
for (const [index, choiceOption] of options.choices.entries()) {
|
|
204
|
+
const selected = index === selectedIndex;
|
|
205
|
+
const label = selected
|
|
206
|
+
? style("bold", choiceOption.label)
|
|
207
|
+
: choiceOption.label;
|
|
208
|
+
const hint = choiceOption.hint ? ` ${style("dim", choiceOption.hint)}` : "";
|
|
209
|
+
lines.push(`${selected ? ">" : " "} ${label}${hint}`);
|
|
210
|
+
}
|
|
211
|
+
lines.push(style("dim", "Use up/down arrows, then Enter."));
|
|
212
|
+
return lines;
|
|
213
|
+
}
|
|
214
|
+
function clearRendered(output, lineCount) {
|
|
215
|
+
if (lineCount > 0)
|
|
216
|
+
output.write(`\x1b[${lineCount}F\x1b[0J`);
|
|
217
|
+
}
|
|
218
|
+
function style(kind, value) {
|
|
219
|
+
const codes = {
|
|
220
|
+
bold: "\x1b[1m",
|
|
221
|
+
cyan: "\x1b[36m",
|
|
222
|
+
dim: "\x1b[2m",
|
|
223
|
+
green: "\x1b[32m",
|
|
224
|
+
};
|
|
225
|
+
return `${codes[kind]}${value}\x1b[0m`;
|
|
226
|
+
}
|
|
227
|
+
export async function runCli(args, runtime = createNodeRuntime()) {
|
|
228
|
+
try {
|
|
229
|
+
const command = args[0];
|
|
230
|
+
const commandArgs = args.slice(1);
|
|
231
|
+
if (command == null) {
|
|
232
|
+
if (runtime.wizardTerminal) {
|
|
233
|
+
return await setupWizardCommand(runtime);
|
|
234
|
+
}
|
|
235
|
+
return ok(mainHelpText());
|
|
236
|
+
}
|
|
237
|
+
if (command === "--help" || command === "-h") {
|
|
238
|
+
return ok(mainHelpText());
|
|
239
|
+
}
|
|
240
|
+
if (command === "--version" || command === "-v") {
|
|
241
|
+
return ok(`${CLI_VERSION}\n`);
|
|
242
|
+
}
|
|
243
|
+
switch (command) {
|
|
244
|
+
case "guide":
|
|
245
|
+
return ok(guideText());
|
|
246
|
+
case "options":
|
|
247
|
+
return optionsCommand(commandArgs);
|
|
248
|
+
case "login":
|
|
249
|
+
return await loginCommand(commandArgs, runtime);
|
|
250
|
+
case "logout":
|
|
251
|
+
return await logoutCommand(commandArgs, runtime);
|
|
252
|
+
case "whoami":
|
|
253
|
+
return await whoamiCommand(commandArgs, runtime);
|
|
254
|
+
case "new":
|
|
255
|
+
return await newCommand(commandArgs, runtime);
|
|
256
|
+
case "init":
|
|
257
|
+
return await initCommand(commandArgs, runtime);
|
|
258
|
+
case "doctor":
|
|
259
|
+
return await doctorCommand(commandArgs, runtime);
|
|
260
|
+
case "dev":
|
|
261
|
+
return await devCommand(commandArgs, runtime);
|
|
262
|
+
case "create-input":
|
|
263
|
+
return await createInputCommand(commandArgs, runtime);
|
|
264
|
+
case "examples":
|
|
265
|
+
return await examplesCommand(commandArgs, runtime);
|
|
266
|
+
case "agent":
|
|
267
|
+
return await agentCommand(commandArgs, runtime);
|
|
268
|
+
default:
|
|
269
|
+
return requestedJsonOutput(args)
|
|
270
|
+
? jsonFailure(command, new Error(`Unknown command "${command}". Run remotedraw --help.`))
|
|
271
|
+
: fail(`Unknown command "${command}". Run remotedraw --help.`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
return requestedJsonOutput(args)
|
|
276
|
+
? jsonFailure(args[0] ?? "help", error)
|
|
277
|
+
: fail(errorMessage(error));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
async function setupWizardCommand(runtime) {
|
|
281
|
+
const terminal = runtime.wizardTerminal;
|
|
282
|
+
if (!terminal)
|
|
283
|
+
return ok(mainHelpText());
|
|
284
|
+
if (!cloudSetupDisabled(runtime)) {
|
|
285
|
+
const apiBaseUrl = remoteDrawApiBaseUrl(runtime.env);
|
|
286
|
+
await loginToRemoteDraw(runtime, apiBaseUrl);
|
|
287
|
+
}
|
|
288
|
+
const result = await runSetupWizard({
|
|
289
|
+
definition: projectSetupDefinition(),
|
|
290
|
+
terminal,
|
|
291
|
+
execute: async (values) => {
|
|
292
|
+
const plan = planFromWizardValues(values);
|
|
293
|
+
const outputDir = path.resolve(runtime.cwd, plan.slug);
|
|
294
|
+
await assertProjectFilesWritable(newProjectFiles(plan), outputDir, runtime, false, false);
|
|
295
|
+
const provisioning = await maybeProvisionProject(plan, outputDir, runtime, { force: false, dryRun: false, offline: cloudSetupDisabled(runtime) });
|
|
296
|
+
await createRemoteDrawProject(plan, outputDir, runtime);
|
|
297
|
+
if (provisioning) {
|
|
298
|
+
await writeProvisionedProjectSetup(runtime, outputDir, provisioning, false);
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
path: outputDir,
|
|
302
|
+
nextCommand: `cd ${shellQuote(outputDir)} && ${installCommand(plan.packageManager)}`,
|
|
303
|
+
};
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
return { exitCode: result.exitCode, stdout: "" };
|
|
307
|
+
}
|
|
308
|
+
function ok(stdout) {
|
|
309
|
+
return { exitCode: 0, stdout: ensureTrailingNewline(stdout) };
|
|
310
|
+
}
|
|
311
|
+
function jsonResult(value, exitCode = 0) {
|
|
312
|
+
return {
|
|
313
|
+
exitCode,
|
|
314
|
+
stdout: `${JSON.stringify(value, null, 2)}\n`,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
function jsonFailure(command, error) {
|
|
318
|
+
const message = errorMessage(error);
|
|
319
|
+
const code = message.includes("required")
|
|
320
|
+
? "MISSING_ARGUMENT"
|
|
321
|
+
: message.includes(" requires ") || message.includes("only supported")
|
|
322
|
+
? "INVALID_COMBINATION"
|
|
323
|
+
: message.startsWith("Unknown") || message.startsWith("Expected")
|
|
324
|
+
? "INVALID_ARGUMENT"
|
|
325
|
+
: message.startsWith("Refusing to overwrite")
|
|
326
|
+
? "FILE_CONFLICT"
|
|
327
|
+
: message.includes("logged in")
|
|
328
|
+
? "AUTH_REQUIRED"
|
|
329
|
+
: "COMMAND_FAILED";
|
|
330
|
+
return jsonResult({ ok: false, command, error: { code, message } }, 1);
|
|
331
|
+
}
|
|
332
|
+
function requestedJsonOutput(args) {
|
|
333
|
+
return args.some((argument, index) => argument === "--format=json" ||
|
|
334
|
+
(argument === "--format" && args[index + 1] === "json"));
|
|
335
|
+
}
|
|
336
|
+
function outputFormat(parsed) {
|
|
337
|
+
return choice(readString(parsed, "format") ?? "text", ["text", "json"], "format");
|
|
338
|
+
}
|
|
339
|
+
function fail(message) {
|
|
340
|
+
return { exitCode: 1, stdout: "", stderr: ensureTrailingNewline(message) };
|
|
341
|
+
}
|
|
342
|
+
function ensureTrailingNewline(value) {
|
|
343
|
+
return value.endsWith("\n") ? value : `${value}\n`;
|
|
344
|
+
}
|
|
345
|
+
function errorMessage(error) {
|
|
346
|
+
return error instanceof Error ? error.message : String(error);
|
|
347
|
+
}
|
|
348
|
+
function mainHelpText() {
|
|
349
|
+
return [
|
|
350
|
+
"RemoteDraw CLI",
|
|
351
|
+
"",
|
|
352
|
+
"Usage:",
|
|
353
|
+
" remotedraw <command> [options]",
|
|
354
|
+
"",
|
|
355
|
+
"Commands:",
|
|
356
|
+
" guide Choose an integration path and SDK.",
|
|
357
|
+
" options Print setup choices and compatibility metadata.",
|
|
358
|
+
" login Authorize this computer through the dashboard.",
|
|
359
|
+
" logout Revoke and remove this computer's CLI credential.",
|
|
360
|
+
" whoami Show the signed-in RemoteDraw account.",
|
|
361
|
+
" new Create a new app with RemoteDraw starter code.",
|
|
362
|
+
" init Add RemoteDraw starter code to an existing project.",
|
|
363
|
+
" dev Print the local sandbox workflow for an integration.",
|
|
364
|
+
" doctor Check project config, packages, and environment variables.",
|
|
365
|
+
" create-input Create or print a test input request payload.",
|
|
366
|
+
" examples List or install example projects.",
|
|
367
|
+
" agent Print or install the RemoteDraw agent skill.",
|
|
368
|
+
"",
|
|
369
|
+
"Start here:",
|
|
370
|
+
" remotedraw guide",
|
|
371
|
+
" remotedraw login",
|
|
372
|
+
" remotedraw new",
|
|
373
|
+
" remotedraw new --app-name MijnApp --target web --sender remotedraw-ios --sdk react",
|
|
374
|
+
" remotedraw init --target web --sender embedded-web --sdk react",
|
|
375
|
+
].join("\n");
|
|
376
|
+
}
|
|
377
|
+
function optionsCommand(args) {
|
|
378
|
+
const parsed = parseArgs(args, {
|
|
379
|
+
values: ["format"],
|
|
380
|
+
booleans: ["help"],
|
|
381
|
+
});
|
|
382
|
+
if (parsed.booleans.has("help"))
|
|
383
|
+
return ok(optionsHelpText());
|
|
384
|
+
const catalog = agentOptionCatalog();
|
|
385
|
+
if (outputFormat(parsed) === "json")
|
|
386
|
+
return jsonResult(catalog);
|
|
387
|
+
return ok([
|
|
388
|
+
"RemoteDraw setup options",
|
|
389
|
+
"",
|
|
390
|
+
...catalog.fields.flatMap((field) => [
|
|
391
|
+
`${field.label} (${field.flag})`,
|
|
392
|
+
...field.options.map((option) => ` ${option.value}: ${option.details.summary}`),
|
|
393
|
+
"",
|
|
394
|
+
]),
|
|
395
|
+
"For machine-readable metadata: remotedraw options --format json",
|
|
396
|
+
].join("\n"));
|
|
397
|
+
}
|
|
398
|
+
function guideText() {
|
|
399
|
+
return [
|
|
400
|
+
"RemoteDraw integration guide",
|
|
401
|
+
"",
|
|
402
|
+
"Choose the surface your customer already has:",
|
|
403
|
+
" Web app receiver + RemoteDraw iOS app sender",
|
|
404
|
+
" remotedraw init --target web --sender remotedraw-ios --sdk react --preset signature",
|
|
405
|
+
"",
|
|
406
|
+
" Web app receiver + your own web sender UI",
|
|
407
|
+
" remotedraw init --target web --sender embedded-web --sdk react --preset sketch",
|
|
408
|
+
"",
|
|
409
|
+
" Svelte app receiver + RemoteDraw iOS app sender",
|
|
410
|
+
" remotedraw init --target web --sender remotedraw-ios --sdk svelte --preset signature",
|
|
411
|
+
"",
|
|
412
|
+
" Desktop app receiver with raw HTTP",
|
|
413
|
+
" remotedraw init --target desktop --sender remotedraw-ios --sdk js --preset sketch",
|
|
414
|
+
"",
|
|
415
|
+
" Your own iOS sender app",
|
|
416
|
+
" remotedraw init --target ios --sender own-ios --sdk swift --preset sketch",
|
|
417
|
+
"",
|
|
418
|
+
"API keys stay on trusted backend code. Receivers get receiver tokens, senders get join URLs or sender tokens.",
|
|
419
|
+
"remotedraw new and init create a dashboard project, mint a project-scoped development API key, and write it to .env.local by default.",
|
|
420
|
+
"Use --offline when you intentionally want local scaffolding only.",
|
|
421
|
+
].join("\n");
|
|
422
|
+
}
|
|
423
|
+
async function loginCommand(args, runtime) {
|
|
424
|
+
const parsed = parseArgs(args, {
|
|
425
|
+
values: ["api-base-url", "device-name"],
|
|
426
|
+
booleans: ["force", "no-open", "help"],
|
|
427
|
+
});
|
|
428
|
+
if (parsed.booleans.has("help"))
|
|
429
|
+
return ok(loginHelpText());
|
|
430
|
+
const apiBaseUrl = remoteDrawApiBaseUrl(runtime.env, readString(parsed, "api-base-url"));
|
|
431
|
+
const deviceName = readString(parsed, "device-name");
|
|
432
|
+
const result = await loginToRemoteDraw(runtime, apiBaseUrl, {
|
|
433
|
+
force: parsed.booleans.has("force"),
|
|
434
|
+
openBrowser: !parsed.booleans.has("no-open"),
|
|
435
|
+
...(deviceName == null ? {} : { deviceName }),
|
|
436
|
+
});
|
|
437
|
+
return ok([
|
|
438
|
+
result.alreadyLoggedIn
|
|
439
|
+
? "Already logged in."
|
|
440
|
+
: "RemoteDraw login complete.",
|
|
441
|
+
accountSummary(result.account),
|
|
442
|
+
`Credential: ${globalConfigPath(runtime)}`,
|
|
443
|
+
].join("\n"));
|
|
444
|
+
}
|
|
445
|
+
async function logoutCommand(args, runtime) {
|
|
446
|
+
const parsed = parseArgs(args, {
|
|
447
|
+
values: ["api-base-url"],
|
|
448
|
+
booleans: ["help"],
|
|
449
|
+
});
|
|
450
|
+
if (parsed.booleans.has("help"))
|
|
451
|
+
return ok(logoutHelpText());
|
|
452
|
+
const apiBaseUrl = remoteDrawApiBaseUrl(runtime.env, readString(parsed, "api-base-url"));
|
|
453
|
+
const result = await logoutFromRemoteDraw(runtime, apiBaseUrl);
|
|
454
|
+
return ok(result.hadCredential
|
|
455
|
+
? `Logged out from ${apiBaseUrl}.`
|
|
456
|
+
: `No stored credential for ${apiBaseUrl}.`);
|
|
457
|
+
}
|
|
458
|
+
async function whoamiCommand(args, runtime) {
|
|
459
|
+
const parsed = parseArgs(args, {
|
|
460
|
+
values: ["api-base-url"],
|
|
461
|
+
booleans: ["help"],
|
|
462
|
+
});
|
|
463
|
+
if (parsed.booleans.has("help"))
|
|
464
|
+
return ok(whoamiHelpText());
|
|
465
|
+
const apiBaseUrl = remoteDrawApiBaseUrl(runtime.env, readString(parsed, "api-base-url"));
|
|
466
|
+
const account = await currentCliAccount(runtime, apiBaseUrl);
|
|
467
|
+
if (!account) {
|
|
468
|
+
return fail(`Not logged in to ${apiBaseUrl}. Run remotedraw login.`);
|
|
469
|
+
}
|
|
470
|
+
return ok([accountSummary(account), `API: ${apiBaseUrl}`].join("\n"));
|
|
471
|
+
}
|
|
472
|
+
function accountSummary(account) {
|
|
473
|
+
const identity = account.email || account.name || "RemoteDraw user";
|
|
474
|
+
return `${identity} (${account.tenantSlug})`;
|
|
475
|
+
}
|
|
476
|
+
async function newCommand(args, runtime) {
|
|
477
|
+
const parsed = parseArgs(args, {
|
|
478
|
+
values: [
|
|
479
|
+
"app-name",
|
|
480
|
+
"path",
|
|
481
|
+
"target",
|
|
482
|
+
"sender",
|
|
483
|
+
"sdk",
|
|
484
|
+
"preset",
|
|
485
|
+
"api-base-url",
|
|
486
|
+
"package-manager",
|
|
487
|
+
"format",
|
|
488
|
+
],
|
|
489
|
+
booleans: ["force", "dry-run", "offline", "non-interactive", "help"],
|
|
490
|
+
});
|
|
491
|
+
if (parsed.booleans.has("help"))
|
|
492
|
+
return ok(newHelpText());
|
|
493
|
+
const format = outputFormat(parsed);
|
|
494
|
+
const plan = !parsed.booleans.has("non-interactive") &&
|
|
495
|
+
shouldPromptForNewProject(parsed, runtime)
|
|
496
|
+
? await promptForNewProject(parsed, runtime)
|
|
497
|
+
: planFromArgs(parsed, {
|
|
498
|
+
appNameRequired: true,
|
|
499
|
+
defaultAppName: "RemoteDraw app",
|
|
500
|
+
});
|
|
501
|
+
const outputDir = resolveOutputDir(runtime.cwd, parsed, plan.slug);
|
|
502
|
+
const dryRun = parsed.booleans.has("dry-run");
|
|
503
|
+
const force = parsed.booleans.has("force");
|
|
504
|
+
const offline = parsed.booleans.has("offline") || cloudSetupDisabled(runtime);
|
|
505
|
+
const commandRuntime = parsed.booleans.has("non-interactive")
|
|
506
|
+
? { ...runtime, isInteractive: false }
|
|
507
|
+
: runtime;
|
|
508
|
+
await assertProjectFilesWritable(newProjectFiles(plan), outputDir, runtime, dryRun, force);
|
|
509
|
+
const provisioning = await maybeProvisionProject(plan, outputDir, commandRuntime, { dryRun, force, offline });
|
|
510
|
+
const written = await createRemoteDrawProject(plan, outputDir, runtime, {
|
|
511
|
+
dryRun,
|
|
512
|
+
force,
|
|
513
|
+
});
|
|
514
|
+
if (provisioning) {
|
|
515
|
+
await writeProvisionedProjectSetup(runtime, outputDir, provisioning, force);
|
|
516
|
+
written.push(".env.local", ".gitignore cloud secret rule");
|
|
517
|
+
}
|
|
518
|
+
return format === "json"
|
|
519
|
+
? jsonResult(scaffoldJsonResult("new", plan, outputDir, written, parsed, provisioning, offline, dryRun))
|
|
520
|
+
: ok(scaffoldSummary("Created", plan, outputDir, written, dryRun) +
|
|
521
|
+
cloudSetupSummary(provisioning, offline, dryRun));
|
|
522
|
+
}
|
|
523
|
+
export async function createRemoteDrawProject(plan, outputDir, runtime, options = {}) {
|
|
524
|
+
validatePlan(plan);
|
|
525
|
+
return await writeProjectFiles(newProjectFiles(plan), outputDir, runtime, {
|
|
526
|
+
dryRun: options.dryRun ?? false,
|
|
527
|
+
force: options.force ?? false,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
async function initCommand(args, runtime) {
|
|
531
|
+
const parsed = parseArgs(args, {
|
|
532
|
+
values: [
|
|
533
|
+
"app-name",
|
|
534
|
+
"path",
|
|
535
|
+
"target",
|
|
536
|
+
"sender",
|
|
537
|
+
"sdk",
|
|
538
|
+
"preset",
|
|
539
|
+
"api-base-url",
|
|
540
|
+
"package-manager",
|
|
541
|
+
"format",
|
|
542
|
+
],
|
|
543
|
+
booleans: ["force", "dry-run", "offline", "non-interactive", "help"],
|
|
544
|
+
});
|
|
545
|
+
if (parsed.booleans.has("help"))
|
|
546
|
+
return ok(initHelpText());
|
|
547
|
+
const format = outputFormat(parsed);
|
|
548
|
+
const outputDir = resolveOutputDir(runtime.cwd, parsed);
|
|
549
|
+
const defaultAppName = path.basename(outputDir);
|
|
550
|
+
const plan = planFromArgs(parsed, {
|
|
551
|
+
appNameRequired: false,
|
|
552
|
+
defaultAppName,
|
|
553
|
+
});
|
|
554
|
+
const dryRun = parsed.booleans.has("dry-run");
|
|
555
|
+
const force = parsed.booleans.has("force");
|
|
556
|
+
const offline = parsed.booleans.has("offline") || cloudSetupDisabled(runtime);
|
|
557
|
+
const commandRuntime = parsed.booleans.has("non-interactive")
|
|
558
|
+
? { ...runtime, isInteractive: false }
|
|
559
|
+
: runtime;
|
|
560
|
+
const files = initProjectFiles(plan, await hasPackageJson(outputDir, runtime));
|
|
561
|
+
await assertProjectFilesWritable(files, outputDir, runtime, dryRun, force);
|
|
562
|
+
const provisioning = await maybeProvisionProject(plan, outputDir, commandRuntime, { dryRun, force, offline });
|
|
563
|
+
const written = await writeProjectFiles(files, outputDir, runtime, {
|
|
564
|
+
dryRun,
|
|
565
|
+
force,
|
|
566
|
+
});
|
|
567
|
+
if (!dryRun)
|
|
568
|
+
await updatePackageJson(outputDir, plan, runtime);
|
|
569
|
+
if (dryRun)
|
|
570
|
+
written.push("package.json dependency check");
|
|
571
|
+
if (provisioning) {
|
|
572
|
+
await writeProvisionedProjectSetup(runtime, outputDir, provisioning, force);
|
|
573
|
+
written.push(".env.local", ".gitignore cloud secret rule");
|
|
574
|
+
}
|
|
575
|
+
return format === "json"
|
|
576
|
+
? jsonResult(scaffoldJsonResult("init", plan, outputDir, written, parsed, provisioning, offline, dryRun))
|
|
577
|
+
: ok(scaffoldSummary("Initialized", plan, outputDir, written, dryRun) +
|
|
578
|
+
cloudSetupSummary(provisioning, offline, dryRun));
|
|
579
|
+
}
|
|
580
|
+
function cloudSetupDisabled(runtime) {
|
|
581
|
+
return runtime.env.REMOTEDRAW_CLI_OFFLINE === "1";
|
|
582
|
+
}
|
|
583
|
+
async function maybeProvisionProject(plan, outputDir, runtime, options) {
|
|
584
|
+
if (options.dryRun || options.offline)
|
|
585
|
+
return undefined;
|
|
586
|
+
const apiBaseUrl = remoteDrawApiBaseUrl(runtime.env, plan.apiBaseUrl);
|
|
587
|
+
await assertEnvCanAcceptProvisioning(runtime, outputDir, apiBaseUrl, options.force);
|
|
588
|
+
const provisioning = await provisionRemoteDrawProject(runtime, apiBaseUrl, {
|
|
589
|
+
name: plan.appName,
|
|
590
|
+
keyName: `${plan.appName} development`,
|
|
591
|
+
});
|
|
592
|
+
plan.apiBaseUrl = provisioning.apiBaseUrl;
|
|
593
|
+
return provisioning;
|
|
594
|
+
}
|
|
595
|
+
function cloudSetupSummary(provisioning, offline, dryRun) {
|
|
596
|
+
if (dryRun)
|
|
597
|
+
return "";
|
|
598
|
+
if (offline) {
|
|
599
|
+
return "\n\nCloud setup skipped (--offline). Run remotedraw init later to create a dashboard project.";
|
|
600
|
+
}
|
|
601
|
+
if (!provisioning)
|
|
602
|
+
return "";
|
|
603
|
+
return [
|
|
604
|
+
"",
|
|
605
|
+
"",
|
|
606
|
+
`Dashboard project: ${provisioning.project.dashboardUrl}`,
|
|
607
|
+
"Development API key: written to .env.local (server-side only)",
|
|
608
|
+
`Project ID: ${provisioning.project.id}`,
|
|
609
|
+
].join("\n");
|
|
610
|
+
}
|
|
611
|
+
const DOCTOR_PROBE_TIMEOUT_MS = 8_000;
|
|
612
|
+
function probeFailureMessage(error) {
|
|
613
|
+
return error instanceof Error ? error.message : String(error);
|
|
614
|
+
}
|
|
615
|
+
async function probeApiHealth(runtime, apiBaseUrl) {
|
|
616
|
+
if (!runtime.fetch) {
|
|
617
|
+
return { ok: false, message: "this runtime cannot make network requests" };
|
|
618
|
+
}
|
|
619
|
+
try {
|
|
620
|
+
const response = await runtime.fetch(`${apiBaseUrl}/health`, {
|
|
621
|
+
method: "GET",
|
|
622
|
+
signal: AbortSignal.timeout(DOCTOR_PROBE_TIMEOUT_MS),
|
|
623
|
+
});
|
|
624
|
+
if (!response.ok) {
|
|
625
|
+
return { ok: false, message: `HTTP ${response.status}` };
|
|
626
|
+
}
|
|
627
|
+
return { ok: true, message: "ok" };
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
return { ok: false, message: probeFailureMessage(error) };
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Validates the key with a read-only call. `/v1/sessions/list` needs only
|
|
635
|
+
* `sessions:read` and creates nothing, so doctor never consumes a credit to
|
|
636
|
+
* tell a developer their key works.
|
|
637
|
+
*/
|
|
638
|
+
async function probeApiKey(runtime, apiBaseUrl, apiKey) {
|
|
639
|
+
if (!runtime.fetch) {
|
|
640
|
+
return { ok: false, message: "This runtime cannot make network requests." };
|
|
641
|
+
}
|
|
642
|
+
try {
|
|
643
|
+
const response = await runtime.fetch(`${apiBaseUrl}/v1/sessions/list`, {
|
|
644
|
+
method: "POST",
|
|
645
|
+
headers: {
|
|
646
|
+
Authorization: `Bearer ${apiKey}`,
|
|
647
|
+
"Content-Type": "application/json",
|
|
648
|
+
},
|
|
649
|
+
body: JSON.stringify({ limit: 1 }),
|
|
650
|
+
signal: AbortSignal.timeout(DOCTOR_PROBE_TIMEOUT_MS),
|
|
651
|
+
});
|
|
652
|
+
const raw = await response.text();
|
|
653
|
+
let payload;
|
|
654
|
+
try {
|
|
655
|
+
payload = raw ? JSON.parse(raw) : {};
|
|
656
|
+
}
|
|
657
|
+
catch {
|
|
658
|
+
return {
|
|
659
|
+
ok: false,
|
|
660
|
+
message: `Deployment returned a non-JSON response (HTTP ${response.status}).`,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
if (!response.ok) {
|
|
664
|
+
const message = typeof payload === "object" &&
|
|
665
|
+
payload !== null &&
|
|
666
|
+
"error" in payload &&
|
|
667
|
+
typeof payload.error?.message ===
|
|
668
|
+
"string"
|
|
669
|
+
? payload.error.message
|
|
670
|
+
: `Deployment rejected the key (HTTP ${response.status}).`;
|
|
671
|
+
return { ok: false, message };
|
|
672
|
+
}
|
|
673
|
+
const items = typeof payload === "object" && payload !== null && "items" in payload
|
|
674
|
+
? payload.items
|
|
675
|
+
: undefined;
|
|
676
|
+
return { ok: true, sessionCount: Array.isArray(items) ? items.length : 0 };
|
|
677
|
+
}
|
|
678
|
+
catch (error) {
|
|
679
|
+
return { ok: false, message: probeFailureMessage(error) };
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
async function doctorCommand(args, runtime) {
|
|
683
|
+
const parsed = parseArgs(args, {
|
|
684
|
+
values: ["path", "format"],
|
|
685
|
+
booleans: ["help", "offline"],
|
|
686
|
+
});
|
|
687
|
+
if (parsed.booleans.has("help"))
|
|
688
|
+
return ok(doctorHelpText());
|
|
689
|
+
const format = outputFormat(parsed);
|
|
690
|
+
const offline = parsed.booleans.has("offline") ||
|
|
691
|
+
runtime.env.REMOTEDRAW_CLI_OFFLINE === "1";
|
|
692
|
+
const dir = resolveOutputDir(runtime.cwd, parsed);
|
|
693
|
+
const projectEnv = await environmentForProject(dir, runtime);
|
|
694
|
+
const configPath = path.join(dir, "remotedraw.config.json");
|
|
695
|
+
const packagePath = path.join(dir, "package.json");
|
|
696
|
+
const envApiBaseUrl = projectEnv.REMOTEDRAW_API_BASE_URL;
|
|
697
|
+
const normalizedEnvApiBaseUrl = envApiBaseUrl == null || !isHttpOrigin(envApiBaseUrl)
|
|
698
|
+
? undefined
|
|
699
|
+
: normalizedOrigin(envApiBaseUrl);
|
|
700
|
+
const envApiKey = projectEnv.REMOTEDRAW_API_KEY;
|
|
701
|
+
const lines = ["RemoteDraw doctor", ""];
|
|
702
|
+
const checks = [];
|
|
703
|
+
const addCheck = (state, label, message) => {
|
|
704
|
+
checks.push({ state, label, message });
|
|
705
|
+
lines.push(statusLine(state, label, message));
|
|
706
|
+
};
|
|
707
|
+
const hasConfig = await runtime.exists(configPath);
|
|
708
|
+
addCheck(hasConfig ? "ok" : "warn", "remotedraw.config.json", hasConfig
|
|
709
|
+
? "Project is linked to a local integration profile."
|
|
710
|
+
: "Run remotedraw init to create one.");
|
|
711
|
+
const packageJson = (await runtime.exists(packagePath))
|
|
712
|
+
? await readJsonFile(packagePath, runtime)
|
|
713
|
+
: null;
|
|
714
|
+
addCheck(packageJson ? "ok" : "warn", "package.json", packageJson
|
|
715
|
+
? "Package metadata found."
|
|
716
|
+
: "No package.json found in this directory.");
|
|
717
|
+
const config = hasConfig ? await readJsonFile(configPath, runtime) : null;
|
|
718
|
+
const sdk = stringFromRecord(config, "sdk");
|
|
719
|
+
if (sdk === "react") {
|
|
720
|
+
addCheck(hasDependency(packageJson, "@remotedraw/react") ? "ok" : "warn", "@remotedraw/react", hasDependency(packageJson, "@remotedraw/react")
|
|
721
|
+
? "React SDK dependency is installed in package.json."
|
|
722
|
+
: "Install @remotedraw/react or rerun remotedraw init.");
|
|
723
|
+
}
|
|
724
|
+
addCheck(normalizedEnvApiBaseUrl ? "ok" : "warn", "REMOTEDRAW_API_BASE_URL", normalizedEnvApiBaseUrl
|
|
725
|
+
? `Using ${normalizedEnvApiBaseUrl}.`
|
|
726
|
+
: "Set this to your Convex site origin, for example https://<deployment>.convex.site.");
|
|
727
|
+
addCheck(envApiKey?.startsWith("rd_sk_") ? "ok" : "warn", "REMOTEDRAW_API_KEY", envApiKey?.startsWith("rd_sk_")
|
|
728
|
+
? "Server-side API key shape looks correct."
|
|
729
|
+
: "Keep an rd_sk_... key in backend secrets only.");
|
|
730
|
+
// The checks above only prove the strings look right. A revoked key, a URL
|
|
731
|
+
// pointing at the wrong deployment, and an exhausted credit balance all pass
|
|
732
|
+
// them, so doctor also asks the deployment itself.
|
|
733
|
+
if (offline) {
|
|
734
|
+
addCheck("warn", "deployment", "Skipped network checks (--offline). Rerun without --offline to verify the key against the deployment.");
|
|
735
|
+
}
|
|
736
|
+
else if (!normalizedEnvApiBaseUrl) {
|
|
737
|
+
addCheck("warn", "deployment", "Skipped network checks because REMOTEDRAW_API_BASE_URL is not set.");
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
const health = await probeApiHealth(runtime, normalizedEnvApiBaseUrl);
|
|
741
|
+
addCheck(health.ok ? "ok" : "error", "deployment reachable", health.ok
|
|
742
|
+
? `${normalizedEnvApiBaseUrl} answered /health.`
|
|
743
|
+
: `${normalizedEnvApiBaseUrl} did not answer /health: ${health.message}`);
|
|
744
|
+
if (!envApiKey?.startsWith("rd_sk_")) {
|
|
745
|
+
addCheck("warn", "API key accepted", "Skipped because no rd_sk_... key was found in this project's environment.");
|
|
746
|
+
}
|
|
747
|
+
else if (!health.ok) {
|
|
748
|
+
addCheck("warn", "API key accepted", "Skipped because the deployment did not answer.");
|
|
749
|
+
}
|
|
750
|
+
else {
|
|
751
|
+
const probe = await probeApiKey(runtime, normalizedEnvApiBaseUrl, envApiKey);
|
|
752
|
+
addCheck(probe.ok ? "ok" : "error", "API key accepted", probe.ok
|
|
753
|
+
? `Key is live. ${probe.sessionCount} recent API ${probe.sessionCount === 1 ? "session" : "sessions"} visible.`
|
|
754
|
+
: probe.message);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return format === "json"
|
|
758
|
+
? jsonResult({
|
|
759
|
+
ok: true,
|
|
760
|
+
command: "doctor",
|
|
761
|
+
directory: dir,
|
|
762
|
+
healthy: checks.every((check) => check.state === "ok"),
|
|
763
|
+
checks,
|
|
764
|
+
})
|
|
765
|
+
: ok(lines.join("\n"));
|
|
766
|
+
}
|
|
767
|
+
async function devCommand(args, runtime) {
|
|
768
|
+
const parsed = parseArgs(args, {
|
|
769
|
+
values: ["api-base-url", "path"],
|
|
770
|
+
booleans: ["help"],
|
|
771
|
+
});
|
|
772
|
+
if (parsed.booleans.has("help"))
|
|
773
|
+
return ok(devHelpText());
|
|
774
|
+
const dir = resolveOutputDir(runtime.cwd, parsed);
|
|
775
|
+
const projectEnv = await environmentForProject(dir, runtime);
|
|
776
|
+
const apiBaseUrl = readString(parsed, "api-base-url") ??
|
|
777
|
+
projectEnv.REMOTEDRAW_API_BASE_URL ??
|
|
778
|
+
"http://localhost:3210";
|
|
779
|
+
return ok([
|
|
780
|
+
"RemoteDraw local development workflow",
|
|
781
|
+
"",
|
|
782
|
+
`API base URL: ${apiBaseUrl}`,
|
|
783
|
+
"",
|
|
784
|
+
"1. Start the RemoteDraw API/web service for local testing.",
|
|
785
|
+
" Inside this repository: bun run dev:codex",
|
|
786
|
+
"",
|
|
787
|
+
"2. Start your app's dev server.",
|
|
788
|
+
" Use the package manager and dev script generated by remotedraw new/init.",
|
|
789
|
+
"",
|
|
790
|
+
"3. Create a test input request from trusted backend code.",
|
|
791
|
+
` remotedraw create-input --preset signature --api-base-url ${apiBaseUrl}`,
|
|
792
|
+
"",
|
|
793
|
+
"4. Show the returned HTTPS joinUrl in your receiver UI.",
|
|
794
|
+
" Use the RemoteDraw iOS app for remotedraw-ios sender flows, or EmbeddedSender for owned web sender flows.",
|
|
795
|
+
].join("\n"));
|
|
796
|
+
}
|
|
797
|
+
async function createInputCommand(args, runtime) {
|
|
798
|
+
const parsed = parseArgs(args, {
|
|
799
|
+
values: [
|
|
800
|
+
"preset",
|
|
801
|
+
"label",
|
|
802
|
+
"external-id",
|
|
803
|
+
"api-base-url",
|
|
804
|
+
"api-key",
|
|
805
|
+
"expires-in-ms",
|
|
806
|
+
],
|
|
807
|
+
booleans: ["json", "curl", "execute", "help"],
|
|
808
|
+
});
|
|
809
|
+
if (parsed.booleans.has("help"))
|
|
810
|
+
return ok(createInputHelpText());
|
|
811
|
+
const projectEnv = await environmentForProject(runtime.cwd, runtime);
|
|
812
|
+
const preset = choice(readString(parsed, "preset") ?? "signature", presetChoices, "preset");
|
|
813
|
+
const apiBaseUrl = readString(parsed, "api-base-url") ??
|
|
814
|
+
projectEnv.REMOTEDRAW_API_BASE_URL ??
|
|
815
|
+
"https://<deployment>.convex.site";
|
|
816
|
+
const payload = createSessionPayload({
|
|
817
|
+
preset,
|
|
818
|
+
label: readString(parsed, "label") ?? labelForPreset(preset),
|
|
819
|
+
externalId: readString(parsed, "external-id") ?? `cli-${preset}`,
|
|
820
|
+
expiresInMs: optionalInteger(readString(parsed, "expires-in-ms")),
|
|
821
|
+
});
|
|
822
|
+
if (parsed.booleans.has("execute")) {
|
|
823
|
+
const apiKey = readString(parsed, "api-key") ?? projectEnv.REMOTEDRAW_API_KEY;
|
|
824
|
+
if (!runtime.fetch)
|
|
825
|
+
throw new Error("This runtime cannot execute HTTP requests.");
|
|
826
|
+
if (!apiKey?.startsWith("rd_sk_")) {
|
|
827
|
+
throw new Error("create-input --execute requires --api-key or REMOTEDRAW_API_KEY with an rd_sk_... server key.");
|
|
828
|
+
}
|
|
829
|
+
const response = await runtime.fetch(`${normalizedOrigin(apiBaseUrl)}/v1/sessions`, {
|
|
830
|
+
method: "POST",
|
|
831
|
+
headers: {
|
|
832
|
+
Authorization: `Bearer ${apiKey}`,
|
|
833
|
+
"Content-Type": "application/json",
|
|
834
|
+
},
|
|
835
|
+
body: JSON.stringify(payload),
|
|
836
|
+
});
|
|
837
|
+
const text = await response.text();
|
|
838
|
+
if (!response.ok)
|
|
839
|
+
throw new Error(text || `RemoteDraw API returned ${response.status}.`);
|
|
840
|
+
return ok(text);
|
|
841
|
+
}
|
|
842
|
+
if (parsed.booleans.has("json")) {
|
|
843
|
+
return ok(JSON.stringify(payload, null, 2));
|
|
844
|
+
}
|
|
845
|
+
return ok([
|
|
846
|
+
"RemoteDraw test input request",
|
|
847
|
+
"",
|
|
848
|
+
"Create this from trusted backend code. Do not run API-key requests in a browser client.",
|
|
849
|
+
"",
|
|
850
|
+
curlForCreateInput(apiBaseUrl, payload),
|
|
851
|
+
"",
|
|
852
|
+
"Use --json to print only the request body or --execute to call the API with REMOTEDRAW_API_KEY.",
|
|
853
|
+
].join("\n"));
|
|
854
|
+
}
|
|
855
|
+
async function examplesCommand(args, runtime) {
|
|
856
|
+
const parsed = parseArgs(args, {
|
|
857
|
+
values: ["install", "path", "app-name", "package-manager"],
|
|
858
|
+
booleans: ["list", "force", "dry-run", "help"],
|
|
859
|
+
});
|
|
860
|
+
if (parsed.booleans.has("help"))
|
|
861
|
+
return ok(examplesHelpText());
|
|
862
|
+
const example = readString(parsed, "install");
|
|
863
|
+
if (example == null || parsed.booleans.has("list")) {
|
|
864
|
+
return ok([
|
|
865
|
+
"RemoteDraw examples",
|
|
866
|
+
"",
|
|
867
|
+
" react-ios Web receiver using the RemoteDraw iOS app as sender.",
|
|
868
|
+
" react-owned-sender Web receiver plus EmbeddedSender for owned web input.",
|
|
869
|
+
" raw-http Package-free backend/receiver/sender route fixtures.",
|
|
870
|
+
" ios-owned-sender Swift join-link and sender request helper starter.",
|
|
871
|
+
"",
|
|
872
|
+
"Install one:",
|
|
873
|
+
" remotedraw examples --install react-ios --path ./remotedraw-react-ios",
|
|
874
|
+
].join("\n"));
|
|
875
|
+
}
|
|
876
|
+
const installedExample = choice(example, exampleChoices, "example");
|
|
877
|
+
const defaults = planDefaultsForExample(installedExample);
|
|
878
|
+
const outputDir = resolveOutputDir(runtime.cwd, parsed, installedExample);
|
|
879
|
+
const plan = planFromArgs(parsed, {
|
|
880
|
+
appNameRequired: false,
|
|
881
|
+
defaultAppName: readString(parsed, "app-name") ?? titleCase(installedExample),
|
|
882
|
+
defaults,
|
|
883
|
+
});
|
|
884
|
+
const files = newProjectFiles(plan);
|
|
885
|
+
const dryRun = parsed.booleans.has("dry-run");
|
|
886
|
+
const written = await writeProjectFiles(files, outputDir, runtime, {
|
|
887
|
+
dryRun,
|
|
888
|
+
force: parsed.booleans.has("force"),
|
|
889
|
+
});
|
|
890
|
+
return ok(scaffoldSummary("Installed example", plan, outputDir, written, dryRun));
|
|
891
|
+
}
|
|
892
|
+
async function agentCommand(args, runtime) {
|
|
893
|
+
const parsed = parseArgs(args, {
|
|
894
|
+
values: ["path"],
|
|
895
|
+
booleans: ["print-skill", "dry-run", "force", "help"],
|
|
896
|
+
});
|
|
897
|
+
if (parsed.booleans.has("help"))
|
|
898
|
+
return ok(agentHelpText());
|
|
899
|
+
if (parsed.booleans.has("print-skill"))
|
|
900
|
+
return ok(agentSkillMarkdown());
|
|
901
|
+
const targetDir = readString(parsed, "path");
|
|
902
|
+
if (targetDir == null) {
|
|
903
|
+
return ok([
|
|
904
|
+
"RemoteDraw agent setup",
|
|
905
|
+
"",
|
|
906
|
+
"Use this when an AI coding agent needs to add RemoteDraw to a customer app.",
|
|
907
|
+
"",
|
|
908
|
+
"Print the skill:",
|
|
909
|
+
" remotedraw agent --print-skill",
|
|
910
|
+
"",
|
|
911
|
+
"Install the skill file:",
|
|
912
|
+
" remotedraw agent --path ~/.codex/skills/remotedraw",
|
|
913
|
+
].join("\n"));
|
|
914
|
+
}
|
|
915
|
+
const outputDir = resolveAgentPath(targetDir, runtime);
|
|
916
|
+
const outputFile = path.join(outputDir, "SKILL.md");
|
|
917
|
+
if (!parsed.booleans.has("dry-run") &&
|
|
918
|
+
!parsed.booleans.has("force") &&
|
|
919
|
+
(await runtime.exists(outputFile))) {
|
|
920
|
+
throw new Error("SKILL.md already exists. Pass --force to replace it.");
|
|
921
|
+
}
|
|
922
|
+
if (!parsed.booleans.has("dry-run")) {
|
|
923
|
+
await runtime.mkdir(outputDir);
|
|
924
|
+
await runtime.writeFile(outputFile, agentSkillMarkdown());
|
|
925
|
+
}
|
|
926
|
+
return ok([
|
|
927
|
+
parsed.booleans.has("dry-run")
|
|
928
|
+
? `Would write ${outputFile}`
|
|
929
|
+
: `Installed RemoteDraw agent skill at ${outputFile}`,
|
|
930
|
+
"",
|
|
931
|
+
"Next:",
|
|
932
|
+
" Ask your agent to use the RemoteDraw skill before editing API, CLI, sender, receiver, or iOS integration code.",
|
|
933
|
+
].join("\n"));
|
|
934
|
+
}
|
|
935
|
+
function newHelpText() {
|
|
936
|
+
return [
|
|
937
|
+
"Usage:",
|
|
938
|
+
" remotedraw new",
|
|
939
|
+
" remotedraw new --app-name <name> [options]",
|
|
940
|
+
"",
|
|
941
|
+
"Run without options for the interactive setup flow.",
|
|
942
|
+
"",
|
|
943
|
+
"Options:",
|
|
944
|
+
" --path <dir> Output directory. Defaults to an app-name slug.",
|
|
945
|
+
" --target <kind> web, desktop, ios, or headless.",
|
|
946
|
+
" --sender <kind> remotedraw-ios, embedded-web, own-ios, or headless.",
|
|
947
|
+
" --sdk <kind> react, svelte, js, swift, or headless.",
|
|
948
|
+
" --preset <kind> signature or sketch. Legacy API presets remain accepted for existing integrations.",
|
|
949
|
+
" --package-manager <name> npm, pnpm, yarn, or bun.",
|
|
950
|
+
" --api-base-url <url> Default API origin for generated config.",
|
|
951
|
+
" --force Overwrite generated files and existing RemoteDraw .env.local values.",
|
|
952
|
+
" --offline Scaffold locally without login, dashboard project, or API key creation.",
|
|
953
|
+
" --non-interactive Never prompt; fail when required input is missing.",
|
|
954
|
+
" --format <text|json> Human-readable or machine-readable result.",
|
|
955
|
+
" --dry-run Print files that would be written.",
|
|
956
|
+
].join("\n");
|
|
957
|
+
}
|
|
958
|
+
function initHelpText() {
|
|
959
|
+
return [
|
|
960
|
+
"Usage:",
|
|
961
|
+
" remotedraw init [options]",
|
|
962
|
+
"",
|
|
963
|
+
"Options match remotedraw new. init writes remotedraw.config.json, .env.example, and src/remotedraw starter files into an existing project.",
|
|
964
|
+
].join("\n");
|
|
965
|
+
}
|
|
966
|
+
function loginHelpText() {
|
|
967
|
+
return [
|
|
968
|
+
"Authorize the RemoteDraw CLI",
|
|
969
|
+
"",
|
|
970
|
+
"Usage:",
|
|
971
|
+
" remotedraw login [--no-open] [--force] [--api-base-url <origin>]",
|
|
972
|
+
"",
|
|
973
|
+
"The browser confirms your account. A revocable CLI credential is stored in your user config directory, never in the project.",
|
|
974
|
+
].join("\n");
|
|
975
|
+
}
|
|
976
|
+
function logoutHelpText() {
|
|
977
|
+
return [
|
|
978
|
+
"Revoke the RemoteDraw CLI credential",
|
|
979
|
+
"",
|
|
980
|
+
"Usage:",
|
|
981
|
+
" remotedraw logout [--api-base-url <origin>]",
|
|
982
|
+
].join("\n");
|
|
983
|
+
}
|
|
984
|
+
function whoamiHelpText() {
|
|
985
|
+
return [
|
|
986
|
+
"Show the active RemoteDraw CLI account",
|
|
987
|
+
"",
|
|
988
|
+
"Usage:",
|
|
989
|
+
" remotedraw whoami [--api-base-url <origin>]",
|
|
990
|
+
].join("\n");
|
|
991
|
+
}
|
|
992
|
+
function doctorHelpText() {
|
|
993
|
+
return [
|
|
994
|
+
"Usage:",
|
|
995
|
+
" remotedraw doctor [--path <dir>] [--format text|json] [--offline]",
|
|
996
|
+
"",
|
|
997
|
+
"Checks local config, package.json dependencies, and the REMOTEDRAW_API_BASE_URL / REMOTEDRAW_API_KEY environment variables.",
|
|
998
|
+
"Then reaches the deployment: GET /health, and a free POST /v1/sessions/list to confirm the API key is live.",
|
|
999
|
+
"Pass --offline to skip the network checks.",
|
|
1000
|
+
].join("\n");
|
|
1001
|
+
}
|
|
1002
|
+
function optionsHelpText() {
|
|
1003
|
+
return [
|
|
1004
|
+
"Usage:",
|
|
1005
|
+
" remotedraw options [--format text|json]",
|
|
1006
|
+
"",
|
|
1007
|
+
"Prints every setup choice, compatibility rule, default, explanation, and documentation URL.",
|
|
1008
|
+
].join("\n");
|
|
1009
|
+
}
|
|
1010
|
+
function devHelpText() {
|
|
1011
|
+
return [
|
|
1012
|
+
"Usage:",
|
|
1013
|
+
" remotedraw dev [--api-base-url <url>]",
|
|
1014
|
+
"",
|
|
1015
|
+
"Prints the local test workflow for pairing, input request creation, and receiver rendering.",
|
|
1016
|
+
].join("\n");
|
|
1017
|
+
}
|
|
1018
|
+
function createInputHelpText() {
|
|
1019
|
+
return [
|
|
1020
|
+
"Usage:",
|
|
1021
|
+
" remotedraw create-input [--preset signature] [--json|--curl|--execute]",
|
|
1022
|
+
"",
|
|
1023
|
+
"Options:",
|
|
1024
|
+
" --preset <kind> Session/input preset.",
|
|
1025
|
+
" --label <text> Receiver target label.",
|
|
1026
|
+
" --external-id <id> App-owned correlation id.",
|
|
1027
|
+
" --api-base-url <url> RemoteDraw API origin.",
|
|
1028
|
+
" --api-key <key> Server API key for --execute.",
|
|
1029
|
+
" --expires-in-ms <n> Optional session TTL.",
|
|
1030
|
+
].join("\n");
|
|
1031
|
+
}
|
|
1032
|
+
function examplesHelpText() {
|
|
1033
|
+
return [
|
|
1034
|
+
"Usage:",
|
|
1035
|
+
" remotedraw examples [--list]",
|
|
1036
|
+
" remotedraw examples --install <example> --path <dir>",
|
|
1037
|
+
"",
|
|
1038
|
+
`Examples: ${exampleChoices.join(", ")}`,
|
|
1039
|
+
].join("\n");
|
|
1040
|
+
}
|
|
1041
|
+
function agentHelpText() {
|
|
1042
|
+
return [
|
|
1043
|
+
"Usage:",
|
|
1044
|
+
" remotedraw agent",
|
|
1045
|
+
" remotedraw agent --print-skill",
|
|
1046
|
+
" remotedraw agent --path <dir> [--force] [--dry-run]",
|
|
1047
|
+
"",
|
|
1048
|
+
"Writes a SKILL.md file that tells coding agents how to choose an SDK, use the CLI, keep API keys server-side, and verify RemoteDraw integrations.",
|
|
1049
|
+
].join("\n");
|
|
1050
|
+
}
|
|
1051
|
+
function parseArgs(args, allowed) {
|
|
1052
|
+
const values = new Map();
|
|
1053
|
+
const booleans = new Set();
|
|
1054
|
+
const positionals = [];
|
|
1055
|
+
const valueFlags = new Set(allowed.values);
|
|
1056
|
+
const booleanFlags = new Set(allowed.booleans);
|
|
1057
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1058
|
+
const arg = args[index];
|
|
1059
|
+
if (arg == null)
|
|
1060
|
+
continue;
|
|
1061
|
+
if (!arg.startsWith("--")) {
|
|
1062
|
+
positionals.push(arg);
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
const withoutPrefix = arg.slice(2);
|
|
1066
|
+
const equalsIndex = withoutPrefix.indexOf("=");
|
|
1067
|
+
const name = equalsIndex === -1 ? withoutPrefix : withoutPrefix.slice(0, equalsIndex);
|
|
1068
|
+
const inlineValue = equalsIndex === -1 ? undefined : withoutPrefix.slice(equalsIndex + 1);
|
|
1069
|
+
if (booleanFlags.has(name)) {
|
|
1070
|
+
if (inlineValue != null) {
|
|
1071
|
+
throw new Error(`--${name} does not accept a value.`);
|
|
1072
|
+
}
|
|
1073
|
+
booleans.add(name);
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
if (!valueFlags.has(name)) {
|
|
1077
|
+
throw new Error(`Unsupported option "--${name}".`);
|
|
1078
|
+
}
|
|
1079
|
+
const value = inlineValue ?? args[index + 1];
|
|
1080
|
+
if (value == null || value.startsWith("--")) {
|
|
1081
|
+
throw new Error(`--${name} requires a value.`);
|
|
1082
|
+
}
|
|
1083
|
+
values.set(name, value);
|
|
1084
|
+
if (inlineValue == null)
|
|
1085
|
+
index += 1;
|
|
1086
|
+
}
|
|
1087
|
+
return { values, booleans, positionals };
|
|
1088
|
+
}
|
|
1089
|
+
function readString(parsed, name) {
|
|
1090
|
+
const value = parsed.values.get(name);
|
|
1091
|
+
return value == null || value.trim() === "" ? undefined : value.trim();
|
|
1092
|
+
}
|
|
1093
|
+
function optionalInteger(value) {
|
|
1094
|
+
if (value == null)
|
|
1095
|
+
return undefined;
|
|
1096
|
+
const number = Number(value);
|
|
1097
|
+
if (!Number.isInteger(number) || number <= 0) {
|
|
1098
|
+
throw new Error("--expires-in-ms requires a positive integer.");
|
|
1099
|
+
}
|
|
1100
|
+
return number;
|
|
1101
|
+
}
|
|
1102
|
+
function shouldPromptForNewProject(parsed, runtime) {
|
|
1103
|
+
if (!runtime.prompts || runtime.isInteractive !== true)
|
|
1104
|
+
return false;
|
|
1105
|
+
return ["app-name", "target", "sender", "sdk"].some((name) => readString(parsed, name) == null);
|
|
1106
|
+
}
|
|
1107
|
+
async function promptForNewProject(parsed, runtime) {
|
|
1108
|
+
const prompts = runtime.prompts;
|
|
1109
|
+
if (!prompts) {
|
|
1110
|
+
return planFromArgs(parsed, {
|
|
1111
|
+
appNameRequired: true,
|
|
1112
|
+
defaultAppName: "RemoteDraw app",
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
await prompts.intro?.("RemoteDraw project setup");
|
|
1116
|
+
const appName = readString(parsed, "app-name") ??
|
|
1117
|
+
(await prompts.text({
|
|
1118
|
+
message: "Project name",
|
|
1119
|
+
defaultValue: "RemoteDraw App",
|
|
1120
|
+
validate: validateProjectName,
|
|
1121
|
+
}));
|
|
1122
|
+
const target = readString(parsed, "target") == null
|
|
1123
|
+
? await prompts.select({
|
|
1124
|
+
message: "Project type",
|
|
1125
|
+
defaultValue: "web",
|
|
1126
|
+
choices: targetPromptChoices(),
|
|
1127
|
+
})
|
|
1128
|
+
: choice(readString(parsed, "target"), targetChoices, "target");
|
|
1129
|
+
const sender = readString(parsed, "sender") == null
|
|
1130
|
+
? await promptForSender(prompts, target)
|
|
1131
|
+
: choice(readString(parsed, "sender"), senderChoices, "sender");
|
|
1132
|
+
const sdk = readString(parsed, "sdk") == null
|
|
1133
|
+
? await promptForSdk(prompts, target, sender)
|
|
1134
|
+
: choice(readString(parsed, "sdk"), sdkChoices, "sdk");
|
|
1135
|
+
const preset = readString(parsed, "preset") == null
|
|
1136
|
+
? await prompts.select({
|
|
1137
|
+
message: "Starter experience",
|
|
1138
|
+
defaultValue: "signature",
|
|
1139
|
+
choices: presetPromptChoices(),
|
|
1140
|
+
})
|
|
1141
|
+
: choice(readString(parsed, "preset"), presetChoices, "preset");
|
|
1142
|
+
const packageManager = readString(parsed, "package-manager") == null
|
|
1143
|
+
? await prompts.select({
|
|
1144
|
+
message: "Package manager",
|
|
1145
|
+
defaultValue: "npm",
|
|
1146
|
+
choices: packageManagerPromptChoices(),
|
|
1147
|
+
})
|
|
1148
|
+
: choice(readString(parsed, "package-manager"), packageManagerChoices, "package-manager");
|
|
1149
|
+
const apiBaseUrl = readString(parsed, "api-base-url") ??
|
|
1150
|
+
apiBaseUrlForEndpoint(await prompts.select({
|
|
1151
|
+
message: "API endpoint",
|
|
1152
|
+
helperText: "Use the placeholder unless you are wiring local dev now.",
|
|
1153
|
+
defaultValue: "deployment",
|
|
1154
|
+
choices: apiEndpointPromptChoices(),
|
|
1155
|
+
}));
|
|
1156
|
+
validatePlan({ target, sender, sdk });
|
|
1157
|
+
return {
|
|
1158
|
+
appName,
|
|
1159
|
+
slug: slugify(appName),
|
|
1160
|
+
target,
|
|
1161
|
+
sender,
|
|
1162
|
+
sdk,
|
|
1163
|
+
preset,
|
|
1164
|
+
apiBaseUrl,
|
|
1165
|
+
packageManager,
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
function validateProjectName(value) {
|
|
1169
|
+
return value.trim() === "" ? "Enter a project name." : undefined;
|
|
1170
|
+
}
|
|
1171
|
+
async function promptForSender(prompts, target) {
|
|
1172
|
+
const choices = senderPromptChoices(target);
|
|
1173
|
+
return await prompts.select({
|
|
1174
|
+
message: "Phone sender",
|
|
1175
|
+
helperText: "Choose where drawing input will run.",
|
|
1176
|
+
defaultValue: defaultPromptValue(defaultSenderForTarget(target), choices),
|
|
1177
|
+
choices,
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
async function promptForSdk(prompts, target, sender) {
|
|
1181
|
+
const choices = sdkPromptChoices(target, sender);
|
|
1182
|
+
return await prompts.select({
|
|
1183
|
+
message: "UI framework / SDK",
|
|
1184
|
+
helperText: "Pick the SDK that matches your receiver UI.",
|
|
1185
|
+
defaultValue: defaultPromptValue(defaultSdkForChoices(target, sender), choices),
|
|
1186
|
+
choices,
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
function defaultPromptValue(preferred, choices) {
|
|
1190
|
+
return choices.some((choiceOption) => choiceOption.value === preferred)
|
|
1191
|
+
? preferred
|
|
1192
|
+
: choices[0].value;
|
|
1193
|
+
}
|
|
1194
|
+
function targetPromptChoices() {
|
|
1195
|
+
return [
|
|
1196
|
+
{ value: "web", label: "Web app" },
|
|
1197
|
+
{ value: "desktop", label: "Desktop or custom app" },
|
|
1198
|
+
{ value: "ios", label: "iOS sender app" },
|
|
1199
|
+
{ value: "headless", label: "Headless service" },
|
|
1200
|
+
];
|
|
1201
|
+
}
|
|
1202
|
+
const DOCS_INTEGRATION_PATHS = "https://docs.remotedraw.com/docs#paths";
|
|
1203
|
+
const DOCS_SDKS = "https://docs.remotedraw.com/docs/sdks#components";
|
|
1204
|
+
const DOCS_PAYLOADS = "https://docs.remotedraw.com/docs/api#payloads";
|
|
1205
|
+
const DOCS_ENDPOINTS = "https://docs.remotedraw.com/docs/api#endpoints";
|
|
1206
|
+
const DOCS_AUTH = "https://docs.remotedraw.com/docs/api#auth";
|
|
1207
|
+
const targetWizardDetails = {
|
|
1208
|
+
web: {
|
|
1209
|
+
summary: "Voor webapps met een tekenoppervlak in de browser.",
|
|
1210
|
+
explanation: "Kies dit als je website of webapp de tekeningen toont. De telefoon kan de RemoteDraw-iOS-app of een ingebouwde webzender gebruiken.",
|
|
1211
|
+
docsUrl: DOCS_INTEGRATION_PATHS,
|
|
1212
|
+
docsLabel: "Webapp-docs openen",
|
|
1213
|
+
},
|
|
1214
|
+
desktop: {
|
|
1215
|
+
summary: "Voor desktopapps en andere maatwerkinterfaces.",
|
|
1216
|
+
explanation: "Kies dit voor Electron, Tauri, een native desktopapp of een bestaande niet-webinterface. Je koppelt de ontvanger via de JavaScript-client of HTTP-API.",
|
|
1217
|
+
docsUrl: DOCS_INTEGRATION_PATHS,
|
|
1218
|
+
docsLabel: "Desktop-docs openen",
|
|
1219
|
+
},
|
|
1220
|
+
ios: {
|
|
1221
|
+
summary: "Voor een eigen iOS-app die als telefoonzender werkt.",
|
|
1222
|
+
explanation: "Kies dit als je zelf de native tekenervaring, navigatie en vormgeving op de iPhone beheert. De wizard maakt Swift-helpers aan.",
|
|
1223
|
+
docsUrl: DOCS_INTEGRATION_PATHS,
|
|
1224
|
+
docsLabel: "iOS-docs openen",
|
|
1225
|
+
},
|
|
1226
|
+
headless: {
|
|
1227
|
+
summary: "Voor backends, automatisering en diensten zonder interface.",
|
|
1228
|
+
explanation: "Kies dit als je sessies en tekengegevens rechtstreeks via de HTTP-API verwerkt en geen standaard ontvanger- of zenderinterface nodig hebt.",
|
|
1229
|
+
docsUrl: DOCS_ENDPOINTS,
|
|
1230
|
+
docsLabel: "Headless-docs openen",
|
|
1231
|
+
},
|
|
1232
|
+
};
|
|
1233
|
+
function wizardChoices(choices, details) {
|
|
1234
|
+
return dutchWizardChoices(choices).map((choiceOption) => ({
|
|
1235
|
+
...choiceOption,
|
|
1236
|
+
details: details[choiceOption.value],
|
|
1237
|
+
}));
|
|
1238
|
+
}
|
|
1239
|
+
const senderWizardDetails = {
|
|
1240
|
+
"remotedraw-ios": {
|
|
1241
|
+
summary: "De officiële RemoteDraw-app is de tekenzender op de telefoon.",
|
|
1242
|
+
explanation: "Dit is de snelste route. Je ontvanger toont een QR-code of deelnamelink; de RemoteDraw-iOS-app verzorgt de tekeninterface en synchronisatie.",
|
|
1243
|
+
docsUrl: DOCS_INTEGRATION_PATHS,
|
|
1244
|
+
docsLabel: "iOS-zenderdocs openen",
|
|
1245
|
+
},
|
|
1246
|
+
"embedded-web": {
|
|
1247
|
+
summary: "Een webzender die onderdeel is van je eigen product.",
|
|
1248
|
+
explanation: "Kies dit als je de telefooninterface zelf wilt vormgeven in React of een andere webstack. Je app verwerkt de deelnamelink en zenderstatus.",
|
|
1249
|
+
docsUrl: DOCS_INTEGRATION_PATHS,
|
|
1250
|
+
docsLabel: "Webzenderdocs openen",
|
|
1251
|
+
},
|
|
1252
|
+
"own-ios": {
|
|
1253
|
+
summary: "Een native iOS-zender die je volledig zelf beheert.",
|
|
1254
|
+
explanation: "Kies dit voor eigen navigatie, branding, pushberichten of app-routing. Je ontvangt Swift-helpers voor deelnamelinks en zenderverzoeken.",
|
|
1255
|
+
docsUrl: DOCS_INTEGRATION_PATHS,
|
|
1256
|
+
docsLabel: "Swift-zenderdocs openen",
|
|
1257
|
+
},
|
|
1258
|
+
headless: {
|
|
1259
|
+
summary: "Een eigen zender zonder door RemoteDraw geleverde interface.",
|
|
1260
|
+
explanation: "Kies dit als je rechtstreeks met de HTTP-zenderroutes werkt, bijvoorbeeld vanuit automatisering, hardware of een volledig eigen client.",
|
|
1261
|
+
docsUrl: DOCS_ENDPOINTS,
|
|
1262
|
+
docsLabel: "HTTP-zenderdocs openen",
|
|
1263
|
+
},
|
|
1264
|
+
};
|
|
1265
|
+
const sdkWizardDetails = {
|
|
1266
|
+
react: {
|
|
1267
|
+
summary: "React-componenten voor koppeling, ontvanger en zender.",
|
|
1268
|
+
explanation: "Kies dit voor React of Next.js. De starter gebruikt de componenten en hooks van @remotedraw/react voor sessiestatus en live tekeningen.",
|
|
1269
|
+
docsUrl: DOCS_SDKS,
|
|
1270
|
+
docsLabel: "React SDK-docs openen",
|
|
1271
|
+
},
|
|
1272
|
+
svelte: {
|
|
1273
|
+
summary: "Een Svelte-store bovenop de frameworkvrije client.",
|
|
1274
|
+
explanation: "Kies dit voor Svelte of SvelteKit. Je beheert zelf de markup en bindt sessie-, teken- en zenderstatus via de Svelte-store.",
|
|
1275
|
+
docsUrl: DOCS_SDKS,
|
|
1276
|
+
docsLabel: "Svelte SDK-docs openen",
|
|
1277
|
+
},
|
|
1278
|
+
js: {
|
|
1279
|
+
summary: "Frameworkvrije JavaScript-clients en protocoltypen.",
|
|
1280
|
+
explanation: "Kies dit voor desktopapps, andere webframeworks of maatwerkclients. De starter gebruikt fetch en de gedeelde RemoteDraw-contracten.",
|
|
1281
|
+
docsUrl: DOCS_ENDPOINTS,
|
|
1282
|
+
docsLabel: "JavaScript-docs openen",
|
|
1283
|
+
},
|
|
1284
|
+
swift: {
|
|
1285
|
+
summary: "Native Swift-helpers voor een eigen iOS-zender.",
|
|
1286
|
+
explanation: "Kies dit als je zender in Swift of SwiftUI bouwt. De starter bevat parsing van deelnamelinks en getypeerde zenderverzoeken.",
|
|
1287
|
+
docsUrl: DOCS_SDKS,
|
|
1288
|
+
docsLabel: "Swift SDK-docs openen",
|
|
1289
|
+
},
|
|
1290
|
+
headless: {
|
|
1291
|
+
summary: "Alleen configuratie, zonder UI-pakket.",
|
|
1292
|
+
explanation: "Kies dit als een andere taal of eigen client de HTTP-API aanroept. De wizard voegt geen frameworkafhankelijkheid toe.",
|
|
1293
|
+
docsUrl: DOCS_ENDPOINTS,
|
|
1294
|
+
docsLabel: "HTTP API-docs openen",
|
|
1295
|
+
},
|
|
1296
|
+
};
|
|
1297
|
+
function senderPromptChoices(target) {
|
|
1298
|
+
if (target === "ios") {
|
|
1299
|
+
return [
|
|
1300
|
+
{
|
|
1301
|
+
value: "own-ios",
|
|
1302
|
+
label: "Own iOS sender",
|
|
1303
|
+
hint: "Use Swift join-link and sender request helpers.",
|
|
1304
|
+
},
|
|
1305
|
+
];
|
|
1306
|
+
}
|
|
1307
|
+
if (target === "headless") {
|
|
1308
|
+
return [
|
|
1309
|
+
{
|
|
1310
|
+
value: "headless",
|
|
1311
|
+
label: "Headless/custom sender",
|
|
1312
|
+
hint: "No hosted phone UI.",
|
|
1313
|
+
},
|
|
1314
|
+
];
|
|
1315
|
+
}
|
|
1316
|
+
if (target === "desktop") {
|
|
1317
|
+
return [
|
|
1318
|
+
{
|
|
1319
|
+
value: "remotedraw-ios",
|
|
1320
|
+
label: "RemoteDraw iOS app",
|
|
1321
|
+
hint: "Show a join URL or QR code from your receiver.",
|
|
1322
|
+
},
|
|
1323
|
+
{
|
|
1324
|
+
value: "headless",
|
|
1325
|
+
label: "Headless/custom sender",
|
|
1326
|
+
hint: "Build directly on the HTTP sender routes.",
|
|
1327
|
+
},
|
|
1328
|
+
];
|
|
1329
|
+
}
|
|
1330
|
+
return [
|
|
1331
|
+
{
|
|
1332
|
+
value: "remotedraw-ios",
|
|
1333
|
+
label: "RemoteDraw iOS app",
|
|
1334
|
+
hint: "Fastest path: scan the receiver QR code.",
|
|
1335
|
+
},
|
|
1336
|
+
{
|
|
1337
|
+
value: "embedded-web",
|
|
1338
|
+
label: "Own web sender",
|
|
1339
|
+
hint: "Use a web sender component or raw sender helpers.",
|
|
1340
|
+
},
|
|
1341
|
+
];
|
|
1342
|
+
}
|
|
1343
|
+
function sdkPromptChoices(target, sender) {
|
|
1344
|
+
if (target === "ios" || sender === "own-ios") {
|
|
1345
|
+
return [
|
|
1346
|
+
{
|
|
1347
|
+
value: "swift",
|
|
1348
|
+
label: "Swift helpers",
|
|
1349
|
+
hint: "For customer-owned iOS sender apps.",
|
|
1350
|
+
},
|
|
1351
|
+
];
|
|
1352
|
+
}
|
|
1353
|
+
if (target === "headless" || sender === "headless") {
|
|
1354
|
+
return [
|
|
1355
|
+
{
|
|
1356
|
+
value: "js",
|
|
1357
|
+
label: "JavaScript client",
|
|
1358
|
+
hint: "Framework-free HTTP clients and protocol types.",
|
|
1359
|
+
},
|
|
1360
|
+
{
|
|
1361
|
+
value: "headless",
|
|
1362
|
+
label: "Config only",
|
|
1363
|
+
hint: "No UI package dependency.",
|
|
1364
|
+
},
|
|
1365
|
+
];
|
|
1366
|
+
}
|
|
1367
|
+
if (target === "desktop") {
|
|
1368
|
+
return [
|
|
1369
|
+
{
|
|
1370
|
+
value: "js",
|
|
1371
|
+
label: "JavaScript client",
|
|
1372
|
+
hint: "Framework-free HTTP clients and protocol types.",
|
|
1373
|
+
},
|
|
1374
|
+
];
|
|
1375
|
+
}
|
|
1376
|
+
return [
|
|
1377
|
+
{
|
|
1378
|
+
value: "react",
|
|
1379
|
+
label: "React SDK",
|
|
1380
|
+
hint: "Receiver, pairing, and optional embedded sender components.",
|
|
1381
|
+
},
|
|
1382
|
+
{
|
|
1383
|
+
value: "svelte",
|
|
1384
|
+
label: "Svelte SDK",
|
|
1385
|
+
hint: "Svelte receiver store plus shared HTTP clients.",
|
|
1386
|
+
},
|
|
1387
|
+
{
|
|
1388
|
+
value: "js",
|
|
1389
|
+
label: "No framework",
|
|
1390
|
+
hint: "Framework-free clients and raw HTTP starter files.",
|
|
1391
|
+
},
|
|
1392
|
+
];
|
|
1393
|
+
}
|
|
1394
|
+
function presetPromptChoices() {
|
|
1395
|
+
return [
|
|
1396
|
+
{
|
|
1397
|
+
value: "signature",
|
|
1398
|
+
label: "Signature strip",
|
|
1399
|
+
hint: "Product-ready branded field with exact surface mapping.",
|
|
1400
|
+
},
|
|
1401
|
+
{
|
|
1402
|
+
value: "sketch",
|
|
1403
|
+
label: "Custom drawing surface",
|
|
1404
|
+
hint: "Flexible normalized input for your own receiver UI.",
|
|
1405
|
+
},
|
|
1406
|
+
];
|
|
1407
|
+
}
|
|
1408
|
+
function packageManagerPromptChoices() {
|
|
1409
|
+
return packageManagerChoices.map((packageManager) => ({
|
|
1410
|
+
value: packageManager,
|
|
1411
|
+
label: packageManager,
|
|
1412
|
+
}));
|
|
1413
|
+
}
|
|
1414
|
+
function apiEndpointPromptChoices() {
|
|
1415
|
+
return [
|
|
1416
|
+
{
|
|
1417
|
+
value: "deployment",
|
|
1418
|
+
label: "Deployment URL placeholder",
|
|
1419
|
+
hint: "Fill in your Convex site origin later.",
|
|
1420
|
+
},
|
|
1421
|
+
{
|
|
1422
|
+
value: "local",
|
|
1423
|
+
label: "Local dev server",
|
|
1424
|
+
hint: "Use http://localhost:3210.",
|
|
1425
|
+
},
|
|
1426
|
+
];
|
|
1427
|
+
}
|
|
1428
|
+
const presetWizardDetails = {
|
|
1429
|
+
signature: {
|
|
1430
|
+
summary: "De productklare RemoteDraw-handtekeningstrook.",
|
|
1431
|
+
explanation: "Kies dit voor de complete, merkbare SignatureStrip met een exact passend telefoonoppervlak. Je kunt logo, tekst, kleur en omliggende formulier-UI aanpassen.",
|
|
1432
|
+
docsUrl: DOCS_PAYLOADS,
|
|
1433
|
+
docsLabel: "Handtekeningdocs openen",
|
|
1434
|
+
},
|
|
1435
|
+
initials: {
|
|
1436
|
+
summary: "Een klein tekenveld voor initialen.",
|
|
1437
|
+
explanation: "Gebruik dit wanneer iemand op één of meerdere plekken korte initialen moet plaatsen, bijvoorbeeld bij documentcontrole.",
|
|
1438
|
+
docsUrl: DOCS_PAYLOADS,
|
|
1439
|
+
docsLabel: "Initialendocs openen",
|
|
1440
|
+
},
|
|
1441
|
+
approval: {
|
|
1442
|
+
summary: "Vrije tekeninvoer voor een visueel akkoord.",
|
|
1443
|
+
explanation: "Gebruik dit voor goedkeuringen waarbij een vink, paraaf of korte markering voldoende is en je workflow de status beheert.",
|
|
1444
|
+
docsUrl: DOCS_PAYLOADS,
|
|
1445
|
+
docsLabel: "Goedkeuringsdocs openen",
|
|
1446
|
+
},
|
|
1447
|
+
sketch: {
|
|
1448
|
+
summary: "Genormaliseerde invoer voor een eigen ontvangeroppervlak.",
|
|
1449
|
+
explanation: "Kies dit wanneer jouw product de foto, kaart, PDF, canvas of andere receiver-UI beheert. RemoteDraw levert de invoerprimitieven zonder een voorgeschreven componentontwerp.",
|
|
1450
|
+
docsUrl: DOCS_PAYLOADS,
|
|
1451
|
+
docsLabel: "Schetsdocs openen",
|
|
1452
|
+
},
|
|
1453
|
+
photoMarkup: {
|
|
1454
|
+
summary: "Aantekeningen bovenop een foto.",
|
|
1455
|
+
explanation: "Gebruik dit voor inspecties, feedback of aanwijzingen op beeldmateriaal. Je app levert de foto en bewaart de relatie met de tekeningen.",
|
|
1456
|
+
docsUrl: DOCS_PAYLOADS,
|
|
1457
|
+
docsLabel: "Fotoannotatiedocs openen",
|
|
1458
|
+
},
|
|
1459
|
+
pdfMarkup: {
|
|
1460
|
+
summary: "Aantekeningen op een PDF-pagina.",
|
|
1461
|
+
explanation: "Gebruik dit voor documentreview. Je app rendert de juiste pagina en koppelt RemoteDraw-coördinaten aan die vaste weergave.",
|
|
1462
|
+
docsUrl: DOCS_PAYLOADS,
|
|
1463
|
+
docsLabel: "PDF-annotatiedocs openen",
|
|
1464
|
+
},
|
|
1465
|
+
mapMarkup: {
|
|
1466
|
+
summary: "Tekeningen en aanwijzingen op een kaart.",
|
|
1467
|
+
explanation: "Gebruik dit voor routes, locaties of ruimtelijke feedback. Je app beheert de kaart en viewport; RemoteDraw synchroniseert de invoer.",
|
|
1468
|
+
docsUrl: DOCS_PAYLOADS,
|
|
1469
|
+
docsLabel: "Kaartannotatiedocs openen",
|
|
1470
|
+
},
|
|
1471
|
+
screenMarkup: {
|
|
1472
|
+
summary: "Aantekeningen op een scherm of applicatieweergave.",
|
|
1473
|
+
explanation: "Gebruik dit voor support, demo's en UI-feedback. Je ontvanger levert het schermbeeld waarop de telefoonmarkeringen worden geprojecteerd.",
|
|
1474
|
+
docsUrl: DOCS_PAYLOADS,
|
|
1475
|
+
docsLabel: "Schermaantekeningdocs openen",
|
|
1476
|
+
},
|
|
1477
|
+
designReview: {
|
|
1478
|
+
summary: "Gerichte visuele feedback op een ontwerp.",
|
|
1479
|
+
explanation: "Gebruik dit voor ontwerpbeoordelingen met pijlen, vormen en vrije lijnen. Je product beheert opmerkingen, versies en besluitvorming.",
|
|
1480
|
+
docsUrl: DOCS_PAYLOADS,
|
|
1481
|
+
docsLabel: "Ontwerpbeoordelingsdocs openen",
|
|
1482
|
+
},
|
|
1483
|
+
pointer: {
|
|
1484
|
+
summary: "Live aanwijzen zonder blijvende tekening.",
|
|
1485
|
+
explanation: "Gebruik dit voor presentaties en begeleiding waarbij de telefoon als aanwijzer dient en beweging belangrijker is dan opgeslagen inkt.",
|
|
1486
|
+
docsUrl: DOCS_PAYLOADS,
|
|
1487
|
+
docsLabel: "Aanwijzerdocs openen",
|
|
1488
|
+
},
|
|
1489
|
+
};
|
|
1490
|
+
const packageManagerWizardDetails = {
|
|
1491
|
+
npm: {
|
|
1492
|
+
summary: "De standaard package manager die met Node.js wordt geleverd.",
|
|
1493
|
+
explanation: "Kies npm als je project package-lock.json gebruikt of geen voorkeur heeft. De wizard gebruikt npm voor installatie- en vervolgopdrachten.",
|
|
1494
|
+
docsUrl: "https://docs.npmjs.com/",
|
|
1495
|
+
docsLabel: "npm-docs openen",
|
|
1496
|
+
},
|
|
1497
|
+
pnpm: {
|
|
1498
|
+
summary: "Een snelle package manager met een gedeelde pakketopslag.",
|
|
1499
|
+
explanation: "Kies pnpm als je project pnpm-lock.yaml gebruikt, strikte dependency-isolatie wenst of al deel is van een pnpm-workspace.",
|
|
1500
|
+
docsUrl: "https://pnpm.io/",
|
|
1501
|
+
docsLabel: "pnpm-docs openen",
|
|
1502
|
+
},
|
|
1503
|
+
yarn: {
|
|
1504
|
+
summary: "Een package manager met workspace- en Plug'n'Play-ondersteuning.",
|
|
1505
|
+
explanation: "Kies Yarn als je project yarn.lock gebruikt. De wizard sluit aan op de bestaande Yarn-versie en projectconfiguratie.",
|
|
1506
|
+
docsUrl: "https://yarnpkg.com/getting-started/usage",
|
|
1507
|
+
docsLabel: "Yarn-docs openen",
|
|
1508
|
+
},
|
|
1509
|
+
bun: {
|
|
1510
|
+
summary: "De snelle package manager die onderdeel is van de Bun-runtime.",
|
|
1511
|
+
explanation: "Kies Bun als je project bun.lock gebruikt of Bun al inzet voor scripts, tests en installatie van dependencies.",
|
|
1512
|
+
docsUrl: "https://bun.com/docs/pm/cli/install",
|
|
1513
|
+
docsLabel: "Bun-docs openen",
|
|
1514
|
+
},
|
|
1515
|
+
};
|
|
1516
|
+
const apiEndpointWizardDetails = {
|
|
1517
|
+
deployment: {
|
|
1518
|
+
summary: "Een tijdelijke URL die je later door je echte API-origin vervangt.",
|
|
1519
|
+
explanation: "Kies dit voor een nieuw project dat nog niet aan een lokale server is gekoppeld. De gegenereerde configuratie bevat een duidelijke deployment-placeholder.",
|
|
1520
|
+
docsUrl: DOCS_AUTH,
|
|
1521
|
+
docsLabel: "Deploymentdocs openen",
|
|
1522
|
+
},
|
|
1523
|
+
local: {
|
|
1524
|
+
summary: "Verbind met de lokale API op http://localhost:3210.",
|
|
1525
|
+
explanation: "Kies dit wanneer je RemoteDraw lokaal draait en de ontvanger op dezelfde computer de ontwikkelserver kan bereiken.",
|
|
1526
|
+
docsUrl: DOCS_AUTH,
|
|
1527
|
+
docsLabel: "Lokale API-docs openen",
|
|
1528
|
+
},
|
|
1529
|
+
};
|
|
1530
|
+
function agentOptionCatalog() {
|
|
1531
|
+
const catalogOption = (choiceOption, compatibility) => ({
|
|
1532
|
+
value: choiceOption.value,
|
|
1533
|
+
label: choiceOption.label,
|
|
1534
|
+
...(choiceOption.hint ? { hint: choiceOption.hint } : {}),
|
|
1535
|
+
details: choiceOption.details,
|
|
1536
|
+
...(compatibility ?? {}),
|
|
1537
|
+
});
|
|
1538
|
+
const targets = wizardChoices(targetPromptChoices(), targetWizardDetails);
|
|
1539
|
+
const senders = senderChoices.map((value) => {
|
|
1540
|
+
const compatibleTargets = targetChoices.filter((target) => senderPromptChoices(target).some((option) => option.value === value));
|
|
1541
|
+
const prompt = compatibleTargets
|
|
1542
|
+
.flatMap((target) => senderPromptChoices(target))
|
|
1543
|
+
.find((option) => option.value === value);
|
|
1544
|
+
return catalogOption(wizardChoices([prompt], senderWizardDetails)[0], {
|
|
1545
|
+
compatibleTargets,
|
|
1546
|
+
});
|
|
1547
|
+
});
|
|
1548
|
+
const sdks = sdkChoices.map((value) => {
|
|
1549
|
+
const compatiblePlans = targetChoices.flatMap((target) => senderPromptChoices(target).flatMap((sender) => sdkPromptChoices(target, sender.value).some((option) => option.value === value)
|
|
1550
|
+
? [{ target, sender: sender.value }]
|
|
1551
|
+
: []));
|
|
1552
|
+
const firstPlan = compatiblePlans[0];
|
|
1553
|
+
const prompt = sdkPromptChoices(firstPlan.target, firstPlan.sender).find((option) => option.value === value);
|
|
1554
|
+
return catalogOption(wizardChoices([prompt], sdkWizardDetails)[0], {
|
|
1555
|
+
compatiblePlans,
|
|
1556
|
+
});
|
|
1557
|
+
});
|
|
1558
|
+
return {
|
|
1559
|
+
ok: true,
|
|
1560
|
+
command: "options",
|
|
1561
|
+
schemaVersion: 1,
|
|
1562
|
+
fields: [
|
|
1563
|
+
{
|
|
1564
|
+
key: "appName",
|
|
1565
|
+
flag: "--app-name",
|
|
1566
|
+
label: "Projectnaam",
|
|
1567
|
+
kind: "text",
|
|
1568
|
+
requiredFor: ["new"],
|
|
1569
|
+
options: [],
|
|
1570
|
+
},
|
|
1571
|
+
{
|
|
1572
|
+
key: "target",
|
|
1573
|
+
flag: "--target",
|
|
1574
|
+
label: "Projecttype",
|
|
1575
|
+
kind: "select",
|
|
1576
|
+
options: targets.map((option) => catalogOption(option)),
|
|
1577
|
+
},
|
|
1578
|
+
{
|
|
1579
|
+
key: "sender",
|
|
1580
|
+
flag: "--sender",
|
|
1581
|
+
label: "Telefoonzender",
|
|
1582
|
+
kind: "select",
|
|
1583
|
+
options: senders,
|
|
1584
|
+
},
|
|
1585
|
+
{
|
|
1586
|
+
key: "sdk",
|
|
1587
|
+
flag: "--sdk",
|
|
1588
|
+
label: "UI-framework / SDK",
|
|
1589
|
+
kind: "select",
|
|
1590
|
+
options: sdks,
|
|
1591
|
+
},
|
|
1592
|
+
{
|
|
1593
|
+
key: "preset",
|
|
1594
|
+
flag: "--preset",
|
|
1595
|
+
label: "Startpunt",
|
|
1596
|
+
kind: "select",
|
|
1597
|
+
options: wizardChoices(presetPromptChoices(), presetWizardDetails).map((option) => catalogOption(option)),
|
|
1598
|
+
},
|
|
1599
|
+
{
|
|
1600
|
+
key: "packageManager",
|
|
1601
|
+
flag: "--package-manager",
|
|
1602
|
+
label: "Pakketbeheerder",
|
|
1603
|
+
kind: "select",
|
|
1604
|
+
options: wizardChoices(packageManagerPromptChoices(), packageManagerWizardDetails).map((option) => catalogOption(option)),
|
|
1605
|
+
},
|
|
1606
|
+
{
|
|
1607
|
+
key: "apiBaseUrl",
|
|
1608
|
+
flag: "--api-base-url",
|
|
1609
|
+
label: "API-eindpunt",
|
|
1610
|
+
kind: "select",
|
|
1611
|
+
options: wizardChoices(apiEndpointPromptChoices(), apiEndpointWizardDetails).map((option) => ({
|
|
1612
|
+
...catalogOption(option),
|
|
1613
|
+
endpoint: option.value,
|
|
1614
|
+
value: apiBaseUrlForEndpoint(option.value),
|
|
1615
|
+
})),
|
|
1616
|
+
},
|
|
1617
|
+
],
|
|
1618
|
+
defaults: {
|
|
1619
|
+
target: "web",
|
|
1620
|
+
senderByTarget: Object.fromEntries(targetChoices.map((target) => [target, defaultSenderForTarget(target)])),
|
|
1621
|
+
sdkByTargetAndSender: Object.fromEntries(targetChoices.flatMap((target) => senderPromptChoices(target).map((sender) => [
|
|
1622
|
+
`${target}:${sender.value}`,
|
|
1623
|
+
defaultSdkForChoices(target, sender.value),
|
|
1624
|
+
]))),
|
|
1625
|
+
preset: "signature",
|
|
1626
|
+
packageManager: "npm",
|
|
1627
|
+
apiBaseUrl: "https://<deployment>.convex.site",
|
|
1628
|
+
},
|
|
1629
|
+
safety: {
|
|
1630
|
+
dryRunFlag: "--dry-run",
|
|
1631
|
+
nonInteractiveFlag: "--non-interactive",
|
|
1632
|
+
overwriteFlag: "--force",
|
|
1633
|
+
localOnlyFlag: "--offline",
|
|
1634
|
+
},
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
function apiBaseUrlForEndpoint(endpoint) {
|
|
1638
|
+
if (endpoint === "local")
|
|
1639
|
+
return "http://localhost:3210";
|
|
1640
|
+
return "https://<deployment>.convex.site";
|
|
1641
|
+
}
|
|
1642
|
+
export function projectSetupDefinition() {
|
|
1643
|
+
return {
|
|
1644
|
+
defaults: {
|
|
1645
|
+
appName: "RemoteDraw App",
|
|
1646
|
+
target: "web",
|
|
1647
|
+
sender: "remotedraw-ios",
|
|
1648
|
+
sdk: "react",
|
|
1649
|
+
preset: "signature",
|
|
1650
|
+
packageManager: "npm",
|
|
1651
|
+
apiEndpoint: "deployment",
|
|
1652
|
+
},
|
|
1653
|
+
fields: [
|
|
1654
|
+
{
|
|
1655
|
+
key: "appName",
|
|
1656
|
+
label: "Projectnaam",
|
|
1657
|
+
kind: "text",
|
|
1658
|
+
validate: validateWizardProjectName,
|
|
1659
|
+
},
|
|
1660
|
+
{
|
|
1661
|
+
key: "target",
|
|
1662
|
+
label: "Projecttype",
|
|
1663
|
+
kind: "select",
|
|
1664
|
+
choices: () => wizardChoices(targetPromptChoices(), targetWizardDetails),
|
|
1665
|
+
},
|
|
1666
|
+
{
|
|
1667
|
+
key: "sender",
|
|
1668
|
+
label: "Telefoonzender",
|
|
1669
|
+
kind: "select",
|
|
1670
|
+
choices: (values) => wizardChoices(senderPromptChoices(choice(values.target, targetChoices, "target")), senderWizardDetails),
|
|
1671
|
+
},
|
|
1672
|
+
{
|
|
1673
|
+
key: "sdk",
|
|
1674
|
+
label: "UI-framework / SDK",
|
|
1675
|
+
kind: "select",
|
|
1676
|
+
choices: (values) => wizardChoices(sdkPromptChoices(choice(values.target, targetChoices, "target"), choice(values.sender, senderChoices, "sender")), sdkWizardDetails),
|
|
1677
|
+
},
|
|
1678
|
+
{
|
|
1679
|
+
key: "preset",
|
|
1680
|
+
label: "Startpunt",
|
|
1681
|
+
kind: "select",
|
|
1682
|
+
choices: () => wizardChoices(presetPromptChoices(), presetWizardDetails),
|
|
1683
|
+
},
|
|
1684
|
+
{
|
|
1685
|
+
key: "packageManager",
|
|
1686
|
+
label: "Pakketbeheerder",
|
|
1687
|
+
kind: "select",
|
|
1688
|
+
choices: () => wizardChoices(packageManagerPromptChoices(), packageManagerWizardDetails),
|
|
1689
|
+
},
|
|
1690
|
+
{
|
|
1691
|
+
key: "apiEndpoint",
|
|
1692
|
+
label: "API-eindpunt",
|
|
1693
|
+
kind: "select",
|
|
1694
|
+
choices: () => wizardChoices(apiEndpointPromptChoices(), apiEndpointWizardDetails),
|
|
1695
|
+
},
|
|
1696
|
+
],
|
|
1697
|
+
normalize(values, changed) {
|
|
1698
|
+
const next = { ...values };
|
|
1699
|
+
const target = choice(next.target, targetChoices, "target");
|
|
1700
|
+
const availableSenders = senderPromptChoices(target);
|
|
1701
|
+
if (changed === "target" ||
|
|
1702
|
+
!availableSenders.some((item) => item.value === next.sender)) {
|
|
1703
|
+
next.sender = defaultPromptValue(defaultSenderForTarget(target), availableSenders);
|
|
1704
|
+
}
|
|
1705
|
+
const sender = choice(next.sender, senderChoices, "sender");
|
|
1706
|
+
const availableSdks = sdkPromptChoices(target, sender);
|
|
1707
|
+
if (changed === "target" ||
|
|
1708
|
+
changed === "sender" ||
|
|
1709
|
+
!availableSdks.some((item) => item.value === next.sdk)) {
|
|
1710
|
+
next.sdk = defaultPromptValue(defaultSdkForChoices(target, sender), availableSdks);
|
|
1711
|
+
}
|
|
1712
|
+
return next;
|
|
1713
|
+
},
|
|
1714
|
+
review(values) {
|
|
1715
|
+
const plan = planFromWizardValues(values);
|
|
1716
|
+
return [
|
|
1717
|
+
["Project", plan.appName],
|
|
1718
|
+
["Map", plan.slug],
|
|
1719
|
+
["Projecttype", plan.target],
|
|
1720
|
+
["Telefoonzender", plan.sender],
|
|
1721
|
+
["SDK", plan.sdk],
|
|
1722
|
+
["Startpunt", plan.preset],
|
|
1723
|
+
["Pakketbeheerder", plan.packageManager],
|
|
1724
|
+
["API-eindpunt", plan.apiBaseUrl],
|
|
1725
|
+
];
|
|
1726
|
+
},
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1729
|
+
function validateWizardProjectName(value) {
|
|
1730
|
+
return value.trim() === "" ? "Voer een projectnaam in." : undefined;
|
|
1731
|
+
}
|
|
1732
|
+
const dutchWizardText = {
|
|
1733
|
+
"Web app": "Webapp",
|
|
1734
|
+
"Desktop or custom app": "Desktop- of maatwerkapp",
|
|
1735
|
+
"iOS sender app": "iOS-zenderapp",
|
|
1736
|
+
"Headless service": "Headless-service",
|
|
1737
|
+
"Own iOS sender": "Eigen iOS-zender",
|
|
1738
|
+
"Use Swift join-link and sender request helpers.": "Gebruik Swift-helpers voor deelnamelinks en zenderverzoeken.",
|
|
1739
|
+
"Headless/custom sender": "Headless- of maatwerkzender",
|
|
1740
|
+
"No hosted phone UI.": "Geen gehoste telefooninterface.",
|
|
1741
|
+
"RemoteDraw iOS app": "RemoteDraw-iOS-app",
|
|
1742
|
+
"Show a join URL or QR code from your receiver.": "Toon vanuit je ontvanger een deelname-URL of QR-code.",
|
|
1743
|
+
"Build directly on the HTTP sender routes.": "Bouw rechtstreeks op de HTTP-zenderroutes.",
|
|
1744
|
+
"Fastest path: scan the receiver QR code.": "Snelste route: scan de QR-code van de ontvanger.",
|
|
1745
|
+
"Own web sender": "Eigen webzender",
|
|
1746
|
+
"Use a web sender component or raw sender helpers.": "Gebruik een webzendercomponent of losse zenderhelpers.",
|
|
1747
|
+
"Swift helpers": "Swift-helpers",
|
|
1748
|
+
"For customer-owned iOS sender apps.": "Voor iOS-zenderapps in eigen beheer.",
|
|
1749
|
+
"JavaScript client": "JavaScript-client",
|
|
1750
|
+
"Framework-free HTTP clients and protocol types.": "Frameworkvrije HTTP-clients en protocoltypen.",
|
|
1751
|
+
"Config only": "Alleen configuratie",
|
|
1752
|
+
"No UI package dependency.": "Geen afhankelijkheid van een UI-pakket.",
|
|
1753
|
+
"Receiver, pairing, and optional embedded sender components.": "Componenten voor ontvanger, koppeling en optionele ingebouwde zender.",
|
|
1754
|
+
"Svelte receiver store plus shared HTTP clients.": "Svelte-store voor de ontvanger met gedeelde HTTP-clients.",
|
|
1755
|
+
"No framework": "Geen framework",
|
|
1756
|
+
"Framework-free clients and raw HTTP starter files.": "Frameworkvrije clients en kale HTTP-startbestanden.",
|
|
1757
|
+
"Signature strip": "Handtekeningstrook",
|
|
1758
|
+
"Product-ready branded field with exact surface mapping.": "Productklaar merkveld met exacte oppervlaktekoppeling.",
|
|
1759
|
+
"Custom drawing surface": "Eigen tekenoppervlak",
|
|
1760
|
+
"Flexible normalized input for your own receiver UI.": "Flexibele genormaliseerde invoer voor je eigen ontvanger-UI.",
|
|
1761
|
+
Signature: "Handtekening",
|
|
1762
|
+
Initials: "Initialen",
|
|
1763
|
+
Approval: "Goedkeuring",
|
|
1764
|
+
Sketch: "Schets",
|
|
1765
|
+
"Photo markup": "Fotoannotatie",
|
|
1766
|
+
"PDF markup": "PDF-annotatie",
|
|
1767
|
+
"Map markup": "Kaartannotatie",
|
|
1768
|
+
"Screen markup": "Schermaantekening",
|
|
1769
|
+
"Design review": "Ontwerpbeoordeling",
|
|
1770
|
+
Pointer: "Aanwijzer",
|
|
1771
|
+
"Deployment URL placeholder": "Tijdelijke deployment-URL",
|
|
1772
|
+
"Fill in your Convex site origin later.": "Vul later de oorsprong van je Convex-site in.",
|
|
1773
|
+
"Local dev server": "Lokale ontwikkelserver",
|
|
1774
|
+
"Use http://localhost:3210.": "Gebruik http://localhost:3210.",
|
|
1775
|
+
};
|
|
1776
|
+
function dutchWizardChoices(choices) {
|
|
1777
|
+
return choices.map((choiceOption) => ({
|
|
1778
|
+
...choiceOption,
|
|
1779
|
+
label: dutchWizardText[choiceOption.label] ?? choiceOption.label,
|
|
1780
|
+
...(choiceOption.hint == null
|
|
1781
|
+
? {}
|
|
1782
|
+
: {
|
|
1783
|
+
hint: dutchWizardText[choiceOption.hint] ?? choiceOption.hint,
|
|
1784
|
+
}),
|
|
1785
|
+
}));
|
|
1786
|
+
}
|
|
1787
|
+
function planFromWizardValues(values) {
|
|
1788
|
+
const appName = values.appName.trim();
|
|
1789
|
+
const target = choice(values.target, targetChoices, "target");
|
|
1790
|
+
const sender = choice(values.sender, senderChoices, "sender");
|
|
1791
|
+
const sdk = choice(values.sdk, sdkChoices, "sdk");
|
|
1792
|
+
const plan = {
|
|
1793
|
+
appName,
|
|
1794
|
+
slug: slugify(appName),
|
|
1795
|
+
target,
|
|
1796
|
+
sender,
|
|
1797
|
+
sdk,
|
|
1798
|
+
preset: choice(values.preset, presetChoices, "preset"),
|
|
1799
|
+
packageManager: choice(values.packageManager, packageManagerChoices, "package-manager"),
|
|
1800
|
+
apiBaseUrl: apiBaseUrlForEndpoint(choice(values.apiEndpoint, apiEndpointChoices, "api endpoint")),
|
|
1801
|
+
};
|
|
1802
|
+
const nameError = validateProjectName(plan.appName);
|
|
1803
|
+
if (nameError)
|
|
1804
|
+
throw new Error(nameError);
|
|
1805
|
+
validatePlan(plan);
|
|
1806
|
+
return plan;
|
|
1807
|
+
}
|
|
1808
|
+
function planFromArgs(parsed, options) {
|
|
1809
|
+
const appName = readString(parsed, "app-name") ??
|
|
1810
|
+
options.defaults?.appName ??
|
|
1811
|
+
options.defaultAppName;
|
|
1812
|
+
if (options.appNameRequired && !readString(parsed, "app-name")) {
|
|
1813
|
+
throw new Error("--app-name is required.");
|
|
1814
|
+
}
|
|
1815
|
+
const target = choice(readString(parsed, "target") ?? options.defaults?.target ?? "web", targetChoices, "target");
|
|
1816
|
+
const sender = choice(readString(parsed, "sender") ??
|
|
1817
|
+
options.defaults?.sender ??
|
|
1818
|
+
defaultSenderForTarget(target), senderChoices, "sender");
|
|
1819
|
+
const sdk = choice(readString(parsed, "sdk") ??
|
|
1820
|
+
options.defaults?.sdk ??
|
|
1821
|
+
defaultSdkForChoices(target, sender), sdkChoices, "sdk");
|
|
1822
|
+
const preset = choice(readString(parsed, "preset") ?? options.defaults?.preset ?? "signature", presetChoices, "preset");
|
|
1823
|
+
const packageManager = choice(readString(parsed, "package-manager") ??
|
|
1824
|
+
options.defaults?.packageManager ??
|
|
1825
|
+
"npm", packageManagerChoices, "package-manager");
|
|
1826
|
+
const apiBaseUrl = readString(parsed, "api-base-url") ??
|
|
1827
|
+
options.defaults?.apiBaseUrl ??
|
|
1828
|
+
"https://<deployment>.convex.site";
|
|
1829
|
+
validatePlan({ target, sender, sdk });
|
|
1830
|
+
return {
|
|
1831
|
+
appName,
|
|
1832
|
+
slug: slugify(appName),
|
|
1833
|
+
target,
|
|
1834
|
+
sender,
|
|
1835
|
+
sdk,
|
|
1836
|
+
preset,
|
|
1837
|
+
apiBaseUrl,
|
|
1838
|
+
packageManager,
|
|
1839
|
+
};
|
|
1840
|
+
}
|
|
1841
|
+
function defaultSenderForTarget(target) {
|
|
1842
|
+
if (target === "ios")
|
|
1843
|
+
return "own-ios";
|
|
1844
|
+
if (target === "headless")
|
|
1845
|
+
return "headless";
|
|
1846
|
+
return "remotedraw-ios";
|
|
1847
|
+
}
|
|
1848
|
+
function defaultSdkForChoices(target, sender) {
|
|
1849
|
+
if (target === "ios" || sender === "own-ios")
|
|
1850
|
+
return "swift";
|
|
1851
|
+
if (target === "headless" || sender === "headless")
|
|
1852
|
+
return "js";
|
|
1853
|
+
return "react";
|
|
1854
|
+
}
|
|
1855
|
+
function validatePlan(plan) {
|
|
1856
|
+
if (plan.sender === "own-ios" && plan.sdk !== "swift") {
|
|
1857
|
+
throw new Error("--sender own-ios requires --sdk swift.");
|
|
1858
|
+
}
|
|
1859
|
+
if (plan.sdk === "swift" && plan.sender !== "own-ios") {
|
|
1860
|
+
throw new Error("--sdk swift is only supported with --sender own-ios.");
|
|
1861
|
+
}
|
|
1862
|
+
if (plan.target === "ios" && plan.sender !== "own-ios") {
|
|
1863
|
+
throw new Error("--target ios requires --sender own-ios.");
|
|
1864
|
+
}
|
|
1865
|
+
if (plan.sender === "headless" && plan.sdk === "react") {
|
|
1866
|
+
throw new Error("--sender headless requires --sdk js or --sdk headless.");
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
function choice(value, choices, label) {
|
|
1870
|
+
if (choices.includes(value))
|
|
1871
|
+
return value;
|
|
1872
|
+
throw new Error(`Unknown ${label} "${value}". Expected one of: ${choices.join(", ")}.`);
|
|
1873
|
+
}
|
|
1874
|
+
function resolveOutputDir(cwd, parsed, fallback) {
|
|
1875
|
+
const requestedPath = readString(parsed, "path") ?? parsed.positionals[0] ?? fallback ?? ".";
|
|
1876
|
+
return path.resolve(cwd, requestedPath);
|
|
1877
|
+
}
|
|
1878
|
+
async function hasPackageJson(dir, runtime) {
|
|
1879
|
+
return await runtime.exists(path.join(dir, "package.json"));
|
|
1880
|
+
}
|
|
1881
|
+
function newProjectFiles(plan) {
|
|
1882
|
+
const files = initProjectFiles(plan, false);
|
|
1883
|
+
files.set("README.md", generatedReadme(plan));
|
|
1884
|
+
files.set("package.json", JSON.stringify(newPackageJson(plan), null, 2) + "\n");
|
|
1885
|
+
files.set(".gitignore", "node_modules\ndist\n.env\n.env.local\n");
|
|
1886
|
+
if (plan.sdk === "react") {
|
|
1887
|
+
files.set("index.html", reactIndexHtml(plan));
|
|
1888
|
+
files.set("src/main.tsx", reactMainTsx());
|
|
1889
|
+
files.set("src/App.tsx", reactAppTsx(plan));
|
|
1890
|
+
files.set("src/styles.css", reactStylesCss());
|
|
1891
|
+
files.set("tsconfig.json", reactTsconfig());
|
|
1892
|
+
files.set("vite.config.ts", reactViteConfig());
|
|
1893
|
+
}
|
|
1894
|
+
return files;
|
|
1895
|
+
}
|
|
1896
|
+
function initProjectFiles(plan, packageJsonAlreadyExists) {
|
|
1897
|
+
const files = new Map();
|
|
1898
|
+
files.set("remotedraw.config.json", JSON.stringify(configJson(plan), null, 2) + "\n");
|
|
1899
|
+
files.set(".env.example", envExample(plan));
|
|
1900
|
+
files.set("src/remotedraw/README.md", integrationReadme(plan));
|
|
1901
|
+
if (!packageJsonAlreadyExists) {
|
|
1902
|
+
files.set("package.json", JSON.stringify(initPackageJson(plan), null, 2) + "\n");
|
|
1903
|
+
}
|
|
1904
|
+
if (plan.sdk === "react") {
|
|
1905
|
+
files.set("src/remotedraw/createRemoteDrawSession.ts", reactBackendSessionTs(plan));
|
|
1906
|
+
files.set("src/remotedraw/RemoteDrawReceiver.tsx", reactReceiverTsx());
|
|
1907
|
+
if (plan.sender === "embedded-web") {
|
|
1908
|
+
files.set("src/remotedraw/RemoteDrawEmbeddedSender.tsx", reactEmbeddedSenderTsx());
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
else if (plan.sdk === "svelte") {
|
|
1912
|
+
files.set("src/remotedraw/createRemoteDrawSession.ts", svelteBackendSessionTs(plan));
|
|
1913
|
+
files.set("src/remotedraw/RemoteDrawReceiver.svelte", svelteReceiverSvelte());
|
|
1914
|
+
if (plan.sender !== "remotedraw-ios") {
|
|
1915
|
+
files.set("src/remotedraw/senderFlow.ts", rawHttpSenderTs());
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
else if (plan.sdk === "swift") {
|
|
1919
|
+
files.set("src/remotedraw/RemoteDrawIntegration.swift", swiftIntegration(plan));
|
|
1920
|
+
}
|
|
1921
|
+
else {
|
|
1922
|
+
files.set("src/remotedraw/createRemoteDrawSession.ts", rawHttpSessionTs(plan));
|
|
1923
|
+
files.set("src/remotedraw/receiverPolling.ts", rawHttpReceiverTs());
|
|
1924
|
+
if (plan.sender !== "remotedraw-ios") {
|
|
1925
|
+
files.set("src/remotedraw/senderFlow.ts", rawHttpSenderTs());
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
return files;
|
|
1929
|
+
}
|
|
1930
|
+
async function writeProjectFiles(files, outputDir, runtime, options) {
|
|
1931
|
+
const written = [];
|
|
1932
|
+
await assertProjectFilesWritable(files, outputDir, runtime, options.dryRun, options.force);
|
|
1933
|
+
for (const [relativePath, contents] of files) {
|
|
1934
|
+
const targetPath = path.join(outputDir, relativePath);
|
|
1935
|
+
written.push(relativePath);
|
|
1936
|
+
if (options.dryRun)
|
|
1937
|
+
continue;
|
|
1938
|
+
await runtime.mkdir(path.dirname(targetPath));
|
|
1939
|
+
await runtime.writeFile(targetPath, contents);
|
|
1940
|
+
}
|
|
1941
|
+
return written;
|
|
1942
|
+
}
|
|
1943
|
+
async function assertProjectFilesWritable(files, outputDir, runtime, dryRun, force) {
|
|
1944
|
+
if (dryRun || force)
|
|
1945
|
+
return;
|
|
1946
|
+
for (const relativePath of files.keys()) {
|
|
1947
|
+
if (await runtime.exists(path.join(outputDir, relativePath))) {
|
|
1948
|
+
throw new Error(`Refusing to overwrite ${relativePath}. Pass --force to replace generated files.`);
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
async function updatePackageJson(outputDir, plan, runtime) {
|
|
1953
|
+
const packagePath = path.join(outputDir, "package.json");
|
|
1954
|
+
if (!(await runtime.exists(packagePath)))
|
|
1955
|
+
return;
|
|
1956
|
+
const packageJson = await readJsonFile(packagePath, runtime);
|
|
1957
|
+
if (!isRecord(packageJson))
|
|
1958
|
+
return;
|
|
1959
|
+
const dependencies = ensureRecord(packageJson, "dependencies");
|
|
1960
|
+
for (const [name, version] of Object.entries(dependenciesForPlan(plan))) {
|
|
1961
|
+
if (dependencies[name] == null)
|
|
1962
|
+
dependencies[name] = version;
|
|
1963
|
+
}
|
|
1964
|
+
await runtime.writeFile(packagePath, JSON.stringify(packageJson, null, 2) + "\n");
|
|
1965
|
+
}
|
|
1966
|
+
function ensureRecord(record, key) {
|
|
1967
|
+
const value = record[key];
|
|
1968
|
+
if (isRecord(value))
|
|
1969
|
+
return value;
|
|
1970
|
+
const next = {};
|
|
1971
|
+
record[key] = next;
|
|
1972
|
+
return next;
|
|
1973
|
+
}
|
|
1974
|
+
async function readJsonFile(filePath, runtime) {
|
|
1975
|
+
return JSON.parse(await runtime.readFile(filePath));
|
|
1976
|
+
}
|
|
1977
|
+
async function environmentForProject(dir, runtime) {
|
|
1978
|
+
const fromFiles = {};
|
|
1979
|
+
for (const fileName of [".env", ".env.local"]) {
|
|
1980
|
+
const filePath = path.join(dir, fileName);
|
|
1981
|
+
if (!(await runtime.exists(filePath)))
|
|
1982
|
+
continue;
|
|
1983
|
+
Object.assign(fromFiles, parseEnvFile(await runtime.readFile(filePath)));
|
|
1984
|
+
}
|
|
1985
|
+
return { ...fromFiles, ...runtime.env };
|
|
1986
|
+
}
|
|
1987
|
+
function parseEnvFile(contents) {
|
|
1988
|
+
const values = {};
|
|
1989
|
+
for (const line of contents.split(/\r?\n/)) {
|
|
1990
|
+
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
|
|
1991
|
+
if (!match?.[1] || match[2] == null)
|
|
1992
|
+
continue;
|
|
1993
|
+
const raw = match[2].trim();
|
|
1994
|
+
const quote = raw[0];
|
|
1995
|
+
values[match[1]] =
|
|
1996
|
+
(quote === '"' || quote === "'") && raw.endsWith(quote)
|
|
1997
|
+
? raw.slice(1, -1)
|
|
1998
|
+
: raw.replace(/\s+#.*$/, "");
|
|
1999
|
+
}
|
|
2000
|
+
return values;
|
|
2001
|
+
}
|
|
2002
|
+
function isRecord(value) {
|
|
2003
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2004
|
+
}
|
|
2005
|
+
function stringFromRecord(value, key) {
|
|
2006
|
+
if (!isRecord(value))
|
|
2007
|
+
return undefined;
|
|
2008
|
+
const child = value[key];
|
|
2009
|
+
return typeof child === "string" ? child : undefined;
|
|
2010
|
+
}
|
|
2011
|
+
function hasDependency(packageJson, dependencyName) {
|
|
2012
|
+
if (!isRecord(packageJson))
|
|
2013
|
+
return false;
|
|
2014
|
+
return (isRecord(packageJson.dependencies) &&
|
|
2015
|
+
typeof packageJson.dependencies[dependencyName] === "string");
|
|
2016
|
+
}
|
|
2017
|
+
function dependenciesForPlan(plan) {
|
|
2018
|
+
if (plan.sdk === "react") {
|
|
2019
|
+
return {
|
|
2020
|
+
"@remotedraw/react": "latest",
|
|
2021
|
+
react: "^19.0.0",
|
|
2022
|
+
"react-dom": "^19.0.0",
|
|
2023
|
+
};
|
|
2024
|
+
}
|
|
2025
|
+
if (plan.sdk === "svelte") {
|
|
2026
|
+
return {
|
|
2027
|
+
"@remotedraw/svelte": "latest",
|
|
2028
|
+
};
|
|
2029
|
+
}
|
|
2030
|
+
if (plan.sdk === "js") {
|
|
2031
|
+
return {
|
|
2032
|
+
"@remotedraw/protocol": "latest",
|
|
2033
|
+
};
|
|
2034
|
+
}
|
|
2035
|
+
return {};
|
|
2036
|
+
}
|
|
2037
|
+
function newPackageJson(plan) {
|
|
2038
|
+
const dependencies = dependenciesForPlan(plan);
|
|
2039
|
+
if (plan.sdk === "react") {
|
|
2040
|
+
return {
|
|
2041
|
+
name: plan.slug,
|
|
2042
|
+
private: true,
|
|
2043
|
+
type: "module",
|
|
2044
|
+
scripts: {
|
|
2045
|
+
dev: "vite",
|
|
2046
|
+
build: "tsc --noEmit && vite build",
|
|
2047
|
+
preview: "vite preview",
|
|
2048
|
+
},
|
|
2049
|
+
dependencies,
|
|
2050
|
+
devDependencies: {
|
|
2051
|
+
"@types/react": "^19.0.0",
|
|
2052
|
+
"@types/react-dom": "^19.0.0",
|
|
2053
|
+
"@vitejs/plugin-react": "latest",
|
|
2054
|
+
typescript: "^6.0.0",
|
|
2055
|
+
vite: "latest",
|
|
2056
|
+
},
|
|
2057
|
+
};
|
|
2058
|
+
}
|
|
2059
|
+
return initPackageJson(plan);
|
|
2060
|
+
}
|
|
2061
|
+
function initPackageJson(plan) {
|
|
2062
|
+
return {
|
|
2063
|
+
name: plan.slug,
|
|
2064
|
+
private: true,
|
|
2065
|
+
type: "module",
|
|
2066
|
+
scripts: {
|
|
2067
|
+
dev: "remotedraw dev",
|
|
2068
|
+
"remotedraw:doctor": "remotedraw doctor",
|
|
2069
|
+
"remotedraw:create-input": "remotedraw create-input --preset " + plan.preset,
|
|
2070
|
+
},
|
|
2071
|
+
dependencies: dependenciesForPlan(plan),
|
|
2072
|
+
devDependencies: {
|
|
2073
|
+
typescript: "^6.0.0",
|
|
2074
|
+
},
|
|
2075
|
+
};
|
|
2076
|
+
}
|
|
2077
|
+
function configJson(plan) {
|
|
2078
|
+
return {
|
|
2079
|
+
$schema: "https://remotedraw.app/schemas/remotedraw.config.json",
|
|
2080
|
+
appName: plan.appName,
|
|
2081
|
+
environment: "dev",
|
|
2082
|
+
target: plan.target,
|
|
2083
|
+
sender: plan.sender,
|
|
2084
|
+
sdk: plan.sdk,
|
|
2085
|
+
preset: plan.preset,
|
|
2086
|
+
apiBaseUrl: plan.apiBaseUrl,
|
|
2087
|
+
};
|
|
2088
|
+
}
|
|
2089
|
+
function envExample(plan) {
|
|
2090
|
+
return [
|
|
2091
|
+
"# RemoteDraw API origin. Use your Convex site URL in production.",
|
|
2092
|
+
`REMOTEDRAW_API_BASE_URL=${plan.apiBaseUrl}`,
|
|
2093
|
+
"",
|
|
2094
|
+
"# Non-secret dashboard project identifier. remotedraw new/init fills this in.",
|
|
2095
|
+
"REMOTEDRAW_PROJECT_ID=replace_me",
|
|
2096
|
+
"",
|
|
2097
|
+
"# Server-side only. Never expose rd_sk_... keys to browsers or mobile clients.",
|
|
2098
|
+
"REMOTEDRAW_API_KEY=rd_sk_replace_me",
|
|
2099
|
+
"",
|
|
2100
|
+
].join("\n");
|
|
2101
|
+
}
|
|
2102
|
+
function generatedReadme(plan) {
|
|
2103
|
+
return [
|
|
2104
|
+
`# ${plan.appName}`,
|
|
2105
|
+
"",
|
|
2106
|
+
"RemoteDraw starter generated by the RemoteDraw CLI.",
|
|
2107
|
+
"",
|
|
2108
|
+
"## Next Steps",
|
|
2109
|
+
"",
|
|
2110
|
+
`1. Install packages with ${installCommand(plan.packageManager)}.`,
|
|
2111
|
+
"2. Use the generated `.env.local`, or copy `.env.example` when setup was run with `--offline`.",
|
|
2112
|
+
"3. Create RemoteDraw sessions from trusted backend code only.",
|
|
2113
|
+
`4. Run ${devCommandForPackageManager(plan.packageManager)}.`,
|
|
2114
|
+
plan.sender === "own-ios"
|
|
2115
|
+
? "5. Choose QR mode with the returned HTTPS `joinUrl`, or direct mode with `/v1/sessions/direct-sender` from backend code."
|
|
2116
|
+
: "5. Render the returned HTTPS `joinUrl` in the receiver UI.",
|
|
2117
|
+
"",
|
|
2118
|
+
"See `src/remotedraw/README.md` for integration-specific notes.",
|
|
2119
|
+
].join("\n");
|
|
2120
|
+
}
|
|
2121
|
+
function integrationReadme(plan) {
|
|
2122
|
+
return [
|
|
2123
|
+
"# RemoteDraw Integration",
|
|
2124
|
+
"",
|
|
2125
|
+
`App: ${plan.appName}`,
|
|
2126
|
+
`Target: ${plan.target}`,
|
|
2127
|
+
`Sender: ${plan.sender}`,
|
|
2128
|
+
`SDK: ${plan.sdk}`,
|
|
2129
|
+
`Starter: ${plan.preset}`,
|
|
2130
|
+
"",
|
|
2131
|
+
"## Flow",
|
|
2132
|
+
"",
|
|
2133
|
+
"1. Your backend calls `POST /v1/sessions` with an `rd_sk_...` API key.",
|
|
2134
|
+
"2. Your receiver renders the returned HTTPS `joinUrl` and stores only `receiverToken` client-side.",
|
|
2135
|
+
plan.sender === "remotedraw-ios"
|
|
2136
|
+
? "3. The RemoteDraw iOS app scans or opens the join URL and exchanges the one-time join token."
|
|
2137
|
+
: plan.sender === "own-ios"
|
|
2138
|
+
? "3. Your backend either sends a join URL to the app or creates a direct sender connection with `POST /v1/sessions/direct-sender`."
|
|
2139
|
+
: "3. Your sender exchanges the one-time join token and receives a scoped sender token.",
|
|
2140
|
+
"4. The sender streams draft input, commits durable strokes, and calls submit when done.",
|
|
2141
|
+
"",
|
|
2142
|
+
"API keys belong only in backend secrets. Join tokens, receiver tokens, and sender tokens are short-lived scoped credentials.",
|
|
2143
|
+
].join("\n");
|
|
2144
|
+
}
|
|
2145
|
+
function reactBackendSessionTs(plan) {
|
|
2146
|
+
return [
|
|
2147
|
+
'import { createHttpRemoteDrawApiClient } from "@remotedraw/react";',
|
|
2148
|
+
'import type { CreateSessionRequest } from "@remotedraw/react";',
|
|
2149
|
+
"",
|
|
2150
|
+
"const apiBaseUrl = process.env.REMOTEDRAW_API_BASE_URL;",
|
|
2151
|
+
"const apiKey = process.env.REMOTEDRAW_API_KEY;",
|
|
2152
|
+
"",
|
|
2153
|
+
"export function createRemoteDrawSessionRequest(): CreateSessionRequest {",
|
|
2154
|
+
" return " +
|
|
2155
|
+
JSON.stringify(createSessionPayload({
|
|
2156
|
+
preset: plan.preset,
|
|
2157
|
+
label: labelForPreset(plan.preset),
|
|
2158
|
+
externalId: `${plan.slug}-${plan.preset}`,
|
|
2159
|
+
}), null, 2).replace(/\n/g, "\n ") +
|
|
2160
|
+
";",
|
|
2161
|
+
"}",
|
|
2162
|
+
"",
|
|
2163
|
+
"export async function createRemoteDrawSession() {",
|
|
2164
|
+
" if (!apiBaseUrl || !apiKey) {",
|
|
2165
|
+
' throw new Error("Set REMOTEDRAW_API_BASE_URL and REMOTEDRAW_API_KEY in backend secrets.");',
|
|
2166
|
+
" }",
|
|
2167
|
+
"",
|
|
2168
|
+
" const client = createHttpRemoteDrawApiClient(apiBaseUrl, { apiKey });",
|
|
2169
|
+
" return await client.createSession(createRemoteDrawSessionRequest());",
|
|
2170
|
+
"}",
|
|
2171
|
+
"",
|
|
2172
|
+
].join("\n");
|
|
2173
|
+
}
|
|
2174
|
+
function reactReceiverTsx() {
|
|
2175
|
+
return [
|
|
2176
|
+
"import {",
|
|
2177
|
+
" RemoteDrawProvider,",
|
|
2178
|
+
" RemoteDrawReceiver,",
|
|
2179
|
+
" SubmissionStatus,",
|
|
2180
|
+
" createHttpReceiverClient,",
|
|
2181
|
+
'} from "@remotedraw/react";',
|
|
2182
|
+
'import type { CreateSessionResponse } from "@remotedraw/react";',
|
|
2183
|
+
"",
|
|
2184
|
+
"type RemoteDrawReceiverPanelProps = {",
|
|
2185
|
+
" apiBaseUrl: string;",
|
|
2186
|
+
" createdSession: CreateSessionResponse;",
|
|
2187
|
+
"};",
|
|
2188
|
+
"",
|
|
2189
|
+
"export function RemoteDrawReceiverPanel({",
|
|
2190
|
+
" apiBaseUrl,",
|
|
2191
|
+
" createdSession,",
|
|
2192
|
+
"}: RemoteDrawReceiverPanelProps) {",
|
|
2193
|
+
" const receiver = createHttpReceiverClient(apiBaseUrl);",
|
|
2194
|
+
"",
|
|
2195
|
+
" return (",
|
|
2196
|
+
" <RemoteDrawProvider",
|
|
2197
|
+
" session={createdSession.session}",
|
|
2198
|
+
" receiverToken={createdSession.receiverToken}",
|
|
2199
|
+
" joinUrl={createdSession.joinUrl}",
|
|
2200
|
+
" joinUrls={createdSession.joinUrls}",
|
|
2201
|
+
" receiver={receiver}",
|
|
2202
|
+
" >",
|
|
2203
|
+
' <RemoteDrawReceiver aria-label="RemoteDraw receiver surface" />',
|
|
2204
|
+
' <SubmissionStatus metadataKeys={["externalId", "senderLabel"]} />',
|
|
2205
|
+
" </RemoteDrawProvider>",
|
|
2206
|
+
" );",
|
|
2207
|
+
"}",
|
|
2208
|
+
"",
|
|
2209
|
+
].join("\n");
|
|
2210
|
+
}
|
|
2211
|
+
function reactEmbeddedSenderTsx() {
|
|
2212
|
+
return [
|
|
2213
|
+
'import { EmbeddedSender, createHttpSenderClient } from "@remotedraw/react";',
|
|
2214
|
+
"",
|
|
2215
|
+
"type RemoteDrawEmbeddedSenderPanelProps = {",
|
|
2216
|
+
" apiBaseUrl: string;",
|
|
2217
|
+
" joinTokenOrUrl: string;",
|
|
2218
|
+
"};",
|
|
2219
|
+
"",
|
|
2220
|
+
"export function RemoteDrawEmbeddedSenderPanel({",
|
|
2221
|
+
" apiBaseUrl,",
|
|
2222
|
+
" joinTokenOrUrl,",
|
|
2223
|
+
"}: RemoteDrawEmbeddedSenderPanelProps) {",
|
|
2224
|
+
" return (",
|
|
2225
|
+
" <EmbeddedSender",
|
|
2226
|
+
" client={createHttpSenderClient(apiBaseUrl)}",
|
|
2227
|
+
" initialJoinToken={joinTokenOrUrl}",
|
|
2228
|
+
" autoJoin",
|
|
2229
|
+
' device={{ platform: "web", displayName: "Embedded sender" }}',
|
|
2230
|
+
' submitMetadata={{ senderLabel: "Embedded sender" }}',
|
|
2231
|
+
' submitLabel="Done"',
|
|
2232
|
+
" />",
|
|
2233
|
+
" );",
|
|
2234
|
+
"}",
|
|
2235
|
+
"",
|
|
2236
|
+
].join("\n");
|
|
2237
|
+
}
|
|
2238
|
+
function svelteBackendSessionTs(plan) {
|
|
2239
|
+
return [
|
|
2240
|
+
'import { createHttpRemoteDrawApiClient } from "@remotedraw/svelte";',
|
|
2241
|
+
'import type { CreateSessionRequest } from "@remotedraw/svelte";',
|
|
2242
|
+
"",
|
|
2243
|
+
"const apiBaseUrl = process.env.REMOTEDRAW_API_BASE_URL;",
|
|
2244
|
+
"const apiKey = process.env.REMOTEDRAW_API_KEY;",
|
|
2245
|
+
"",
|
|
2246
|
+
"export function createRemoteDrawSessionRequest(): CreateSessionRequest {",
|
|
2247
|
+
" return " +
|
|
2248
|
+
JSON.stringify(createSessionPayload({
|
|
2249
|
+
preset: plan.preset,
|
|
2250
|
+
label: labelForPreset(plan.preset),
|
|
2251
|
+
externalId: `${plan.slug}-${plan.preset}`,
|
|
2252
|
+
}), null, 2).replace(/\n/g, "\n ") +
|
|
2253
|
+
";",
|
|
2254
|
+
"}",
|
|
2255
|
+
"",
|
|
2256
|
+
"export async function createRemoteDrawSession() {",
|
|
2257
|
+
" if (!apiBaseUrl || !apiKey) {",
|
|
2258
|
+
' throw new Error("Set REMOTEDRAW_API_BASE_URL and REMOTEDRAW_API_KEY in backend secrets.");',
|
|
2259
|
+
" }",
|
|
2260
|
+
"",
|
|
2261
|
+
" const client = createHttpRemoteDrawApiClient(apiBaseUrl, { apiKey });",
|
|
2262
|
+
" return await client.createSession(createRemoteDrawSessionRequest());",
|
|
2263
|
+
"}",
|
|
2264
|
+
"",
|
|
2265
|
+
].join("\n");
|
|
2266
|
+
}
|
|
2267
|
+
function svelteReceiverSvelte() {
|
|
2268
|
+
return [
|
|
2269
|
+
'<script lang="ts">',
|
|
2270
|
+
" import {",
|
|
2271
|
+
" createHttpReceiverClient,",
|
|
2272
|
+
" createRemoteDrawReceiver,",
|
|
2273
|
+
" pointsToPath,",
|
|
2274
|
+
" SURFACE_SIZE,",
|
|
2275
|
+
' } from "@remotedraw/svelte";',
|
|
2276
|
+
' import type { CreateSessionResponse } from "@remotedraw/svelte";',
|
|
2277
|
+
' import { onDestroy } from "svelte";',
|
|
2278
|
+
"",
|
|
2279
|
+
" export let apiBaseUrl: string;",
|
|
2280
|
+
" export let createdSession: CreateSessionResponse;",
|
|
2281
|
+
"",
|
|
2282
|
+
" const receiver = createRemoteDrawReceiver({",
|
|
2283
|
+
" receiver: createHttpReceiverClient(apiBaseUrl),",
|
|
2284
|
+
" session: {",
|
|
2285
|
+
" ...createdSession.session,",
|
|
2286
|
+
" receiverToken: createdSession.receiverToken,",
|
|
2287
|
+
" joinUrl: createdSession.joinUrl,",
|
|
2288
|
+
" joinUrls: createdSession.joinUrls,",
|
|
2289
|
+
" },",
|
|
2290
|
+
" });",
|
|
2291
|
+
" receiver.start();",
|
|
2292
|
+
" onDestroy(() => receiver.stop());",
|
|
2293
|
+
"</script>",
|
|
2294
|
+
"",
|
|
2295
|
+
"{#if $receiver.joinUrl}",
|
|
2296
|
+
" <p>Scan to pair: <a href={$receiver.joinUrl}>{$receiver.joinUrl}</a></p>",
|
|
2297
|
+
"{/if}",
|
|
2298
|
+
"",
|
|
2299
|
+
'<svg viewBox={`0 0 ${SURFACE_SIZE} ${SURFACE_SIZE}`} role="img" aria-label="RemoteDraw receiver surface">',
|
|
2300
|
+
" {#each $receiver.drawings as drawing (drawing.id)}",
|
|
2301
|
+
' <path d={pointsToPath(drawing.points)} fill="none" stroke="#151512" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" />',
|
|
2302
|
+
" {/each}",
|
|
2303
|
+
" {#each $receiver.drafts as draft (draft.id)}",
|
|
2304
|
+
' <path d={pointsToPath(draft.points)} fill="none" stroke="#1f7a8c" stroke-width="6" stroke-dasharray="16 14" opacity="0.6" stroke-linecap="round" stroke-linejoin="round" />',
|
|
2305
|
+
" {/each}",
|
|
2306
|
+
"</svg>",
|
|
2307
|
+
"",
|
|
2308
|
+
].join("\n");
|
|
2309
|
+
}
|
|
2310
|
+
function rawHttpSessionTs(plan) {
|
|
2311
|
+
return [
|
|
2312
|
+
"const apiBaseUrl = process.env.REMOTEDRAW_API_BASE_URL;",
|
|
2313
|
+
"const apiKey = process.env.REMOTEDRAW_API_KEY;",
|
|
2314
|
+
"",
|
|
2315
|
+
"export function createRemoteDrawSessionRequest() {",
|
|
2316
|
+
" return " +
|
|
2317
|
+
JSON.stringify(createSessionPayload({
|
|
2318
|
+
preset: plan.preset,
|
|
2319
|
+
label: labelForPreset(plan.preset),
|
|
2320
|
+
externalId: `${plan.slug}-${plan.preset}`,
|
|
2321
|
+
}), null, 2).replace(/\n/g, "\n ") +
|
|
2322
|
+
";",
|
|
2323
|
+
"}",
|
|
2324
|
+
"",
|
|
2325
|
+
"export async function createRemoteDrawSession() {",
|
|
2326
|
+
" if (!apiBaseUrl || !apiKey) {",
|
|
2327
|
+
' throw new Error("Set REMOTEDRAW_API_BASE_URL and REMOTEDRAW_API_KEY in backend secrets.");',
|
|
2328
|
+
" }",
|
|
2329
|
+
"",
|
|
2330
|
+
" const response = await fetch(`${apiBaseUrl}/v1/sessions`, {",
|
|
2331
|
+
' method: "POST",',
|
|
2332
|
+
" headers: {",
|
|
2333
|
+
" Authorization: `Bearer ${apiKey}`,",
|
|
2334
|
+
' "Content-Type": "application/json",',
|
|
2335
|
+
" },",
|
|
2336
|
+
" body: JSON.stringify(createRemoteDrawSessionRequest()),",
|
|
2337
|
+
" });",
|
|
2338
|
+
" if (!response.ok) throw new Error(await response.text());",
|
|
2339
|
+
" return await response.json();",
|
|
2340
|
+
"}",
|
|
2341
|
+
"",
|
|
2342
|
+
].join("\n");
|
|
2343
|
+
}
|
|
2344
|
+
function rawHttpReceiverTs() {
|
|
2345
|
+
return [
|
|
2346
|
+
"type CreatedRemoteDrawSession = {",
|
|
2347
|
+
" session: { id: string };",
|
|
2348
|
+
" receiverToken: string;",
|
|
2349
|
+
"};",
|
|
2350
|
+
"",
|
|
2351
|
+
"export async function readRemoteDrawReceiverState(",
|
|
2352
|
+
" apiBaseUrl: string,",
|
|
2353
|
+
" createdSession: CreatedRemoteDrawSession,",
|
|
2354
|
+
") {",
|
|
2355
|
+
" const body = {",
|
|
2356
|
+
" sessionId: createdSession.session.id,",
|
|
2357
|
+
" receiverToken: createdSession.receiverToken,",
|
|
2358
|
+
" };",
|
|
2359
|
+
"",
|
|
2360
|
+
" const post = async (path: string) => {",
|
|
2361
|
+
" const response = await fetch(`${apiBaseUrl}${path}`, {",
|
|
2362
|
+
' method: "POST",',
|
|
2363
|
+
' headers: { "Content-Type": "application/json" },',
|
|
2364
|
+
" body: JSON.stringify(body),",
|
|
2365
|
+
" });",
|
|
2366
|
+
" if (!response.ok) throw new Error(await response.text());",
|
|
2367
|
+
" return await response.json();",
|
|
2368
|
+
" };",
|
|
2369
|
+
"",
|
|
2370
|
+
" const [session, drawings, drafts, senders] = await Promise.all([",
|
|
2371
|
+
' post("/v1/receiver/session"),',
|
|
2372
|
+
' post("/v1/receiver/drawings"),',
|
|
2373
|
+
' post("/v1/receiver/drafts"),',
|
|
2374
|
+
' post("/v1/receiver/senders"),',
|
|
2375
|
+
" ]);",
|
|
2376
|
+
"",
|
|
2377
|
+
" return { session, drawings, drafts, senders };",
|
|
2378
|
+
"}",
|
|
2379
|
+
"",
|
|
2380
|
+
].join("\n");
|
|
2381
|
+
}
|
|
2382
|
+
function rawHttpSenderTs() {
|
|
2383
|
+
return [
|
|
2384
|
+
"export async function joinRemoteDrawSender(apiBaseUrl: string, joinToken: string) {",
|
|
2385
|
+
" const response = await fetch(`${apiBaseUrl}/v1/join`, {",
|
|
2386
|
+
' method: "POST",',
|
|
2387
|
+
' headers: { "Content-Type": "application/json" },',
|
|
2388
|
+
" body: JSON.stringify({",
|
|
2389
|
+
" joinToken,",
|
|
2390
|
+
' device: { platform: "web", displayName: "Owned sender" },',
|
|
2391
|
+
" }),",
|
|
2392
|
+
" });",
|
|
2393
|
+
" if (!response.ok) throw new Error(await response.text());",
|
|
2394
|
+
" return await response.json();",
|
|
2395
|
+
"}",
|
|
2396
|
+
"",
|
|
2397
|
+
"export async function commitRemoteDrawStroke(apiBaseUrl: string, senderToken: string) {",
|
|
2398
|
+
" const response = await fetch(`${apiBaseUrl}/v1/sender/commit`, {",
|
|
2399
|
+
' method: "POST",',
|
|
2400
|
+
' headers: { "Content-Type": "application/json" },',
|
|
2401
|
+
" body: JSON.stringify({",
|
|
2402
|
+
" senderToken,",
|
|
2403
|
+
" clientStrokeId: crypto.randomUUID(),",
|
|
2404
|
+
" sequence: 1,",
|
|
2405
|
+
' pointerType: "touch",',
|
|
2406
|
+
' tool: "freehand",',
|
|
2407
|
+
" points: [",
|
|
2408
|
+
" { x: 0.2, y: 0.25, t: 0 },",
|
|
2409
|
+
" { x: 0.5, y: 0.5, t: 24 },",
|
|
2410
|
+
" { x: 0.78, y: 0.62, t: 48 },",
|
|
2411
|
+
" ],",
|
|
2412
|
+
" occurredAt: Date.now(),",
|
|
2413
|
+
" }),",
|
|
2414
|
+
" });",
|
|
2415
|
+
" if (!response.ok) throw new Error(await response.text());",
|
|
2416
|
+
" return await response.json();",
|
|
2417
|
+
"}",
|
|
2418
|
+
"",
|
|
2419
|
+
].join("\n");
|
|
2420
|
+
}
|
|
2421
|
+
function swiftIntegration(plan) {
|
|
2422
|
+
return [
|
|
2423
|
+
"import Foundation",
|
|
2424
|
+
"",
|
|
2425
|
+
"struct RemoteDrawJoinLink {",
|
|
2426
|
+
" let token: String",
|
|
2427
|
+
" let webURL: URL?",
|
|
2428
|
+
"}",
|
|
2429
|
+
"",
|
|
2430
|
+
"enum RemoteDrawJoinLinkParser {",
|
|
2431
|
+
" static func parse(_ input: String) -> RemoteDrawJoinLink? {",
|
|
2432
|
+
" let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)",
|
|
2433
|
+
' if trimmed.hasPrefix("rd_join_") {',
|
|
2434
|
+
" return RemoteDrawJoinLink(token: trimmed, webURL: nil)",
|
|
2435
|
+
" }",
|
|
2436
|
+
" guard let components = URLComponents(string: trimmed) else { return nil }",
|
|
2437
|
+
' let token = components.queryItems?.first(where: { $0.name == "token" })?.value',
|
|
2438
|
+
' guard let token, token.hasPrefix("rd_join_") else { return nil }',
|
|
2439
|
+
" return RemoteDrawJoinLink(token: token, webURL: components.url)",
|
|
2440
|
+
" }",
|
|
2441
|
+
"}",
|
|
2442
|
+
"",
|
|
2443
|
+
"struct RemoteDrawJoinRequest: Encodable {",
|
|
2444
|
+
" let joinToken: String",
|
|
2445
|
+
" let device: Device",
|
|
2446
|
+
"",
|
|
2447
|
+
" struct Device: Encodable {",
|
|
2448
|
+
' let platform = "ios"',
|
|
2449
|
+
` let displayName = "${plan.appName}"`,
|
|
2450
|
+
" }",
|
|
2451
|
+
"}",
|
|
2452
|
+
"",
|
|
2453
|
+
].join("\n");
|
|
2454
|
+
}
|
|
2455
|
+
function reactIndexHtml(plan) {
|
|
2456
|
+
return [
|
|
2457
|
+
"<!doctype html>",
|
|
2458
|
+
'<html lang="en">',
|
|
2459
|
+
" <head>",
|
|
2460
|
+
' <meta charset="UTF-8" />',
|
|
2461
|
+
' <meta name="viewport" content="width=device-width, initial-scale=1.0" />',
|
|
2462
|
+
` <title>${escapeHtml(plan.appName)}</title>`,
|
|
2463
|
+
" </head>",
|
|
2464
|
+
" <body>",
|
|
2465
|
+
' <div id="root"></div>',
|
|
2466
|
+
' <script type="module" src="/src/main.tsx"></script>',
|
|
2467
|
+
" </body>",
|
|
2468
|
+
"</html>",
|
|
2469
|
+
"",
|
|
2470
|
+
].join("\n");
|
|
2471
|
+
}
|
|
2472
|
+
function reactMainTsx() {
|
|
2473
|
+
return [
|
|
2474
|
+
'import React from "react";',
|
|
2475
|
+
'import { createRoot } from "react-dom/client";',
|
|
2476
|
+
'import { App } from "./App";',
|
|
2477
|
+
'import "./styles.css";',
|
|
2478
|
+
"",
|
|
2479
|
+
'createRoot(document.getElementById("root")!).render(',
|
|
2480
|
+
" <React.StrictMode>",
|
|
2481
|
+
" <App />",
|
|
2482
|
+
" </React.StrictMode>,",
|
|
2483
|
+
");",
|
|
2484
|
+
"",
|
|
2485
|
+
].join("\n");
|
|
2486
|
+
}
|
|
2487
|
+
function reactAppTsx(plan) {
|
|
2488
|
+
return [
|
|
2489
|
+
"export function App() {",
|
|
2490
|
+
" return (",
|
|
2491
|
+
' <main className="shell">',
|
|
2492
|
+
' <section className="intro">',
|
|
2493
|
+
` <h1>${escapeText(plan.appName)}</h1>`,
|
|
2494
|
+
" <p>",
|
|
2495
|
+
" Create a RemoteDraw session from your backend, pass the returned",
|
|
2496
|
+
" session object to <code>RemoteDrawReceiverPanel</code>, and show the",
|
|
2497
|
+
" HTTPS join URL to the phone sender.",
|
|
2498
|
+
" </p>",
|
|
2499
|
+
" </section>",
|
|
2500
|
+
" </main>",
|
|
2501
|
+
" );",
|
|
2502
|
+
"}",
|
|
2503
|
+
"",
|
|
2504
|
+
].join("\n");
|
|
2505
|
+
}
|
|
2506
|
+
function reactStylesCss() {
|
|
2507
|
+
return [
|
|
2508
|
+
":root {",
|
|
2509
|
+
" color: #1f2937;",
|
|
2510
|
+
" background: #f7f7f4;",
|
|
2511
|
+
' font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;',
|
|
2512
|
+
"}",
|
|
2513
|
+
"",
|
|
2514
|
+
"body {",
|
|
2515
|
+
" margin: 0;",
|
|
2516
|
+
"}",
|
|
2517
|
+
"",
|
|
2518
|
+
".shell {",
|
|
2519
|
+
" min-height: 100vh;",
|
|
2520
|
+
" display: grid;",
|
|
2521
|
+
" place-items: center;",
|
|
2522
|
+
" padding: 32px;",
|
|
2523
|
+
"}",
|
|
2524
|
+
"",
|
|
2525
|
+
".intro {",
|
|
2526
|
+
" max-width: 680px;",
|
|
2527
|
+
"}",
|
|
2528
|
+
"",
|
|
2529
|
+
".intro h1 {",
|
|
2530
|
+
" margin: 0 0 16px;",
|
|
2531
|
+
" font-size: 40px;",
|
|
2532
|
+
"}",
|
|
2533
|
+
"",
|
|
2534
|
+
".intro p {",
|
|
2535
|
+
" margin: 0;",
|
|
2536
|
+
" color: #4b5563;",
|
|
2537
|
+
" line-height: 1.6;",
|
|
2538
|
+
"}",
|
|
2539
|
+
"",
|
|
2540
|
+
].join("\n");
|
|
2541
|
+
}
|
|
2542
|
+
function reactTsconfig() {
|
|
2543
|
+
return [
|
|
2544
|
+
"{",
|
|
2545
|
+
' "compilerOptions": {',
|
|
2546
|
+
' "target": "ES2022",',
|
|
2547
|
+
' "useDefineForClassFields": true,',
|
|
2548
|
+
' "lib": ["ES2022", "DOM", "DOM.Iterable"],',
|
|
2549
|
+
' "allowJs": false,',
|
|
2550
|
+
' "skipLibCheck": true,',
|
|
2551
|
+
' "esModuleInterop": true,',
|
|
2552
|
+
' "allowSyntheticDefaultImports": true,',
|
|
2553
|
+
' "strict": true,',
|
|
2554
|
+
' "module": "ESNext",',
|
|
2555
|
+
' "moduleResolution": "Bundler",',
|
|
2556
|
+
' "resolveJsonModule": true,',
|
|
2557
|
+
' "isolatedModules": true,',
|
|
2558
|
+
' "noEmit": true,',
|
|
2559
|
+
' "jsx": "react-jsx"',
|
|
2560
|
+
" },",
|
|
2561
|
+
' "include": ["src"]',
|
|
2562
|
+
"}",
|
|
2563
|
+
"",
|
|
2564
|
+
].join("\n");
|
|
2565
|
+
}
|
|
2566
|
+
function reactViteConfig() {
|
|
2567
|
+
return [
|
|
2568
|
+
'import react from "@vitejs/plugin-react";',
|
|
2569
|
+
'import { defineConfig } from "vite";',
|
|
2570
|
+
"",
|
|
2571
|
+
"export default defineConfig({",
|
|
2572
|
+
" plugins: [react()],",
|
|
2573
|
+
"});",
|
|
2574
|
+
"",
|
|
2575
|
+
].join("\n");
|
|
2576
|
+
}
|
|
2577
|
+
export function createSessionPayload(options) {
|
|
2578
|
+
const descriptor = descriptorForPreset(options.preset, options.label);
|
|
2579
|
+
return {
|
|
2580
|
+
externalId: options.externalId,
|
|
2581
|
+
markupPreset: options.preset,
|
|
2582
|
+
target: {
|
|
2583
|
+
kind: targetKindForPreset(options.preset),
|
|
2584
|
+
label: options.label,
|
|
2585
|
+
...(options.preset === "signature"
|
|
2586
|
+
? {
|
|
2587
|
+
inputMapping: "surface",
|
|
2588
|
+
coordinateSpace: { width: 1600, height: 500 },
|
|
2589
|
+
}
|
|
2590
|
+
: options.preset === "initials"
|
|
2591
|
+
? {
|
|
2592
|
+
inputMapping: "surface",
|
|
2593
|
+
coordinateSpace: { width: 800, height: 500 },
|
|
2594
|
+
}
|
|
2595
|
+
: {}),
|
|
2596
|
+
metadata: {
|
|
2597
|
+
descriptor,
|
|
2598
|
+
},
|
|
2599
|
+
},
|
|
2600
|
+
...(options.expiresInMs == null
|
|
2601
|
+
? {}
|
|
2602
|
+
: { expiresInMs: options.expiresInMs }),
|
|
2603
|
+
};
|
|
2604
|
+
}
|
|
2605
|
+
function descriptorForPreset(preset, label) {
|
|
2606
|
+
if (preset === "signature" || preset === "initials") {
|
|
2607
|
+
return {
|
|
2608
|
+
version: 1,
|
|
2609
|
+
privacy: "metadata-only",
|
|
2610
|
+
surfaceRole: "field",
|
|
2611
|
+
inputIntent: preset,
|
|
2612
|
+
label,
|
|
2613
|
+
components: [
|
|
2614
|
+
{
|
|
2615
|
+
id: preset,
|
|
2616
|
+
role: "field",
|
|
2617
|
+
inputIntent: preset,
|
|
2618
|
+
required: true,
|
|
2619
|
+
bounds: { minX: 0.1, minY: 0.68, maxX: 0.9, maxY: 0.86 },
|
|
2620
|
+
},
|
|
2621
|
+
],
|
|
2622
|
+
phoneHints: {
|
|
2623
|
+
preferredLayout: "signature-pad",
|
|
2624
|
+
preferredTool: "freehand",
|
|
2625
|
+
primaryActionLabel: "Submit",
|
|
2626
|
+
showUndo: true,
|
|
2627
|
+
showClear: true,
|
|
2628
|
+
},
|
|
2629
|
+
};
|
|
2630
|
+
}
|
|
2631
|
+
if (preset === "approval") {
|
|
2632
|
+
return {
|
|
2633
|
+
version: 1,
|
|
2634
|
+
privacy: "metadata-only",
|
|
2635
|
+
surfaceRole: "field",
|
|
2636
|
+
inputIntent: "approval",
|
|
2637
|
+
label,
|
|
2638
|
+
phoneHints: {
|
|
2639
|
+
preferredLayout: "approval-pad",
|
|
2640
|
+
preferredTool: "point",
|
|
2641
|
+
primaryActionLabel: "Approve",
|
|
2642
|
+
},
|
|
2643
|
+
};
|
|
2644
|
+
}
|
|
2645
|
+
return {
|
|
2646
|
+
version: 1,
|
|
2647
|
+
privacy: "metadata-only",
|
|
2648
|
+
surfaceRole: surfaceRoleForPreset(preset),
|
|
2649
|
+
inputIntent: preset === "pointer" ? "point" : "annotate",
|
|
2650
|
+
label,
|
|
2651
|
+
phoneHints: {
|
|
2652
|
+
preferredLayout: preset === "pointer" ? "pointing-pad" : "annotation-pad",
|
|
2653
|
+
preferredTool: preset === "pointer" ? "point" : "freehand",
|
|
2654
|
+
primaryActionLabel: "Done",
|
|
2655
|
+
showUndo: true,
|
|
2656
|
+
showClear: true,
|
|
2657
|
+
},
|
|
2658
|
+
};
|
|
2659
|
+
}
|
|
2660
|
+
function targetKindForPreset(preset) {
|
|
2661
|
+
if (preset === "signature" ||
|
|
2662
|
+
preset === "initials" ||
|
|
2663
|
+
preset === "approval") {
|
|
2664
|
+
return "field";
|
|
2665
|
+
}
|
|
2666
|
+
if (preset === "photoMarkup" || preset === "designReview")
|
|
2667
|
+
return "image";
|
|
2668
|
+
if (preset === "pdfMarkup")
|
|
2669
|
+
return "pdf";
|
|
2670
|
+
if (preset === "mapMarkup")
|
|
2671
|
+
return "map";
|
|
2672
|
+
if (preset === "screenMarkup" || preset === "pointer")
|
|
2673
|
+
return "screen";
|
|
2674
|
+
return "svg";
|
|
2675
|
+
}
|
|
2676
|
+
function surfaceRoleForPreset(preset) {
|
|
2677
|
+
if (preset === "photoMarkup" || preset === "designReview")
|
|
2678
|
+
return "image";
|
|
2679
|
+
if (preset === "mapMarkup")
|
|
2680
|
+
return "map";
|
|
2681
|
+
if (preset === "screenMarkup" || preset === "pointer")
|
|
2682
|
+
return "screen";
|
|
2683
|
+
if (preset === "sketch")
|
|
2684
|
+
return "canvas";
|
|
2685
|
+
return "document";
|
|
2686
|
+
}
|
|
2687
|
+
function labelForPreset(preset) {
|
|
2688
|
+
const labels = {
|
|
2689
|
+
signature: "Customer signature",
|
|
2690
|
+
initials: "Customer initials",
|
|
2691
|
+
approval: "Quick approval",
|
|
2692
|
+
sketch: "Sketch input",
|
|
2693
|
+
photoMarkup: "Photo annotation",
|
|
2694
|
+
pdfMarkup: "PDF annotation",
|
|
2695
|
+
mapMarkup: "Map annotation",
|
|
2696
|
+
screenMarkup: "Screen annotation",
|
|
2697
|
+
designReview: "Design review",
|
|
2698
|
+
pointer: "Pointer input",
|
|
2699
|
+
};
|
|
2700
|
+
return labels[preset];
|
|
2701
|
+
}
|
|
2702
|
+
function curlForCreateInput(apiBaseUrl, payload) {
|
|
2703
|
+
return [
|
|
2704
|
+
"curl -sS",
|
|
2705
|
+
"-X POST",
|
|
2706
|
+
shellQuote(`${printableApiBaseUrl(apiBaseUrl)}/v1/sessions`),
|
|
2707
|
+
"-H 'Authorization: Bearer <REMOTEDRAW_API_KEY>'",
|
|
2708
|
+
"-H 'Content-Type: application/json'",
|
|
2709
|
+
"--data",
|
|
2710
|
+
shellQuote(JSON.stringify(payload)),
|
|
2711
|
+
].join(" ");
|
|
2712
|
+
}
|
|
2713
|
+
function shellQuote(value) {
|
|
2714
|
+
return `'${value.replaceAll("'", `'\"'\"'`)}'`;
|
|
2715
|
+
}
|
|
2716
|
+
function normalizedOrigin(value) {
|
|
2717
|
+
const parsed = new URL(value);
|
|
2718
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
2719
|
+
throw new Error("Expected an HTTP(S) API base URL.");
|
|
2720
|
+
}
|
|
2721
|
+
if (parsed.pathname !== "/" || parsed.search || parsed.hash) {
|
|
2722
|
+
throw new Error("Expected API base URL to be an origin without a path, query, or hash.");
|
|
2723
|
+
}
|
|
2724
|
+
return parsed.origin;
|
|
2725
|
+
}
|
|
2726
|
+
function printableApiBaseUrl(value) {
|
|
2727
|
+
if (value.includes("<"))
|
|
2728
|
+
return value.trim().replace(/\/+$/, "");
|
|
2729
|
+
return normalizedOrigin(value);
|
|
2730
|
+
}
|
|
2731
|
+
function resolveAgentPath(value, runtime) {
|
|
2732
|
+
if (value === "~")
|
|
2733
|
+
return runtime.env.HOME ?? value;
|
|
2734
|
+
if (value.startsWith("~/")) {
|
|
2735
|
+
const home = runtime.env.HOME;
|
|
2736
|
+
if (home == null) {
|
|
2737
|
+
throw new Error("Cannot expand ~ because HOME is not set.");
|
|
2738
|
+
}
|
|
2739
|
+
return path.join(home, value.slice(2));
|
|
2740
|
+
}
|
|
2741
|
+
return path.resolve(runtime.cwd, value);
|
|
2742
|
+
}
|
|
2743
|
+
export function agentSkillMarkdown() {
|
|
2744
|
+
return [
|
|
2745
|
+
"---",
|
|
2746
|
+
"name: remotedraw",
|
|
2747
|
+
"description: Add RemoteDraw phone input to customer apps with the RemoteDraw CLI, public API, React SDK, raw HTTP, or customer-owned iOS sender flow.",
|
|
2748
|
+
"---",
|
|
2749
|
+
"",
|
|
2750
|
+
"# RemoteDraw Agent Skill",
|
|
2751
|
+
"",
|
|
2752
|
+
"Use this skill when a user asks to create, initialize, debug, or review a RemoteDraw integration.",
|
|
2753
|
+
"",
|
|
2754
|
+
"## Decision Flow",
|
|
2755
|
+
"",
|
|
2756
|
+
"1. Identify the receiver surface: web app, desktop app, iOS app, or headless/backend workflow.",
|
|
2757
|
+
"2. Identify the sender surface: RemoteDraw iOS app, embedded web sender, customer-owned iOS sender, or raw/headless sender.",
|
|
2758
|
+
"3. Pick the SDK path:",
|
|
2759
|
+
" - React SDK: web receiver and optional embedded web sender.",
|
|
2760
|
+
" - Plain JavaScript/raw HTTP: non-React web, desktop, backend, or custom clients.",
|
|
2761
|
+
" - Swift: customer-owned iOS sender apps.",
|
|
2762
|
+
"4. Pick a supported starter: signature for the product-ready SignatureStrip, or sketch for a receiver UI owned by the integrating app. Configure target kind and descriptors directly for photos, PDFs, maps, screens, and other custom surfaces.",
|
|
2763
|
+
"",
|
|
2764
|
+
"## CLI First",
|
|
2765
|
+
"",
|
|
2766
|
+
"Read the machine-readable option catalog before choosing a plan:",
|
|
2767
|
+
"",
|
|
2768
|
+
"```sh",
|
|
2769
|
+
"remotedraw options --format json",
|
|
2770
|
+
"```",
|
|
2771
|
+
"",
|
|
2772
|
+
"Initialize a project with the closest supported path:",
|
|
2773
|
+
"",
|
|
2774
|
+
"```sh",
|
|
2775
|
+
"remotedraw init --target web --sender remotedraw-ios --sdk react --preset signature",
|
|
2776
|
+
"remotedraw init --target web --sender embedded-web --sdk react --preset sketch",
|
|
2777
|
+
"remotedraw init --target desktop --sender remotedraw-ios --sdk js --preset sketch",
|
|
2778
|
+
"remotedraw init --target ios --sender own-ios --sdk swift --preset sketch",
|
|
2779
|
+
"```",
|
|
2780
|
+
"",
|
|
2781
|
+
"By default, new/init create the dashboard project and a project-scoped development API key, then write REMOTEDRAW_API_BASE_URL, REMOTEDRAW_PROJECT_ID, and REMOTEDRAW_API_KEY to a gitignored .env.local. Use --offline only when cloud setup is intentionally out of scope.",
|
|
2782
|
+
"",
|
|
2783
|
+
"Agents must use explicit non-interactive dry runs before changing a project:",
|
|
2784
|
+
"",
|
|
2785
|
+
"```sh",
|
|
2786
|
+
"remotedraw init --non-interactive --offline --dry-run --format json --target web --sender remotedraw-ios --sdk react --preset signature --package-manager npm",
|
|
2787
|
+
"# Inspect plan, files, defaultsApplied, and warnings before applying.",
|
|
2788
|
+
"remotedraw init --non-interactive --offline --format json --target web --sender remotedraw-ios --sdk react --preset signature --package-manager npm",
|
|
2789
|
+
"remotedraw doctor --format json",
|
|
2790
|
+
"remotedraw create-input --preset signature --json",
|
|
2791
|
+
"```",
|
|
2792
|
+
"",
|
|
2793
|
+
"Do not use the interactive wizard, synthesize arrow-key input, or scrape human-formatted output. If receiver, sender, or preset intent is ambiguous, ask the user instead of guessing. Never pass --force unless overwrite scope was explicitly approved.",
|
|
2794
|
+
"",
|
|
2795
|
+
"## Security Rules",
|
|
2796
|
+
"",
|
|
2797
|
+
"- Keep `rd_sk_...` API keys in trusted backend secrets only.",
|
|
2798
|
+
"- Keep the account-level `rd_cli_...` credential in the user config directory; never copy it into a project. Use REMOTEDRAW_CLI_TOKEN only as an explicitly managed CI secret.",
|
|
2799
|
+
"- Never place API keys in browser bundles, mobile clients, screenshots, logs, or generated examples.",
|
|
2800
|
+
"- Public clients should receive only `joinUrl`, `joinToken`, `receiverToken`, or `senderToken` values scoped to the session.",
|
|
2801
|
+
"- Production QR codes should use HTTPS `joinUrl` values. Do not make the custom scheme the primary QR target.",
|
|
2802
|
+
"",
|
|
2803
|
+
"## API Contract",
|
|
2804
|
+
"",
|
|
2805
|
+
"- Backend creates sessions with `POST /v1/sessions`.",
|
|
2806
|
+
"- Receiver clients read `POST /v1/receiver/session`, `/drawings`, `/drafts`, and `/senders` with a receiver token.",
|
|
2807
|
+
"- Sender clients join with `POST /v1/join`, stream mutable drafts to `/v1/sender/draft`, commit durable strokes to `/v1/sender/commit`, and finish with `/v1/sender/submit`.",
|
|
2808
|
+
"- Custom senders should throttle draft updates, coalesce to the latest pending preview, and commit one durable stroke on pointer-up with a stable `clientStrokeId`.",
|
|
2809
|
+
"",
|
|
2810
|
+
"## Verification",
|
|
2811
|
+
"",
|
|
2812
|
+
"After changes, run the narrowest relevant checks first:",
|
|
2813
|
+
"",
|
|
2814
|
+
"```sh",
|
|
2815
|
+
"remotedraw doctor",
|
|
2816
|
+
"bun run test:api",
|
|
2817
|
+
"bun run typecheck",
|
|
2818
|
+
"```",
|
|
2819
|
+
"",
|
|
2820
|
+
"For customer-owned iOS sender helpers in this repo, also run:",
|
|
2821
|
+
"",
|
|
2822
|
+
"```sh",
|
|
2823
|
+
"bun run ios:kit:test",
|
|
2824
|
+
"```",
|
|
2825
|
+
].join("\n");
|
|
2826
|
+
}
|
|
2827
|
+
function isHttpOrigin(value) {
|
|
2828
|
+
if (value == null || value.includes("<"))
|
|
2829
|
+
return false;
|
|
2830
|
+
try {
|
|
2831
|
+
normalizedOrigin(value);
|
|
2832
|
+
return true;
|
|
2833
|
+
}
|
|
2834
|
+
catch {
|
|
2835
|
+
return false;
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
function planDefaultsForExample(example) {
|
|
2839
|
+
if (example === "react-owned-sender") {
|
|
2840
|
+
return {
|
|
2841
|
+
target: "web",
|
|
2842
|
+
sender: "embedded-web",
|
|
2843
|
+
sdk: "react",
|
|
2844
|
+
preset: "sketch",
|
|
2845
|
+
};
|
|
2846
|
+
}
|
|
2847
|
+
if (example === "raw-http") {
|
|
2848
|
+
return {
|
|
2849
|
+
target: "desktop",
|
|
2850
|
+
sender: "remotedraw-ios",
|
|
2851
|
+
sdk: "js",
|
|
2852
|
+
preset: "sketch",
|
|
2853
|
+
};
|
|
2854
|
+
}
|
|
2855
|
+
if (example === "ios-owned-sender") {
|
|
2856
|
+
return { target: "ios", sender: "own-ios", sdk: "swift", preset: "sketch" };
|
|
2857
|
+
}
|
|
2858
|
+
return {
|
|
2859
|
+
target: "web",
|
|
2860
|
+
sender: "remotedraw-ios",
|
|
2861
|
+
sdk: "react",
|
|
2862
|
+
preset: "signature",
|
|
2863
|
+
};
|
|
2864
|
+
}
|
|
2865
|
+
function scaffoldSummary(verb, plan, outputDir, written, dryRun) {
|
|
2866
|
+
const nextCommandPrefix = dryRun ? "Would write" : "Wrote";
|
|
2867
|
+
return [
|
|
2868
|
+
`${verb} RemoteDraw ${plan.target} integration in ${outputDir}`,
|
|
2869
|
+
"",
|
|
2870
|
+
"Choices:",
|
|
2871
|
+
` App: ${plan.appName}`,
|
|
2872
|
+
` SDK: ${plan.sdk}`,
|
|
2873
|
+
` Sender: ${plan.sender}`,
|
|
2874
|
+
` Starter: ${plan.preset}`,
|
|
2875
|
+
"",
|
|
2876
|
+
`${nextCommandPrefix}:`,
|
|
2877
|
+
...written.map((file) => ` ${file}`),
|
|
2878
|
+
"",
|
|
2879
|
+
"Next:",
|
|
2880
|
+
` 1. ${installCommand(plan.packageManager)}`,
|
|
2881
|
+
" 2. Set REMOTEDRAW_API_BASE_URL and REMOTEDRAW_API_KEY in backend secrets.",
|
|
2882
|
+
" 3. Create an input request from backend code or run remotedraw create-input --json.",
|
|
2883
|
+
" 4. Render the returned HTTPS joinUrl in your receiver UI.",
|
|
2884
|
+
].join("\n");
|
|
2885
|
+
}
|
|
2886
|
+
function scaffoldJsonResult(command, plan, outputDir, files, parsed, provisioning, offline, dryRun) {
|
|
2887
|
+
const resolvedFields = [
|
|
2888
|
+
["appName", "app-name", plan.appName],
|
|
2889
|
+
["target", "target", plan.target],
|
|
2890
|
+
["sender", "sender", plan.sender],
|
|
2891
|
+
["sdk", "sdk", plan.sdk],
|
|
2892
|
+
["preset", "preset", plan.preset],
|
|
2893
|
+
["packageManager", "package-manager", plan.packageManager],
|
|
2894
|
+
["apiBaseUrl", "api-base-url", plan.apiBaseUrl],
|
|
2895
|
+
];
|
|
2896
|
+
const defaultsApplied = resolvedFields
|
|
2897
|
+
.filter(([, flag]) => readString(parsed, flag) == null)
|
|
2898
|
+
.map(([field, , value]) => ({ field, value }));
|
|
2899
|
+
const warnings = [
|
|
2900
|
+
...(plan.apiBaseUrl.includes("<deployment>")
|
|
2901
|
+
? ["Replace the deployment API placeholder before making live requests."]
|
|
2902
|
+
: []),
|
|
2903
|
+
...(offline
|
|
2904
|
+
? [
|
|
2905
|
+
"Cloud provisioning was skipped; no dashboard project or API key was created.",
|
|
2906
|
+
]
|
|
2907
|
+
: dryRun
|
|
2908
|
+
? [
|
|
2909
|
+
"Cloud authentication and provisioning were not executed during dry-run.",
|
|
2910
|
+
]
|
|
2911
|
+
: []),
|
|
2912
|
+
];
|
|
2913
|
+
return {
|
|
2914
|
+
ok: true,
|
|
2915
|
+
command,
|
|
2916
|
+
mode: dryRun ? "dry-run" : "apply",
|
|
2917
|
+
plan: { ...plan },
|
|
2918
|
+
defaultsApplied,
|
|
2919
|
+
outputDir,
|
|
2920
|
+
files,
|
|
2921
|
+
warnings,
|
|
2922
|
+
cloud: dryRun
|
|
2923
|
+
? { mode: "dry-run" }
|
|
2924
|
+
: offline
|
|
2925
|
+
? { mode: "offline" }
|
|
2926
|
+
: provisioning
|
|
2927
|
+
? {
|
|
2928
|
+
mode: "provisioned",
|
|
2929
|
+
projectId: provisioning.project.id,
|
|
2930
|
+
dashboardUrl: provisioning.project.dashboardUrl,
|
|
2931
|
+
}
|
|
2932
|
+
: { mode: "not-run" },
|
|
2933
|
+
nextCommands: [
|
|
2934
|
+
installCommand(plan.packageManager),
|
|
2935
|
+
"remotedraw doctor --format json",
|
|
2936
|
+
`remotedraw create-input --preset ${plan.preset} --json`,
|
|
2937
|
+
],
|
|
2938
|
+
};
|
|
2939
|
+
}
|
|
2940
|
+
function statusLine(state, label, message) {
|
|
2941
|
+
return `[${state}] ${label}: ${message}`;
|
|
2942
|
+
}
|
|
2943
|
+
function installCommand(packageManager) {
|
|
2944
|
+
if (packageManager === "npm")
|
|
2945
|
+
return "npm install";
|
|
2946
|
+
if (packageManager === "pnpm")
|
|
2947
|
+
return "pnpm install";
|
|
2948
|
+
if (packageManager === "yarn")
|
|
2949
|
+
return "yarn install";
|
|
2950
|
+
return "bun install";
|
|
2951
|
+
}
|
|
2952
|
+
function devCommandForPackageManager(packageManager) {
|
|
2953
|
+
if (packageManager === "npm")
|
|
2954
|
+
return "npm run dev";
|
|
2955
|
+
if (packageManager === "pnpm")
|
|
2956
|
+
return "pnpm dev";
|
|
2957
|
+
if (packageManager === "yarn")
|
|
2958
|
+
return "yarn dev";
|
|
2959
|
+
return "bun run dev";
|
|
2960
|
+
}
|
|
2961
|
+
function titleCase(value) {
|
|
2962
|
+
return value
|
|
2963
|
+
.split(/[-_\s]+/)
|
|
2964
|
+
.filter(Boolean)
|
|
2965
|
+
.map((segment) => segment[0]?.toUpperCase() + segment.slice(1))
|
|
2966
|
+
.join(" ");
|
|
2967
|
+
}
|
|
2968
|
+
function slugify(value) {
|
|
2969
|
+
const slug = value
|
|
2970
|
+
.trim()
|
|
2971
|
+
.toLowerCase()
|
|
2972
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
2973
|
+
.replace(/^-+|-+$/g, "");
|
|
2974
|
+
return slug || "remotedraw-app";
|
|
2975
|
+
}
|
|
2976
|
+
function escapeHtml(value) {
|
|
2977
|
+
return value
|
|
2978
|
+
.replaceAll("&", "&")
|
|
2979
|
+
.replaceAll("<", "<")
|
|
2980
|
+
.replaceAll(">", ">")
|
|
2981
|
+
.replaceAll('"', """);
|
|
2982
|
+
}
|
|
2983
|
+
function escapeText(value) {
|
|
2984
|
+
return value.replaceAll("{", "{").replaceAll("}", "}");
|
|
2985
|
+
}
|