@remotedraw/cli 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -3,22 +3,42 @@ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { spawn } from "node:child_process";
4
4
  import path from "node:path";
5
5
  import process from "node:process";
6
+ import { fileURLToPath } from "node:url";
6
7
  import { emitKeypressEvents } from "node:readline";
7
8
  import { createInterface } from "node:readline/promises";
8
9
  import { createWizardTerminal, runSetupWizard, } from "./setup-wizard.js";
9
- import { assertEnvCanAcceptProvisioning, currentCliAccount, globalConfigPath, loginToRemoteDraw, logoutFromRemoteDraw, provisionRemoteDrawProject, remoteDrawApiBaseUrl, writeProvisionedProjectSetup, } from "./cloud.js";
10
+ import { assertEnvCanAcceptProvisioning, currentCliAccount, DEFAULT_REMOTEDRAW_API_BASE_URL, globalConfigPath, loginToRemoteDraw, logoutFromRemoteDraw, provisionRemoteDrawProject, readStoredLanguage, remoteDrawApiBaseUrl, writeStoredLanguage, writeProvisionedProjectSetup, } from "./cloud.js";
11
+ import { LANGUAGE_ENV_VARS, SOURCE_LOCALE, extractLanguageFlag, languageChoices, languagePromptMessage, localeEndonym, normalizeLocale, resolveLocale, setActiveLocale, supportedLocaleList, SUPPORTED_LOCALES, systemLocaleHint, t, } from "./i18n.js";
12
+ import { CliError, cliError } from "./errors.js";
13
+ import { AGENT_SKILL_MARKDOWN } from "./generated/agent-skill.js";
14
+ import { reportCliCrash } from "./telemetry.js";
15
+ import { checkForUpdate, CLI_PACKAGE_NAME, detectInstallMethod, installMethodDisplay, runInstall, } from "./update.js";
10
16
  const packageMetadata = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
11
17
  export const CLI_VERSION = typeof packageMetadata.version === "string"
12
18
  ? packageMetadata.version
13
19
  : "unknown";
14
- const targetChoices = ["web", "desktop", "ios", "headless"];
15
- const senderChoices = [
20
+ // Generated manifests may only name packages this repository actually
21
+ // publishes, at a range those published versions satisfy. "latest" used to be
22
+ // written here, which resolved to nothing at all while the SDK packages were
23
+ // private, and would silently accept a future breaking major once they were
24
+ // not. tests/generated-manifest.test.ts cross-checks every range below against
25
+ // the workspace manifests.
26
+ export const REMOTEDRAW_SDK_RANGE = "^0.1.0";
27
+ export const REMOTEDRAW_CLI_RANGE = "^0.2.0";
28
+ export const targetChoices = ["web", "desktop", "ios", "headless"];
29
+ export const senderChoices = [
16
30
  "remotedraw-ios",
17
31
  "embedded-web",
18
32
  "own-ios",
19
33
  "headless",
20
34
  ];
21
- const sdkChoices = ["react", "svelte", "js", "swift", "headless"];
35
+ export const sdkChoices = [
36
+ "react",
37
+ "svelte",
38
+ "js",
39
+ "swift",
40
+ "headless",
41
+ ];
22
42
  const presetChoices = [
23
43
  "signature",
24
44
  "initials",
@@ -38,15 +58,16 @@ const exampleChoices = [
38
58
  "ios-owned-sender",
39
59
  ];
40
60
  const packageManagerChoices = ["npm", "pnpm", "yarn", "bun"];
41
- const apiEndpointChoices = ["deployment", "local"];
42
61
  export function createNodeRuntime() {
43
62
  const cwd = process.cwd();
44
63
  const env = cliEnvironmentForWorkspace(cwd, process.env);
45
64
  return {
46
65
  cwd,
47
66
  env,
67
+ binPath: process.argv[1] ?? fileURLToPath(import.meta.url),
48
68
  isInteractive: Boolean(process.stdin.isTTY && process.stderr.isTTY),
49
69
  exists: existsSync,
70
+ spawnCommand: runChildCommand,
50
71
  readFile: async (filePath) => {
51
72
  return await readFile(filePath, "utf8");
52
73
  },
@@ -78,10 +99,22 @@ export function cliEnvironmentForWorkspace(cwd, env, exists = existsSync) {
78
99
  ? { ...env, REMOTEDRAW_API_BASE_URL: env.CONVEX_SITE_URL }
79
100
  : env;
80
101
  }
102
+ async function runChildCommand(command, args) {
103
+ return await new Promise((resolve, reject) => {
104
+ const child = spawn(command, args, {
105
+ stdio: "inherit",
106
+ // Windows resolves npm/pnpm/yarn through .cmd shims, which need a shell.
107
+ shell: process.platform === "win32",
108
+ windowsHide: true,
109
+ });
110
+ child.once("error", reject);
111
+ child.once("close", (code) => resolve({ exitCode: code ?? 1 }));
112
+ });
113
+ }
81
114
  async function openExternalUrl(url) {
82
115
  const parsed = new URL(url);
83
116
  if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
84
- throw new Error("Only HTTP(S) URLs can be opened.");
117
+ throw new Error(t("error.httpUrlsOnly"));
85
118
  }
86
119
  const [command, args] = process.platform === "darwin"
87
120
  ? ["open", [url]]
@@ -132,10 +165,10 @@ function createTerminalPrompter(input, output) {
132
165
  }
133
166
  function terminalSelect(input, output, options) {
134
167
  if (options.choices.length === 0) {
135
- throw new Error(`No choices available for ${options.message}.`);
168
+ throw new Error(t("error.noChoices", { message: options.message }));
136
169
  }
137
170
  if (!input.isTTY || !output.isTTY || !input.setRawMode) {
138
- throw new Error("Interactive setup requires a TTY.");
171
+ throw new Error(t("error.interactiveNeedsTty"));
139
172
  }
140
173
  const defaultIndex = Math.max(0, options.choices.findIndex((choiceOption) => choiceOption.value === options.defaultValue));
141
174
  let selectedIndex = defaultIndex;
@@ -154,7 +187,7 @@ function terminalSelect(input, output, options) {
154
187
  const selected = options.choices[selectedIndex];
155
188
  cleanup();
156
189
  clearRendered(output, renderedLines);
157
- output.write(`${style("cyan", "?")} ${options.message} ${style("green", selected.label)}\n`);
190
+ output.write(`${style("green", "")} ${options.message} ${style("green", selected.label)}\n`);
158
191
  resolve(selected.value);
159
192
  };
160
193
  const fail = () => {
@@ -197,18 +230,25 @@ function terminalSelect(input, output, options) {
197
230
  });
198
231
  }
199
232
  function selectPromptLines(options, selectedIndex) {
200
- const lines = [`${style("cyan", "?")} ${options.message}`];
233
+ const lines = [`${style("cyan", "?")} ${style("bold", options.message)}`];
201
234
  if (options.helperText)
202
235
  lines.push(style("dim", options.helperText));
203
236
  for (const [index, choiceOption] of options.choices.entries()) {
204
237
  const selected = index === selectedIndex;
238
+ const marker = selected ? style("cyan", "❯ ◉") : style("dim", " ○");
205
239
  const label = selected
206
240
  ? style("bold", choiceOption.label)
207
241
  : choiceOption.label;
208
242
  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."));
243
+ lines.push(`${marker} ${label}${hint}`);
244
+ }
245
+ // Rendered in the active locale, which for the language question itself is
246
+ // English — nothing has been chosen yet at that point.
247
+ lines.push([
248
+ `${style("dim", "↑/↓")} ${t("prompt.control.move")}`,
249
+ `${style("dim", "Enter")} ${t("prompt.control.select")}`,
250
+ `${style("dim", "Ctrl+C")} ${t("prompt.control.quit")}`,
251
+ ].join(" · "));
212
252
  return lines;
213
253
  }
214
254
  function clearRendered(output, lineCount) {
@@ -224,13 +264,61 @@ function style(kind, value) {
224
264
  };
225
265
  return `${codes[kind]}${value}\x1b[0m`;
226
266
  }
267
+ /**
268
+ * How the locale for this run was decided. Only `language` reports it, but it
269
+ * has to be recorded where the decision is actually made.
270
+ */
271
+ let resolvedLocaleSource = "default";
272
+ /**
273
+ * The language the CLI's *chrome* resolved to. It can differ from the active
274
+ * locale on a machine-readable run, which is forced to English — `language`
275
+ * must still report what the reader actually chose.
276
+ */
277
+ let resolvedChromeLocale = SOURCE_LOCALE;
227
278
  export async function runCli(args, runtime = createNodeRuntime()) {
279
+ // The language flag is global, so it is taken out before per-command parsing
280
+ // — which rejects any option the command has not declared.
281
+ const { value: languageFlag, rest: cliArgs } = extractLanguageFlag(args);
228
282
  try {
229
- const command = args[0];
230
- const commandArgs = args.slice(1);
283
+ const stored = await readStoredLanguage(runtime);
284
+ const resolution = resolveLocale({
285
+ flag: languageFlag,
286
+ env: runtime.env,
287
+ stored,
288
+ });
289
+ if (!resolution.ok) {
290
+ // Report the bad value in whatever language the reader already chose,
291
+ // falling back to English when they have not chosen one.
292
+ setActiveLocale(normalizeLocale(stored) ?? SOURCE_LOCALE);
293
+ const message = t("language.unknown", {
294
+ value: resolution.value,
295
+ supported: supportedLocaleList(),
296
+ variable: LANGUAGE_ENV_VARS[0],
297
+ });
298
+ return requestedJsonOutput(args)
299
+ ? jsonFailure("language", new Error(message))
300
+ : fail(message);
301
+ }
302
+ // Machine-readable output is a data interchange format, not chrome: it
303
+ // stays English so an agent quoting a label back to its user does not
304
+ // suddenly speak the operator's language. `--language` on the same
305
+ // invocation is a deliberate request and does localize it; an ambient
306
+ // stored choice or environment variable does not.
307
+ const machineReadable = machineReadableOutput(cliArgs);
308
+ const effectiveLocale = machineReadable && resolution.source !== "flag"
309
+ ? SOURCE_LOCALE
310
+ : resolution.locale;
311
+ setActiveLocale(effectiveLocale);
312
+ resolvedChromeLocale = resolution.locale;
313
+ resolvedLocaleSource = resolution.source;
314
+ // Unresolved means "never asked". Interactive entry points ask; every
315
+ // other invocation — CI, a pipe, an agent shelling out — stays English.
316
+ const askLanguage = resolution.needsPrompt;
317
+ const command = cliArgs[0];
318
+ const commandArgs = cliArgs.slice(1);
231
319
  if (command == null) {
232
320
  if (runtime.wizardTerminal) {
233
- return await setupWizardCommand(runtime);
321
+ return await setupWizardCommand(runtime, askLanguage);
234
322
  }
235
323
  return ok(mainHelpText());
236
324
  }
@@ -245,6 +333,8 @@ export async function runCli(args, runtime = createNodeRuntime()) {
245
333
  return ok(guideText());
246
334
  case "options":
247
335
  return optionsCommand(commandArgs);
336
+ case "language":
337
+ return await languageCommand(commandArgs, runtime);
248
338
  case "login":
249
339
  return await loginCommand(commandArgs, runtime);
250
340
  case "logout":
@@ -252,9 +342,11 @@ export async function runCli(args, runtime = createNodeRuntime()) {
252
342
  case "whoami":
253
343
  return await whoamiCommand(commandArgs, runtime);
254
344
  case "new":
255
- return await newCommand(commandArgs, runtime);
345
+ return await newCommand(commandArgs, runtime, askLanguage);
256
346
  case "init":
257
347
  return await initCommand(commandArgs, runtime);
348
+ case "update":
349
+ return await updateCommand(commandArgs, runtime);
258
350
  case "doctor":
259
351
  return await doctorCommand(commandArgs, runtime);
260
352
  case "dev":
@@ -267,20 +359,36 @@ export async function runCli(args, runtime = createNodeRuntime()) {
267
359
  return await agentCommand(commandArgs, runtime);
268
360
  default:
269
361
  return requestedJsonOutput(args)
270
- ? jsonFailure(command, new Error(`Unknown command "${command}". Run remotedraw --help.`))
271
- : fail(`Unknown command "${command}". Run remotedraw --help.`);
362
+ ? jsonFailure(command, new Error(t("error.unknownCommand", { command })))
363
+ : fail(t("error.unknownCommand", { command }));
272
364
  }
273
365
  }
274
366
  catch (error) {
367
+ // Only genuine faults land here: a bad flag or an unknown command returns a
368
+ // `fail(...)` result instead of throwing, so this path is not user error.
369
+ const command = cliArgs[0] ?? "help";
370
+ await reportCliCrash(error, command, cliTelemetryEnvironment(runtime));
275
371
  return requestedJsonOutput(args)
276
- ? jsonFailure(args[0] ?? "help", error)
372
+ ? jsonFailure(command, error)
277
373
  : fail(errorMessage(error));
278
374
  }
279
375
  }
280
- async function setupWizardCommand(runtime) {
376
+ function cliTelemetryEnvironment(runtime) {
377
+ return {
378
+ env: runtime.env,
379
+ version: CLI_VERSION,
380
+ platform: process.platform,
381
+ nodeVersion: process.versions.node,
382
+ fetch: runtime.fetch,
383
+ };
384
+ }
385
+ async function setupWizardCommand(runtime, askLanguage) {
281
386
  const terminal = runtime.wizardTerminal;
282
387
  if (!terminal)
283
388
  return ok(mainHelpText());
389
+ // Before login, before the first field: nothing the reader has to understand
390
+ // is printed until the language is settled.
391
+ await ensureLanguageChosen(runtime, askLanguage);
284
392
  if (!cloudSetupDisabled(runtime)) {
285
393
  const apiBaseUrl = remoteDrawApiBaseUrl(runtime.env);
286
394
  await loginToRemoteDraw(runtime, apiBaseUrl);
@@ -305,6 +413,121 @@ async function setupWizardCommand(runtime) {
305
413
  });
306
414
  return { exitCode: result.exitCode, stdout: "" };
307
415
  }
416
+ /**
417
+ * The language question, asked once and only where a human is already being
418
+ * prompted. The question itself is built from every locale's own word for
419
+ * "Language" and lists endonyms, so it reads before any language is chosen.
420
+ */
421
+ async function ensureLanguageChosen(runtime, askLanguage) {
422
+ if (!askLanguage)
423
+ return;
424
+ const prompts = runtime.prompts;
425
+ // No TTY, no question: piped and CI runs must never block on this.
426
+ if (!prompts || runtime.isInteractive !== true)
427
+ return;
428
+ let chosen;
429
+ try {
430
+ chosen = await prompts.select({
431
+ message: languagePromptMessage(),
432
+ defaultValue: systemLocaleHint(runtime.env) ?? SOURCE_LOCALE,
433
+ choices: languageChoices(),
434
+ });
435
+ }
436
+ catch {
437
+ // A prompter that cannot run (stdin is not a real terminal after all)
438
+ // means English, never a failed command over a preference question.
439
+ return;
440
+ }
441
+ const locale = normalizeLocale(chosen) ?? SOURCE_LOCALE;
442
+ await applyLanguage(runtime, locale);
443
+ }
444
+ /** Sets the locale for the rest of this run and remembers it for later ones. */
445
+ async function applyLanguage(runtime, locale) {
446
+ setActiveLocale(locale);
447
+ resolvedChromeLocale = locale;
448
+ resolvedLocaleSource = "prompt";
449
+ try {
450
+ await writeStoredLanguage(runtime, locale);
451
+ }
452
+ catch (error) {
453
+ // An unwritable config is worth saying out loud, but it must not abort the
454
+ // command the reader actually asked for.
455
+ runtime.notify?.(t("language.saveFailed", {
456
+ reason: errorMessage(error),
457
+ language: localeEndonym(locale),
458
+ }));
459
+ }
460
+ }
461
+ async function languageCommand(args, runtime) {
462
+ const parsed = parseArgs(args, { values: ["format"], booleans: ["help"] });
463
+ if (parsed.booleans.has("help"))
464
+ return ok(languageHelpText());
465
+ const format = outputFormat(parsed);
466
+ const requested = parsed.positionals[0];
467
+ if (requested != null) {
468
+ const locale = normalizeLocale(requested);
469
+ if (!locale) {
470
+ const message = t("language.unknown", {
471
+ value: requested,
472
+ supported: supportedLocaleList(),
473
+ variable: LANGUAGE_ENV_VARS[0],
474
+ });
475
+ return format === "json"
476
+ ? jsonFailure("language", new Error(message))
477
+ : fail(message);
478
+ }
479
+ await applyLanguage(runtime, locale);
480
+ return format === "json"
481
+ ? jsonResult({ ok: true, command: "language", language: locale })
482
+ : ok(t("language.saved", { language: localeEndonym(locale) }));
483
+ }
484
+ if (format !== "json" && runtime.prompts && runtime.isInteractive === true) {
485
+ const chosen = await runtime.prompts.select({
486
+ message: languagePromptMessage(),
487
+ defaultValue: resolvedChromeLocale,
488
+ choices: languageChoices(),
489
+ });
490
+ const locale = normalizeLocale(chosen) ?? SOURCE_LOCALE;
491
+ await applyLanguage(runtime, locale);
492
+ return ok(t("language.saved", { language: localeEndonym(locale) }));
493
+ }
494
+ const current = resolvedChromeLocale;
495
+ if (format === "json") {
496
+ return jsonResult({
497
+ ok: true,
498
+ command: "language",
499
+ language: current,
500
+ source: resolvedLocaleSource,
501
+ available: SUPPORTED_LOCALES.map((locale) => ({
502
+ code: locale,
503
+ endonym: localeEndonym(locale),
504
+ })),
505
+ });
506
+ }
507
+ return ok([
508
+ t("language.current", {
509
+ language: localeEndonym(current),
510
+ locale: current,
511
+ }),
512
+ languageSourceLine(runtime),
513
+ "",
514
+ t("language.available"),
515
+ ...SUPPORTED_LOCALES.map((locale) => ` ${locale.padEnd(6)} ${localeEndonym(locale)}`),
516
+ ].join("\n"));
517
+ }
518
+ function languageSourceLine(runtime) {
519
+ if (resolvedLocaleSource === "flag")
520
+ return t("language.source.flag");
521
+ if (resolvedLocaleSource === "env") {
522
+ const variable = LANGUAGE_ENV_VARS.find((name) => runtime.env[name]?.trim()) ??
523
+ LANGUAGE_ENV_VARS[0];
524
+ return t("language.source.env", { variable });
525
+ }
526
+ if (resolvedLocaleSource === "config" || resolvedLocaleSource === "prompt") {
527
+ return t("language.source.config", { path: globalConfigPath(runtime) });
528
+ }
529
+ return t("language.source.default");
530
+ }
308
531
  function ok(stdout) {
309
532
  return { exitCode: 0, stdout: ensureTrailingNewline(stdout) };
310
533
  }
@@ -316,6 +539,12 @@ function jsonResult(value, exitCode = 0) {
316
539
  }
317
540
  function jsonFailure(command, error) {
318
541
  const message = errorMessage(error);
542
+ // A declared code survives translation; the substring heuristic below is
543
+ // only a fallback for errors raised outside the catalog (a failed fetch, a
544
+ // filesystem fault) and is matched against English internals.
545
+ if (error instanceof CliError) {
546
+ return jsonResult({ ok: false, command, error: { code: error.code, message } }, 1);
547
+ }
319
548
  const code = message.includes("required")
320
549
  ? "MISSING_ARGUMENT"
321
550
  : message.includes(" requires ") || message.includes("only supported")
@@ -329,6 +558,14 @@ function jsonFailure(command, error) {
329
558
  : "COMMAND_FAILED";
330
559
  return jsonResult({ ok: false, command, error: { code, message } }, 1);
331
560
  }
561
+ /**
562
+ * True when this invocation asks for output meant to be parsed rather than
563
+ * read: `--format json` on any command, or `create-input --json`.
564
+ */
565
+ function machineReadableOutput(args) {
566
+ return (requestedJsonOutput(args) ||
567
+ args.some((argument) => argument === "--json"));
568
+ }
332
569
  function requestedJsonOutput(args) {
333
570
  return args.some((argument, index) => argument === "--format=json" ||
334
571
  (argument === "--format" && args[index + 1] === "json"));
@@ -346,31 +583,34 @@ function errorMessage(error) {
346
583
  return error instanceof Error ? error.message : String(error);
347
584
  }
348
585
  function mainHelpText() {
586
+ const command = (name, description) => ` ${name.padEnd(14)} ${description}`;
349
587
  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:",
588
+ t("help.title"),
589
+ "",
590
+ t("word.usage"),
591
+ t("help.usage.main"),
592
+ "",
593
+ t("word.commands"),
594
+ command("guide", t("help.command.guide")),
595
+ command("options", t("help.command.options")),
596
+ command("language", t("help.command.language")),
597
+ command("login", t("help.command.login")),
598
+ command("logout", t("help.command.logout")),
599
+ command("whoami", t("help.command.whoami")),
600
+ command("new", t("help.command.new")),
601
+ command("init", t("help.command.init")),
602
+ command("dev", t("help.command.dev")),
603
+ command("doctor", t("help.command.doctor")),
604
+ command("update", t("help.command.update")),
605
+ command("create-input", t("help.command.createInput")),
606
+ command("examples", t("help.command.examples")),
607
+ command("agent", t("help.command.agent")),
608
+ "",
609
+ t("help.startHere"),
370
610
  " remotedraw guide",
371
611
  " remotedraw login",
372
612
  " remotedraw new",
373
- " remotedraw new --app-name MijnApp --target web --sender remotedraw-ios --sdk react",
613
+ " remotedraw new --app-name MyApp --target web --sender remotedraw-ios --sdk react",
374
614
  " remotedraw init --target web --sender embedded-web --sdk react",
375
615
  ].join("\n");
376
616
  }
@@ -385,39 +625,39 @@ function optionsCommand(args) {
385
625
  if (outputFormat(parsed) === "json")
386
626
  return jsonResult(catalog);
387
627
  return ok([
388
- "RemoteDraw setup options",
628
+ t("options.title"),
389
629
  "",
390
630
  ...catalog.fields.flatMap((field) => [
391
631
  `${field.label} (${field.flag})`,
392
632
  ...field.options.map((option) => ` ${option.value}: ${option.details.summary}`),
393
633
  "",
394
634
  ]),
395
- "For machine-readable metadata: remotedraw options --format json",
635
+ t("options.machineReadable"),
396
636
  ].join("\n"));
397
637
  }
398
638
  function guideText() {
399
639
  return [
400
- "RemoteDraw integration guide",
640
+ t("guide.title"),
401
641
  "",
402
- "Choose the surface your customer already has:",
403
- " Web app receiver + RemoteDraw iOS app sender",
642
+ t("guide.intro"),
643
+ t("guide.path.webIos"),
404
644
  " remotedraw init --target web --sender remotedraw-ios --sdk react --preset signature",
405
645
  "",
406
- " Web app receiver + your own web sender UI",
646
+ t("guide.path.webOwned"),
407
647
  " remotedraw init --target web --sender embedded-web --sdk react --preset sketch",
408
648
  "",
409
- " Svelte app receiver + RemoteDraw iOS app sender",
649
+ t("guide.path.svelteIos"),
410
650
  " remotedraw init --target web --sender remotedraw-ios --sdk svelte --preset signature",
411
651
  "",
412
- " Desktop app receiver with raw HTTP",
652
+ t("guide.path.desktop"),
413
653
  " remotedraw init --target desktop --sender remotedraw-ios --sdk js --preset sketch",
414
654
  "",
415
- " Your own iOS sender app",
655
+ t("guide.path.ownIos"),
416
656
  " remotedraw init --target ios --sender own-ios --sdk swift --preset sketch",
417
657
  "",
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.",
658
+ t("guide.note.keys"),
659
+ t("guide.note.provisioning"),
660
+ t("guide.note.offline"),
421
661
  ].join("\n");
422
662
  }
423
663
  async function loginCommand(args, runtime) {
@@ -435,11 +675,9 @@ async function loginCommand(args, runtime) {
435
675
  ...(deviceName == null ? {} : { deviceName }),
436
676
  });
437
677
  return ok([
438
- result.alreadyLoggedIn
439
- ? "Already logged in."
440
- : "RemoteDraw login complete.",
678
+ result.alreadyLoggedIn ? t("login.already") : t("login.complete"),
441
679
  accountSummary(result.account),
442
- `Credential: ${globalConfigPath(runtime)}`,
680
+ t("login.credential", { path: globalConfigPath(runtime) }),
443
681
  ].join("\n"));
444
682
  }
445
683
  async function logoutCommand(args, runtime) {
@@ -452,8 +690,8 @@ async function logoutCommand(args, runtime) {
452
690
  const apiBaseUrl = remoteDrawApiBaseUrl(runtime.env, readString(parsed, "api-base-url"));
453
691
  const result = await logoutFromRemoteDraw(runtime, apiBaseUrl);
454
692
  return ok(result.hadCredential
455
- ? `Logged out from ${apiBaseUrl}.`
456
- : `No stored credential for ${apiBaseUrl}.`);
693
+ ? t("logout.done", { url: apiBaseUrl })
694
+ : t("logout.none", { url: apiBaseUrl }));
457
695
  }
458
696
  async function whoamiCommand(args, runtime) {
459
697
  const parsed = parseArgs(args, {
@@ -465,15 +703,15 @@ async function whoamiCommand(args, runtime) {
465
703
  const apiBaseUrl = remoteDrawApiBaseUrl(runtime.env, readString(parsed, "api-base-url"));
466
704
  const account = await currentCliAccount(runtime, apiBaseUrl);
467
705
  if (!account) {
468
- return fail(`Not logged in to ${apiBaseUrl}. Run remotedraw login.`);
706
+ return fail(t("whoami.notLoggedIn", { url: apiBaseUrl }));
469
707
  }
470
- return ok([accountSummary(account), `API: ${apiBaseUrl}`].join("\n"));
708
+ return ok([accountSummary(account), t("whoami.api", { url: apiBaseUrl })].join("\n"));
471
709
  }
472
710
  function accountSummary(account) {
473
- const identity = account.email || account.name || "RemoteDraw user";
711
+ const identity = account.email || account.name || t("whoami.anonymous");
474
712
  return `${identity} (${account.tenantSlug})`;
475
713
  }
476
- async function newCommand(args, runtime) {
714
+ async function newCommand(args, runtime, askLanguage = false) {
477
715
  const parsed = parseArgs(args, {
478
716
  values: [
479
717
  "app-name",
@@ -491,8 +729,13 @@ async function newCommand(args, runtime) {
491
729
  if (parsed.booleans.has("help"))
492
730
  return ok(newHelpText());
493
731
  const format = outputFormat(parsed);
494
- const plan = !parsed.booleans.has("non-interactive") &&
495
- shouldPromptForNewProject(parsed, runtime)
732
+ const interactive = !parsed.booleans.has("non-interactive") &&
733
+ shouldPromptForNewProject(parsed, runtime);
734
+ // Only ask when this run was going to prompt anyway; a fully flagged
735
+ // `new` stays as non-interactive as the caller wrote it.
736
+ if (interactive)
737
+ await ensureLanguageChosen(runtime, askLanguage);
738
+ const plan = interactive
496
739
  ? await promptForNewProject(parsed, runtime)
497
740
  : planFromArgs(parsed, {
498
741
  appNameRequired: true,
@@ -517,7 +760,7 @@ async function newCommand(args, runtime) {
517
760
  }
518
761
  return format === "json"
519
762
  ? jsonResult(scaffoldJsonResult("new", plan, outputDir, written, parsed, provisioning, offline, dryRun))
520
- : ok(scaffoldSummary("Created", plan, outputDir, written, dryRun) +
763
+ : ok(scaffoldSummary(t("scaffold.verb.created"), plan, outputDir, written, dryRun) +
521
764
  cloudSetupSummary(provisioning, offline, dryRun));
522
765
  }
523
766
  export async function createRemoteDrawProject(plan, outputDir, runtime, options = {}) {
@@ -574,7 +817,7 @@ async function initCommand(args, runtime) {
574
817
  }
575
818
  return format === "json"
576
819
  ? jsonResult(scaffoldJsonResult("init", plan, outputDir, written, parsed, provisioning, offline, dryRun))
577
- : ok(scaffoldSummary("Initialized", plan, outputDir, written, dryRun) +
820
+ : ok(scaffoldSummary(t("scaffold.verb.initialized"), plan, outputDir, written, dryRun) +
578
821
  cloudSetupSummary(provisioning, offline, dryRun));
579
822
  }
580
823
  function cloudSetupDisabled(runtime) {
@@ -596,16 +839,16 @@ function cloudSetupSummary(provisioning, offline, dryRun) {
596
839
  if (dryRun)
597
840
  return "";
598
841
  if (offline) {
599
- return "\n\nCloud setup skipped (--offline). Run remotedraw init later to create a dashboard project.";
842
+ return `\n\n${t("cloud.skipped")}`;
600
843
  }
601
844
  if (!provisioning)
602
845
  return "";
603
846
  return [
604
847
  "",
605
848
  "",
606
- `Dashboard project: ${provisioning.project.dashboardUrl}`,
607
- "Development API key: written to .env.local (server-side only)",
608
- `Project ID: ${provisioning.project.id}`,
849
+ t("cloud.dashboardProject", { url: provisioning.project.dashboardUrl }),
850
+ t("cloud.devKey"),
851
+ t("cloud.projectId", { id: provisioning.project.id }),
609
852
  ].join("\n");
610
853
  }
611
854
  const DOCTOR_PROBE_TIMEOUT_MS = 8_000;
@@ -614,7 +857,7 @@ function probeFailureMessage(error) {
614
857
  }
615
858
  async function probeApiHealth(runtime, apiBaseUrl) {
616
859
  if (!runtime.fetch) {
617
- return { ok: false, message: "this runtime cannot make network requests" };
860
+ return { ok: false, message: t("doctor.probe.noNetwork") };
618
861
  }
619
862
  try {
620
863
  const response = await runtime.fetch(`${apiBaseUrl}/health`, {
@@ -637,7 +880,7 @@ async function probeApiHealth(runtime, apiBaseUrl) {
637
880
  */
638
881
  async function probeApiKey(runtime, apiBaseUrl, apiKey) {
639
882
  if (!runtime.fetch) {
640
- return { ok: false, message: "This runtime cannot make network requests." };
883
+ return { ok: false, message: t("doctor.probe.noNetworkKey") };
641
884
  }
642
885
  try {
643
886
  const response = await runtime.fetch(`${apiBaseUrl}/v1/sessions/list`, {
@@ -657,7 +900,7 @@ async function probeApiKey(runtime, apiBaseUrl, apiKey) {
657
900
  catch {
658
901
  return {
659
902
  ok: false,
660
- message: `Deployment returned a non-JSON response (HTTP ${response.status}).`,
903
+ message: t("doctor.probe.nonJson", { status: response.status }),
661
904
  };
662
905
  }
663
906
  if (!response.ok) {
@@ -667,7 +910,7 @@ async function probeApiKey(runtime, apiBaseUrl, apiKey) {
667
910
  typeof payload.error?.message ===
668
911
  "string"
669
912
  ? payload.error.message
670
- : `Deployment rejected the key (HTTP ${response.status}).`;
913
+ : t("doctor.probe.rejected", { status: response.status });
671
914
  return { ok: false, message };
672
915
  }
673
916
  const items = typeof payload === "object" && payload !== null && "items" in payload
@@ -679,9 +922,111 @@ async function probeApiKey(runtime, apiBaseUrl, apiKey) {
679
922
  return { ok: false, message: probeFailureMessage(error) };
680
923
  }
681
924
  }
925
+ /**
926
+ * npm never refreshes a globally installed binary on its own, so staying
927
+ * current is an explicit step. This command makes that step one word.
928
+ */
929
+ async function updateCommand(args, runtime) {
930
+ const parsed = parseArgs(args, {
931
+ values: ["format"],
932
+ booleans: ["check", "help"],
933
+ });
934
+ if (parsed.booleans.has("help"))
935
+ return ok(updateHelpText());
936
+ const format = outputFormat(parsed);
937
+ const checkOnly = parsed.booleans.has("check");
938
+ const method = detectInstallMethod(runtime.binPath ?? "");
939
+ const command = installMethodDisplay(method);
940
+ // `remotedraw update` is an explicit request, so it ignores the cache and the
941
+ // ambient opt-outs that only silence the passive notice.
942
+ const check = await checkForUpdate(runtime, CLI_VERSION, { force: true });
943
+ if (!check) {
944
+ const message = t("update.unreachable", {
945
+ package: CLI_PACKAGE_NAME,
946
+ command,
947
+ });
948
+ return format === "json"
949
+ ? jsonFailure("update", new Error(message))
950
+ : fail(message);
951
+ }
952
+ if (!check.updateAvailable) {
953
+ const message = t("update.alreadyLatest", {
954
+ package: CLI_PACKAGE_NAME,
955
+ version: check.current,
956
+ });
957
+ return format === "json"
958
+ ? jsonResult({ ok: true, command: "update", ...check, updated: false })
959
+ : ok(message);
960
+ }
961
+ if (checkOnly || method.ephemeral) {
962
+ const message = method.ephemeral
963
+ ? [
964
+ t("update.available", {
965
+ current: check.current,
966
+ latest: check.latest,
967
+ }),
968
+ t("update.ephemeral", { package: CLI_PACKAGE_NAME }),
969
+ ].join("\n")
970
+ : [
971
+ t("update.available", {
972
+ current: check.current,
973
+ latest: check.latest,
974
+ }),
975
+ t("update.runToInstall", { command }),
976
+ ].join("\n");
977
+ return format === "json"
978
+ ? jsonResult({
979
+ ok: true,
980
+ command: "update",
981
+ ...check,
982
+ updated: false,
983
+ installCommand: command,
984
+ ephemeral: method.ephemeral,
985
+ })
986
+ : ok(message);
987
+ }
988
+ const install = await runInstall(runtime, method);
989
+ if (!install.ran) {
990
+ const message = t("update.availableRun", {
991
+ current: check.current,
992
+ latest: check.latest,
993
+ command,
994
+ });
995
+ return format === "json"
996
+ ? jsonResult({
997
+ ok: true,
998
+ command: "update",
999
+ ...check,
1000
+ updated: false,
1001
+ installCommand: command,
1002
+ })
1003
+ : ok(message);
1004
+ }
1005
+ if (install.exitCode !== 0) {
1006
+ const message = t("update.failed", {
1007
+ command,
1008
+ code: install.exitCode,
1009
+ });
1010
+ return format === "json"
1011
+ ? jsonFailure("update", new Error(message))
1012
+ : fail(message);
1013
+ }
1014
+ return format === "json"
1015
+ ? jsonResult({
1016
+ ok: true,
1017
+ command: "update",
1018
+ ...check,
1019
+ updated: true,
1020
+ installCommand: command,
1021
+ })
1022
+ : ok(t("update.done", {
1023
+ package: CLI_PACKAGE_NAME,
1024
+ version: check.latest,
1025
+ }));
1026
+ }
682
1027
  async function doctorCommand(args, runtime) {
683
1028
  const parsed = parseArgs(args, {
684
- values: ["path", "format"],
1029
+ values: ["path", "format", "api-base-url"],
685
1030
  booleans: ["help", "offline"],
686
1031
  });
687
1032
  if (parsed.booleans.has("help"))
@@ -693,67 +1038,89 @@ async function doctorCommand(args, runtime) {
693
1038
  const projectEnv = await environmentForProject(dir, runtime);
694
1039
  const configPath = path.join(dir, "remotedraw.config.json");
695
1040
  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);
1041
+ const apiBaseUrl = resolveDoctorApiBaseUrl(projectEnv, readString(parsed, "api-base-url"));
1042
+ const normalizedEnvApiBaseUrl = apiBaseUrl.state === "ok" ? apiBaseUrl.url : undefined;
700
1043
  const envApiKey = projectEnv.REMOTEDRAW_API_KEY;
701
- const lines = ["RemoteDraw doctor", ""];
1044
+ const lines = [t("doctor.title"), ""];
702
1045
  const checks = [];
703
1046
  const addCheck = (state, label, message) => {
704
1047
  checks.push({ state, label, message });
705
1048
  lines.push(statusLine(state, label, message));
706
1049
  };
707
1050
  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.");
1051
+ addCheck(hasConfig ? "ok" : "warn", "remotedraw.config.json", hasConfig ? t("doctor.config.ok") : t("doctor.config.missing"));
711
1052
  const packageJson = (await runtime.exists(packagePath))
712
1053
  ? await readJsonFile(packagePath, runtime)
713
1054
  : null;
714
- addCheck(packageJson ? "ok" : "warn", "package.json", packageJson
715
- ? "Package metadata found."
716
- : "No package.json found in this directory.");
1055
+ addCheck(packageJson ? "ok" : "warn", "package.json", packageJson ? t("doctor.package.ok") : t("doctor.package.missing"));
717
1056
  const config = hasConfig ? await readJsonFile(configPath, runtime) : null;
718
1057
  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.");
1058
+ const sdkPackage = sdkPackageName(sdk);
1059
+ if (sdkPackage) {
1060
+ const installed = hasDependency(packageJson, sdkPackage);
1061
+ addCheck(installed ? "ok" : "warn", sdkPackage, installed
1062
+ ? t("doctor.sdk.ok", { package: sdkPackage })
1063
+ : t("doctor.sdk.missing", {
1064
+ package: sdkPackage,
1065
+ range: REMOTEDRAW_SDK_RANGE,
1066
+ }));
1067
+ }
1068
+ addCheck(apiBaseUrl.state === "ok"
1069
+ ? "ok"
1070
+ : apiBaseUrl.state === "placeholder"
1071
+ ? "warn"
1072
+ : "error", "REMOTEDRAW_API_BASE_URL", doctorApiBaseUrlMessage(apiBaseUrl));
727
1073
  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.");
1074
+ ? t("doctor.apiKey.ok")
1075
+ : t("doctor.apiKey.missing"));
730
1076
  // The checks above only prove the strings look right. A revoked key, a URL
731
1077
  // pointing at the wrong deployment, and an exhausted credit balance all pass
732
1078
  // them, so doctor also asks the deployment itself.
733
1079
  if (offline) {
734
- addCheck("warn", "deployment", "Skipped network checks (--offline). Rerun without --offline to verify the key against the deployment.");
1080
+ addCheck("warn", t("doctor.label.deployment"), t("doctor.network.offline"));
735
1081
  }
736
1082
  else if (!normalizedEnvApiBaseUrl) {
737
- addCheck("warn", "deployment", "Skipped network checks because REMOTEDRAW_API_BASE_URL is not set.");
1083
+ addCheck("warn", t("doctor.label.deployment"), t("doctor.network.noOrigin"));
738
1084
  }
739
1085
  else {
740
1086
  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}`);
1087
+ addCheck(health.ok ? "ok" : "error", t("doctor.label.deploymentReachable"), health.ok
1088
+ ? t("doctor.health.ok", { url: normalizedEnvApiBaseUrl })
1089
+ : t("doctor.health.failed", {
1090
+ url: normalizedEnvApiBaseUrl,
1091
+ reason: health.message,
1092
+ }));
744
1093
  if (!envApiKey?.startsWith("rd_sk_")) {
745
- addCheck("warn", "API key accepted", "Skipped because no rd_sk_... key was found in this project's environment.");
1094
+ addCheck("warn", t("doctor.label.apiKeyAccepted"), t("doctor.key.skippedNoKey"));
746
1095
  }
747
1096
  else if (!health.ok) {
748
- addCheck("warn", "API key accepted", "Skipped because the deployment did not answer.");
1097
+ addCheck("warn", t("doctor.label.apiKeyAccepted"), t("doctor.key.skippedNoHealth"));
749
1098
  }
750
1099
  else {
751
1100
  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.`
1101
+ addCheck(probe.ok ? "ok" : "error", t("doctor.label.apiKeyAccepted"), probe.ok
1102
+ ? t(probe.sessionCount === 1
1103
+ ? "doctor.key.liveOne"
1104
+ : "doctor.key.liveMany", { count: probe.sessionCount })
754
1105
  : probe.message);
755
1106
  }
756
1107
  }
1108
+ // A stale global install is the quietest failure mode there is: npm pins the
1109
+ // binary at install time and never revisits it, so doctor has to say so.
1110
+ if (offline) {
1111
+ addCheck("warn", t("doctor.label.cliVersion"), t("doctor.version.offline", { version: CLI_VERSION }));
1112
+ }
1113
+ else {
1114
+ const update = await checkForUpdate(runtime, CLI_VERSION, { force: true });
1115
+ addCheck(!update || !update.updateAvailable ? "ok" : "warn", t("doctor.label.cliVersion"), !update
1116
+ ? t("doctor.version.unknown", { version: CLI_VERSION })
1117
+ : update.updateAvailable
1118
+ ? t("doctor.version.stale", {
1119
+ current: update.current,
1120
+ latest: update.latest,
1121
+ })
1122
+ : t("doctor.version.latest", { current: update.current }));
1123
+ }
757
1124
  return format === "json"
758
1125
  ? jsonResult({
759
1126
  ok: true,
@@ -775,23 +1142,22 @@ async function devCommand(args, runtime) {
775
1142
  const projectEnv = await environmentForProject(dir, runtime);
776
1143
  const apiBaseUrl = readString(parsed, "api-base-url") ??
777
1144
  projectEnv.REMOTEDRAW_API_BASE_URL ??
778
- "http://localhost:3210";
1145
+ DEFAULT_REMOTEDRAW_API_BASE_URL;
779
1146
  return ok([
780
- "RemoteDraw local development workflow",
1147
+ t("dev.title"),
781
1148
  "",
782
- `API base URL: ${apiBaseUrl}`,
1149
+ t("dev.apiBaseUrl", { url: apiBaseUrl }),
783
1150
  "",
784
- "1. Start the RemoteDraw API/web service for local testing.",
785
- " Inside this repository: bun run dev:codex",
1151
+ t("dev.hosted"),
786
1152
  "",
787
- "2. Start your app's dev server.",
788
- " Use the package manager and dev script generated by remotedraw new/init.",
1153
+ t("dev.step1"),
1154
+ t("dev.step1.detail"),
789
1155
  "",
790
- "3. Create a test input request from trusted backend code.",
1156
+ t("dev.step2"),
791
1157
  ` remotedraw create-input --preset signature --api-base-url ${apiBaseUrl}`,
792
1158
  "",
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.",
1159
+ t("dev.step3"),
1160
+ t("dev.step3.detail"),
795
1161
  ].join("\n"));
796
1162
  }
797
1163
  async function createInputCommand(args, runtime) {
@@ -812,7 +1178,7 @@ async function createInputCommand(args, runtime) {
812
1178
  const preset = choice(readString(parsed, "preset") ?? "signature", presetChoices, "preset");
813
1179
  const apiBaseUrl = readString(parsed, "api-base-url") ??
814
1180
  projectEnv.REMOTEDRAW_API_BASE_URL ??
815
- "https://<deployment>.convex.site";
1181
+ DEFAULT_REMOTEDRAW_API_BASE_URL;
816
1182
  const payload = createSessionPayload({
817
1183
  preset,
818
1184
  label: readString(parsed, "label") ?? labelForPreset(preset),
@@ -822,9 +1188,9 @@ async function createInputCommand(args, runtime) {
822
1188
  if (parsed.booleans.has("execute")) {
823
1189
  const apiKey = readString(parsed, "api-key") ?? projectEnv.REMOTEDRAW_API_KEY;
824
1190
  if (!runtime.fetch)
825
- throw new Error("This runtime cannot execute HTTP requests.");
1191
+ throw new Error(t("createInput.error.noFetch"));
826
1192
  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.");
1193
+ throw new Error(t("createInput.error.noKey"));
828
1194
  }
829
1195
  const response = await runtime.fetch(`${normalizedOrigin(apiBaseUrl)}/v1/sessions`, {
830
1196
  method: "POST",
@@ -836,20 +1202,20 @@ async function createInputCommand(args, runtime) {
836
1202
  });
837
1203
  const text = await response.text();
838
1204
  if (!response.ok)
839
- throw new Error(text || `RemoteDraw API returned ${response.status}.`);
1205
+ throw new Error(text || t("createInput.error.apiStatus", { status: response.status }));
840
1206
  return ok(text);
841
1207
  }
842
1208
  if (parsed.booleans.has("json")) {
843
1209
  return ok(JSON.stringify(payload, null, 2));
844
1210
  }
845
1211
  return ok([
846
- "RemoteDraw test input request",
1212
+ t("createInput.title"),
847
1213
  "",
848
- "Create this from trusted backend code. Do not run API-key requests in a browser client.",
1214
+ t("createInput.warning"),
849
1215
  "",
850
1216
  curlForCreateInput(apiBaseUrl, payload),
851
1217
  "",
852
- "Use --json to print only the request body or --execute to call the API with REMOTEDRAW_API_KEY.",
1218
+ t("createInput.footer"),
853
1219
  ].join("\n"));
854
1220
  }
855
1221
  async function examplesCommand(args, runtime) {
@@ -862,14 +1228,14 @@ async function examplesCommand(args, runtime) {
862
1228
  const example = readString(parsed, "install");
863
1229
  if (example == null || parsed.booleans.has("list")) {
864
1230
  return ok([
865
- "RemoteDraw examples",
1231
+ t("examples.title"),
866
1232
  "",
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.",
1233
+ ` ${"react-ios".padEnd(19)} ${t("examples.reactIos")}`,
1234
+ ` ${"react-owned-sender".padEnd(19)} ${t("examples.reactOwnedSender")}`,
1235
+ ` ${"raw-http".padEnd(19)} ${t("examples.rawHttp")}`,
1236
+ ` ${"ios-owned-sender".padEnd(19)} ${t("examples.iosOwnedSender")}`,
871
1237
  "",
872
- "Install one:",
1238
+ t("examples.installOne"),
873
1239
  " remotedraw examples --install react-ios --path ./remotedraw-react-ios",
874
1240
  ].join("\n"));
875
1241
  }
@@ -887,7 +1253,7 @@ async function examplesCommand(args, runtime) {
887
1253
  dryRun,
888
1254
  force: parsed.booleans.has("force"),
889
1255
  });
890
- return ok(scaffoldSummary("Installed example", plan, outputDir, written, dryRun));
1256
+ return ok(scaffoldSummary(t("scaffold.verb.installedExample"), plan, outputDir, written, dryRun));
891
1257
  }
892
1258
  async function agentCommand(args, runtime) {
893
1259
  const parsed = parseArgs(args, {
@@ -901,15 +1267,18 @@ async function agentCommand(args, runtime) {
901
1267
  const targetDir = readString(parsed, "path");
902
1268
  if (targetDir == null) {
903
1269
  return ok([
904
- "RemoteDraw agent setup",
1270
+ t("agent.title"),
905
1271
  "",
906
- "Use this when an AI coding agent needs to add RemoteDraw to a customer app.",
1272
+ t("agent.intro"),
907
1273
  "",
908
- "Print the skill:",
1274
+ t("agent.printSkill"),
909
1275
  " remotedraw agent --print-skill",
910
1276
  "",
911
- "Install the skill file:",
912
- " remotedraw agent --path ~/.codex/skills/remotedraw",
1277
+ t("agent.installSkill"),
1278
+ // Agent runtimes read skills from a per-project directory. `~/.codex`
1279
+ // is the retired location and installing there reaches nothing.
1280
+ " remotedraw agent --path .agents/skills/remotedraw",
1281
+ " remotedraw agent --path .claude/skills/remotedraw",
913
1282
  ].join("\n"));
914
1283
  }
915
1284
  const outputDir = resolveAgentPath(targetDir, runtime);
@@ -917,7 +1286,7 @@ async function agentCommand(args, runtime) {
917
1286
  if (!parsed.booleans.has("dry-run") &&
918
1287
  !parsed.booleans.has("force") &&
919
1288
  (await runtime.exists(outputFile))) {
920
- throw new Error("SKILL.md already exists. Pass --force to replace it.");
1289
+ throw new Error(t("agent.error.exists"));
921
1290
  }
922
1291
  if (!parsed.booleans.has("dry-run")) {
923
1292
  await runtime.mkdir(outputDir);
@@ -925,127 +1294,150 @@ async function agentCommand(args, runtime) {
925
1294
  }
926
1295
  return ok([
927
1296
  parsed.booleans.has("dry-run")
928
- ? `Would write ${outputFile}`
929
- : `Installed RemoteDraw agent skill at ${outputFile}`,
1297
+ ? t("agent.wouldWrite", { path: outputFile })
1298
+ : t("agent.installed", { path: outputFile }),
930
1299
  "",
931
- "Next:",
932
- " Ask your agent to use the RemoteDraw skill before editing API, CLI, sender, receiver, or iOS integration code.",
1300
+ t("word.next"),
1301
+ t("agent.next"),
933
1302
  ].join("\n"));
934
1303
  }
935
1304
  function newHelpText() {
1305
+ const option = (flag, description) => ` ${flag.padEnd(25)} ${description}`;
936
1306
  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.",
1307
+ t("word.usage"),
1308
+ t("new.help.usage.bare"),
1309
+ t("new.help.usage.named"),
1310
+ "",
1311
+ t("new.help.interactive"),
1312
+ "",
1313
+ t("word.options"),
1314
+ option("--path <dir>", t("new.help.option.path")),
1315
+ option("--target <kind>", t("new.help.option.target")),
1316
+ option("--sender <kind>", t("new.help.option.sender")),
1317
+ option("--sdk <kind>", t("new.help.option.sdk")),
1318
+ option("--preset <kind>", t("new.help.option.preset")),
1319
+ option("--package-manager <name>", t("new.help.option.packageManager")),
1320
+ option("--api-base-url <url>", t("new.help.option.apiBaseUrl")),
1321
+ option("--force", t("new.help.option.force")),
1322
+ option("--offline", t("new.help.option.offline")),
1323
+ option("--non-interactive", t("new.help.option.nonInteractive")),
1324
+ option("--format <text|json>", t("new.help.option.format")),
1325
+ option("--dry-run", t("new.help.option.dryRun")),
1326
+ option("--language <code>", t("new.help.option.language")),
956
1327
  ].join("\n");
957
1328
  }
958
1329
  function initHelpText() {
959
1330
  return [
960
- "Usage:",
961
- " remotedraw init [options]",
1331
+ t("word.usage"),
1332
+ t("init.help.usage"),
962
1333
  "",
963
- "Options match remotedraw new. init writes remotedraw.config.json, .env.example, and src/remotedraw starter files into an existing project.",
1334
+ t("init.help.body"),
964
1335
  ].join("\n");
965
1336
  }
966
- function loginHelpText() {
1337
+ function languageHelpText() {
967
1338
  return [
968
- "Authorize the RemoteDraw CLI",
1339
+ t("language.help.title"),
1340
+ "",
1341
+ t("word.usage"),
1342
+ t("language.help.usage.list"),
1343
+ t("language.help.usage.set"),
969
1344
  "",
970
- "Usage:",
971
- " remotedraw login [--no-open] [--force] [--api-base-url <origin>]",
1345
+ t("language.help.body"),
1346
+ t("language.help.override"),
972
1347
  "",
973
- "The browser confirms your account. A revocable CLI credential is stored in your user config directory, never in the project.",
1348
+ t("language.available"),
1349
+ ...SUPPORTED_LOCALES.map((locale) => ` ${locale.padEnd(6)} ${localeEndonym(locale)}`),
974
1350
  ].join("\n");
975
1351
  }
976
- function logoutHelpText() {
1352
+ function loginHelpText() {
977
1353
  return [
978
- "Revoke the RemoteDraw CLI credential",
1354
+ t("login.help.title"),
979
1355
  "",
980
- "Usage:",
981
- " remotedraw logout [--api-base-url <origin>]",
1356
+ t("word.usage"),
1357
+ t("login.help.usage"),
1358
+ "",
1359
+ t("login.help.body"),
982
1360
  ].join("\n");
983
1361
  }
1362
+ function logoutHelpText() {
1363
+ return [t("logout.help.title"), "", t("word.usage"), t("logout.help.usage")].join("\n");
1364
+ }
984
1365
  function whoamiHelpText() {
1366
+ return [t("whoami.help.title"), "", t("word.usage"), t("whoami.help.usage")].join("\n");
1367
+ }
1368
+ function doctorHelpText() {
985
1369
  return [
986
- "Show the active RemoteDraw CLI account",
987
- "",
988
- "Usage:",
989
- " remotedraw whoami [--api-base-url <origin>]",
1370
+ t("word.usage"),
1371
+ t("doctor.help.usage"),
1372
+ "",
1373
+ t("doctor.help.checks"),
1374
+ t("doctor.help.baseUrl", { url: DEFAULT_REMOTEDRAW_API_BASE_URL }),
1375
+ t("doctor.help.network"),
1376
+ t("doctor.help.version"),
1377
+ t("doctor.help.offline"),
990
1378
  ].join("\n");
991
1379
  }
992
- function doctorHelpText() {
1380
+ function updateHelpText() {
993
1381
  return [
994
- "Usage:",
995
- " remotedraw doctor [--path <dir>] [--format text|json] [--offline]",
1382
+ t("word.usage"),
1383
+ t("update.help.usage"),
996
1384
  "",
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.",
1385
+ t("update.help.body", { package: CLI_PACKAGE_NAME }),
1386
+ t("update.help.global"),
1387
+ t("update.help.check"),
1388
+ "",
1389
+ t("update.help.silence"),
1000
1390
  ].join("\n");
1001
1391
  }
1002
1392
  function optionsHelpText() {
1003
1393
  return [
1004
- "Usage:",
1394
+ t("word.usage"),
1005
1395
  " remotedraw options [--format text|json]",
1006
1396
  "",
1007
- "Prints every setup choice, compatibility rule, default, explanation, and documentation URL.",
1397
+ t("options.help.body"),
1008
1398
  ].join("\n");
1009
1399
  }
1010
1400
  function devHelpText() {
1011
1401
  return [
1012
- "Usage:",
1013
- " remotedraw dev [--api-base-url <url>]",
1402
+ t("word.usage"),
1403
+ t("dev.help.usage"),
1014
1404
  "",
1015
- "Prints the local test workflow for pairing, input request creation, and receiver rendering.",
1405
+ t("dev.help.body"),
1016
1406
  ].join("\n");
1017
1407
  }
1018
1408
  function createInputHelpText() {
1409
+ const option = (flag, description) => ` ${flag.padEnd(21)} ${description}`;
1019
1410
  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.",
1411
+ t("word.usage"),
1412
+ t("createInput.help.usage"),
1413
+ "",
1414
+ t("word.options"),
1415
+ option("--preset <kind>", t("createInput.help.option.preset")),
1416
+ option("--label <text>", t("createInput.help.option.label")),
1417
+ option("--external-id <id>", t("createInput.help.option.externalId")),
1418
+ option("--api-base-url <url>", t("createInput.help.option.apiBaseUrl")),
1419
+ option("--api-key <key>", t("createInput.help.option.apiKey")),
1420
+ option("--expires-in-ms <n>", t("createInput.help.option.expiresInMs")),
1030
1421
  ].join("\n");
1031
1422
  }
1032
1423
  function examplesHelpText() {
1033
1424
  return [
1034
- "Usage:",
1035
- " remotedraw examples [--list]",
1036
- " remotedraw examples --install <example> --path <dir>",
1425
+ t("word.usage"),
1426
+ t("examples.help.usage.list"),
1427
+ t("examples.help.usage.install"),
1037
1428
  "",
1038
- `Examples: ${exampleChoices.join(", ")}`,
1429
+ t("word.examples", { list: exampleChoices.join(", ") }),
1039
1430
  ].join("\n");
1040
1431
  }
1041
1432
  function agentHelpText() {
1042
1433
  return [
1043
- "Usage:",
1044
- " remotedraw agent",
1045
- " remotedraw agent --print-skill",
1046
- " remotedraw agent --path <dir> [--force] [--dry-run]",
1434
+ t("word.usage"),
1435
+ t("agent.help.usage.bare"),
1436
+ t("agent.help.usage.print"),
1437
+ t("agent.help.usage.install"),
1047
1438
  "",
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.",
1439
+ t("agent.help.body"),
1440
+ t("agent.help.paths"),
1049
1441
  ].join("\n");
1050
1442
  }
1051
1443
  function parseArgs(args, allowed) {
@@ -1068,17 +1460,17 @@ function parseArgs(args, allowed) {
1068
1460
  const inlineValue = equalsIndex === -1 ? undefined : withoutPrefix.slice(equalsIndex + 1);
1069
1461
  if (booleanFlags.has(name)) {
1070
1462
  if (inlineValue != null) {
1071
- throw new Error(`--${name} does not accept a value.`);
1463
+ throw cliError("INVALID_ARGUMENT", "error.optionNoValue", { name });
1072
1464
  }
1073
1465
  booleans.add(name);
1074
1466
  continue;
1075
1467
  }
1076
1468
  if (!valueFlags.has(name)) {
1077
- throw new Error(`Unsupported option "--${name}".`);
1469
+ throw cliError("INVALID_ARGUMENT", "error.unsupportedOption", { name });
1078
1470
  }
1079
1471
  const value = inlineValue ?? args[index + 1];
1080
1472
  if (value == null || value.startsWith("--")) {
1081
- throw new Error(`--${name} requires a value.`);
1473
+ throw cliError("MISSING_ARGUMENT", "error.optionNeedsValue", { name });
1082
1474
  }
1083
1475
  values.set(name, value);
1084
1476
  if (inlineValue == null)
@@ -1095,7 +1487,7 @@ function optionalInteger(value) {
1095
1487
  return undefined;
1096
1488
  const number = Number(value);
1097
1489
  if (!Number.isInteger(number) || number <= 0) {
1098
- throw new Error("--expires-in-ms requires a positive integer.");
1490
+ throw cliError("INVALID_ARGUMENT", "error.expiresInMs");
1099
1491
  }
1100
1492
  return number;
1101
1493
  }
@@ -1112,16 +1504,16 @@ async function promptForNewProject(parsed, runtime) {
1112
1504
  defaultAppName: "RemoteDraw app",
1113
1505
  });
1114
1506
  }
1115
- await prompts.intro?.("RemoteDraw project setup");
1507
+ await prompts.intro?.(t("prompt.intro.setup"));
1116
1508
  const appName = readString(parsed, "app-name") ??
1117
1509
  (await prompts.text({
1118
- message: "Project name",
1510
+ message: t("field.appName"),
1119
1511
  defaultValue: "RemoteDraw App",
1120
1512
  validate: validateProjectName,
1121
1513
  }));
1122
1514
  const target = readString(parsed, "target") == null
1123
1515
  ? await prompts.select({
1124
- message: "Project type",
1516
+ message: t("field.target"),
1125
1517
  defaultValue: "web",
1126
1518
  choices: targetPromptChoices(),
1127
1519
  })
@@ -1134,25 +1526,19 @@ async function promptForNewProject(parsed, runtime) {
1134
1526
  : choice(readString(parsed, "sdk"), sdkChoices, "sdk");
1135
1527
  const preset = readString(parsed, "preset") == null
1136
1528
  ? await prompts.select({
1137
- message: "Starter experience",
1529
+ message: t("field.preset"),
1138
1530
  defaultValue: "signature",
1139
1531
  choices: presetPromptChoices(),
1140
1532
  })
1141
1533
  : choice(readString(parsed, "preset"), presetChoices, "preset");
1142
1534
  const packageManager = readString(parsed, "package-manager") == null
1143
1535
  ? await prompts.select({
1144
- message: "Package manager",
1536
+ message: t("field.packageManager"),
1145
1537
  defaultValue: "npm",
1146
1538
  choices: packageManagerPromptChoices(),
1147
1539
  })
1148
1540
  : 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
- }));
1541
+ const apiBaseUrl = readString(parsed, "api-base-url") ?? DEFAULT_REMOTEDRAW_API_BASE_URL;
1156
1542
  validatePlan({ target, sender, sdk });
1157
1543
  return {
1158
1544
  appName,
@@ -1166,13 +1552,13 @@ async function promptForNewProject(parsed, runtime) {
1166
1552
  };
1167
1553
  }
1168
1554
  function validateProjectName(value) {
1169
- return value.trim() === "" ? "Enter a project name." : undefined;
1555
+ return value.trim() === "" ? t("field.appName.error") : undefined;
1170
1556
  }
1171
1557
  async function promptForSender(prompts, target) {
1172
1558
  const choices = senderPromptChoices(target);
1173
1559
  return await prompts.select({
1174
- message: "Phone sender",
1175
- helperText: "Choose where drawing input will run.",
1560
+ message: t("field.sender"),
1561
+ helperText: t("field.sender.helper"),
1176
1562
  defaultValue: defaultPromptValue(defaultSenderForTarget(target), choices),
1177
1563
  choices,
1178
1564
  });
@@ -1180,8 +1566,8 @@ async function promptForSender(prompts, target) {
1180
1566
  async function promptForSdk(prompts, target, sender) {
1181
1567
  const choices = sdkPromptChoices(target, sender);
1182
1568
  return await prompts.select({
1183
- message: "UI framework / SDK",
1184
- helperText: "Pick the SDK that matches your receiver UI.",
1569
+ message: t("field.sdk"),
1570
+ helperText: t("field.sdk.helper"),
1185
1571
  defaultValue: defaultPromptValue(defaultSdkForChoices(target, sender), choices),
1186
1572
  choices,
1187
1573
  });
@@ -1193,10 +1579,10 @@ function defaultPromptValue(preferred, choices) {
1193
1579
  }
1194
1580
  function targetPromptChoices() {
1195
1581
  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" },
1582
+ { value: "web", label: t("choice.target.web.label") },
1583
+ { value: "desktop", label: t("choice.target.desktop.label") },
1584
+ { value: "ios", label: t("choice.target.ios.label") },
1585
+ { value: "headless", label: t("choice.target.headless.label") },
1200
1586
  ];
1201
1587
  }
1202
1588
  const DOCS_INTEGRATION_PATHS = "https://docs.remotedraw.com/docs#paths";
@@ -1204,103 +1590,112 @@ const DOCS_SDKS = "https://docs.remotedraw.com/docs/sdks#components";
1204
1590
  const DOCS_PAYLOADS = "https://docs.remotedraw.com/docs/api#payloads";
1205
1591
  const DOCS_ENDPOINTS = "https://docs.remotedraw.com/docs/api#endpoints";
1206
1592
  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
- };
1593
+ // Detail bundles are built per call, not frozen at module load: the active
1594
+ // locale is only known once `runCli` has parsed the flag, the environment, and
1595
+ // the stored choice.
1596
+ function targetWizardDetails() {
1597
+ return {
1598
+ web: {
1599
+ summary: t("choice.target.web.summary"),
1600
+ explanation: t("choice.target.web.explanation"),
1601
+ docsUrl: DOCS_INTEGRATION_PATHS,
1602
+ docsLabel: t("choice.target.web.docs"),
1603
+ },
1604
+ desktop: {
1605
+ summary: t("choice.target.desktop.summary"),
1606
+ explanation: t("choice.target.desktop.explanation"),
1607
+ docsUrl: DOCS_INTEGRATION_PATHS,
1608
+ docsLabel: t("choice.target.desktop.docs"),
1609
+ },
1610
+ ios: {
1611
+ summary: t("choice.target.ios.summary"),
1612
+ explanation: t("choice.target.ios.explanation"),
1613
+ docsUrl: DOCS_INTEGRATION_PATHS,
1614
+ docsLabel: t("choice.target.ios.docs"),
1615
+ },
1616
+ headless: {
1617
+ summary: t("choice.target.headless.summary"),
1618
+ explanation: t("choice.target.headless.explanation"),
1619
+ docsUrl: DOCS_ENDPOINTS,
1620
+ docsLabel: t("choice.target.headless.docs"),
1621
+ },
1622
+ };
1623
+ }
1233
1624
  function wizardChoices(choices, details) {
1234
- return dutchWizardChoices(choices).map((choiceOption) => ({
1625
+ return choices.map((choiceOption) => ({
1235
1626
  ...choiceOption,
1236
1627
  details: details[choiceOption.value],
1237
1628
  }));
1238
1629
  }
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
- };
1630
+ function senderWizardDetails() {
1631
+ return {
1632
+ "remotedraw-ios": {
1633
+ summary: t("choice.sender.remotedrawIos.summary"),
1634
+ explanation: t("choice.sender.remotedrawIos.explanation"),
1635
+ docsUrl: DOCS_INTEGRATION_PATHS,
1636
+ docsLabel: t("choice.sender.remotedrawIos.docs"),
1637
+ },
1638
+ "embedded-web": {
1639
+ summary: t("choice.sender.embeddedWeb.summary"),
1640
+ explanation: t("choice.sender.embeddedWeb.explanation"),
1641
+ docsUrl: DOCS_INTEGRATION_PATHS,
1642
+ docsLabel: t("choice.sender.embeddedWeb.docs"),
1643
+ },
1644
+ "own-ios": {
1645
+ summary: t("choice.sender.ownIos.summary"),
1646
+ explanation: t("choice.sender.ownIos.explanation"),
1647
+ docsUrl: DOCS_INTEGRATION_PATHS,
1648
+ docsLabel: t("choice.sender.ownIos.docs"),
1649
+ },
1650
+ headless: {
1651
+ summary: t("choice.sender.headless.summary"),
1652
+ explanation: t("choice.sender.headless.explanation"),
1653
+ docsUrl: DOCS_ENDPOINTS,
1654
+ docsLabel: t("choice.sender.headless.docs"),
1655
+ },
1656
+ };
1657
+ }
1658
+ function sdkWizardDetails() {
1659
+ return {
1660
+ react: {
1661
+ summary: t("choice.sdk.react.summary"),
1662
+ explanation: t("choice.sdk.react.explanation"),
1663
+ docsUrl: DOCS_SDKS,
1664
+ docsLabel: t("choice.sdk.react.docs"),
1665
+ },
1666
+ svelte: {
1667
+ summary: t("choice.sdk.svelte.summary"),
1668
+ explanation: t("choice.sdk.svelte.explanation"),
1669
+ docsUrl: DOCS_SDKS,
1670
+ docsLabel: t("choice.sdk.svelte.docs"),
1671
+ },
1672
+ js: {
1673
+ summary: t("choice.sdk.js.summary"),
1674
+ explanation: t("choice.sdk.js.explanation"),
1675
+ docsUrl: DOCS_ENDPOINTS,
1676
+ docsLabel: t("choice.sdk.js.docs"),
1677
+ },
1678
+ swift: {
1679
+ summary: t("choice.sdk.swift.summary"),
1680
+ explanation: t("choice.sdk.swift.explanation"),
1681
+ docsUrl: DOCS_SDKS,
1682
+ docsLabel: t("choice.sdk.swift.docs"),
1683
+ },
1684
+ headless: {
1685
+ summary: t("choice.sdk.headless.summary"),
1686
+ explanation: t("choice.sdk.headless.explanation"),
1687
+ docsUrl: DOCS_ENDPOINTS,
1688
+ docsLabel: t("choice.sdk.headless.docs"),
1689
+ },
1690
+ };
1691
+ }
1297
1692
  function senderPromptChoices(target) {
1298
1693
  if (target === "ios") {
1299
1694
  return [
1300
1695
  {
1301
1696
  value: "own-ios",
1302
- label: "Own iOS sender",
1303
- hint: "Use Swift join-link and sender request helpers.",
1697
+ label: t("choice.sender.ownIos.label"),
1698
+ hint: t("choice.sender.ownIos.hint"),
1304
1699
  },
1305
1700
  ];
1306
1701
  }
@@ -1308,8 +1703,8 @@ function senderPromptChoices(target) {
1308
1703
  return [
1309
1704
  {
1310
1705
  value: "headless",
1311
- label: "Headless/custom sender",
1312
- hint: "No hosted phone UI.",
1706
+ label: t("choice.sender.headless.label"),
1707
+ hint: t("choice.sender.headless.hint.noUi"),
1313
1708
  },
1314
1709
  ];
1315
1710
  }
@@ -1317,26 +1712,26 @@ function senderPromptChoices(target) {
1317
1712
  return [
1318
1713
  {
1319
1714
  value: "remotedraw-ios",
1320
- label: "RemoteDraw iOS app",
1321
- hint: "Show a join URL or QR code from your receiver.",
1715
+ label: t("choice.sender.remotedrawIos.label"),
1716
+ hint: t("choice.sender.remotedrawIos.hint.joinUrl"),
1322
1717
  },
1323
1718
  {
1324
1719
  value: "headless",
1325
- label: "Headless/custom sender",
1326
- hint: "Build directly on the HTTP sender routes.",
1720
+ label: t("choice.sender.headless.label"),
1721
+ hint: t("choice.sender.headless.hint.httpRoutes"),
1327
1722
  },
1328
1723
  ];
1329
1724
  }
1330
1725
  return [
1331
1726
  {
1332
1727
  value: "remotedraw-ios",
1333
- label: "RemoteDraw iOS app",
1334
- hint: "Fastest path: scan the receiver QR code.",
1728
+ label: t("choice.sender.remotedrawIos.label"),
1729
+ hint: t("choice.sender.remotedrawIos.hint.scan"),
1335
1730
  },
1336
1731
  {
1337
1732
  value: "embedded-web",
1338
- label: "Own web sender",
1339
- hint: "Use a web sender component or raw sender helpers.",
1733
+ label: t("choice.sender.embeddedWeb.label"),
1734
+ hint: t("choice.sender.embeddedWeb.hint"),
1340
1735
  },
1341
1736
  ];
1342
1737
  }
@@ -1345,8 +1740,8 @@ function sdkPromptChoices(target, sender) {
1345
1740
  return [
1346
1741
  {
1347
1742
  value: "swift",
1348
- label: "Swift helpers",
1349
- hint: "For customer-owned iOS sender apps.",
1743
+ label: t("choice.sdk.swift.label"),
1744
+ hint: t("choice.sdk.swift.hint"),
1350
1745
  },
1351
1746
  ];
1352
1747
  }
@@ -1354,13 +1749,13 @@ function sdkPromptChoices(target, sender) {
1354
1749
  return [
1355
1750
  {
1356
1751
  value: "js",
1357
- label: "JavaScript client",
1358
- hint: "Framework-free HTTP clients and protocol types.",
1752
+ label: t("choice.sdk.js.label.client"),
1753
+ hint: t("choice.sdk.js.hint.client"),
1359
1754
  },
1360
1755
  {
1361
1756
  value: "headless",
1362
- label: "Config only",
1363
- hint: "No UI package dependency.",
1757
+ label: t("choice.sdk.headless.label"),
1758
+ hint: t("choice.sdk.headless.hint"),
1364
1759
  },
1365
1760
  ];
1366
1761
  }
@@ -1368,26 +1763,26 @@ function sdkPromptChoices(target, sender) {
1368
1763
  return [
1369
1764
  {
1370
1765
  value: "js",
1371
- label: "JavaScript client",
1372
- hint: "Framework-free HTTP clients and protocol types.",
1766
+ label: t("choice.sdk.js.label.client"),
1767
+ hint: t("choice.sdk.js.hint.client"),
1373
1768
  },
1374
1769
  ];
1375
1770
  }
1376
1771
  return [
1377
1772
  {
1378
1773
  value: "react",
1379
- label: "React SDK",
1380
- hint: "Receiver, pairing, and optional embedded sender components.",
1774
+ label: t("choice.sdk.react.label"),
1775
+ hint: t("choice.sdk.react.hint"),
1381
1776
  },
1382
1777
  {
1383
1778
  value: "svelte",
1384
- label: "Svelte SDK",
1385
- hint: "Svelte receiver store plus shared HTTP clients.",
1779
+ label: t("choice.sdk.svelte.label"),
1780
+ hint: t("choice.sdk.svelte.hint"),
1386
1781
  },
1387
1782
  {
1388
1783
  value: "js",
1389
- label: "No framework",
1390
- hint: "Framework-free clients and raw HTTP starter files.",
1784
+ label: t("choice.sdk.js.label.noFramework"),
1785
+ hint: t("choice.sdk.js.hint.noFramework"),
1391
1786
  },
1392
1787
  ];
1393
1788
  }
@@ -1395,13 +1790,13 @@ function presetPromptChoices() {
1395
1790
  return [
1396
1791
  {
1397
1792
  value: "signature",
1398
- label: "Signature strip",
1399
- hint: "Product-ready branded field with exact surface mapping.",
1793
+ label: t("choice.preset.signature.label"),
1794
+ hint: t("choice.preset.signature.hint"),
1400
1795
  },
1401
1796
  {
1402
1797
  value: "sketch",
1403
- label: "Custom drawing surface",
1404
- hint: "Flexible normalized input for your own receiver UI.",
1798
+ label: t("choice.preset.sketch.label"),
1799
+ hint: t("choice.preset.sketch.hint"),
1405
1800
  },
1406
1801
  ];
1407
1802
  }
@@ -1411,122 +1806,98 @@ function packageManagerPromptChoices() {
1411
1806
  label: packageManager,
1412
1807
  }));
1413
1808
  }
1414
- function apiEndpointPromptChoices() {
1415
- return [
1416
- {
1417
- value: "deployment",
1418
- label: "Deployment URL placeholder",
1419
- hint: "Fill in your Convex site origin later.",
1809
+ function presetWizardDetails() {
1810
+ return {
1811
+ signature: {
1812
+ summary: t("choice.preset.signature.summary"),
1813
+ explanation: t("choice.preset.signature.explanation"),
1814
+ docsUrl: DOCS_PAYLOADS,
1815
+ docsLabel: t("choice.preset.signature.docs"),
1420
1816
  },
1421
- {
1422
- value: "local",
1423
- label: "Local dev server",
1424
- hint: "Use http://localhost:3210.",
1817
+ initials: {
1818
+ summary: t("choice.preset.initials.summary"),
1819
+ explanation: t("choice.preset.initials.explanation"),
1820
+ docsUrl: DOCS_PAYLOADS,
1821
+ docsLabel: t("choice.preset.initials.docs"),
1425
1822
  },
1426
- ];
1823
+ approval: {
1824
+ summary: t("choice.preset.approval.summary"),
1825
+ explanation: t("choice.preset.approval.explanation"),
1826
+ docsUrl: DOCS_PAYLOADS,
1827
+ docsLabel: t("choice.preset.approval.docs"),
1828
+ },
1829
+ sketch: {
1830
+ summary: t("choice.preset.sketch.summary"),
1831
+ explanation: t("choice.preset.sketch.explanation"),
1832
+ docsUrl: DOCS_PAYLOADS,
1833
+ docsLabel: t("choice.preset.sketch.docs"),
1834
+ },
1835
+ photoMarkup: {
1836
+ summary: t("choice.preset.photoMarkup.summary"),
1837
+ explanation: t("choice.preset.photoMarkup.explanation"),
1838
+ docsUrl: DOCS_PAYLOADS,
1839
+ docsLabel: t("choice.preset.photoMarkup.docs"),
1840
+ },
1841
+ pdfMarkup: {
1842
+ summary: t("choice.preset.pdfMarkup.summary"),
1843
+ explanation: t("choice.preset.pdfMarkup.explanation"),
1844
+ docsUrl: DOCS_PAYLOADS,
1845
+ docsLabel: t("choice.preset.pdfMarkup.docs"),
1846
+ },
1847
+ mapMarkup: {
1848
+ summary: t("choice.preset.mapMarkup.summary"),
1849
+ explanation: t("choice.preset.mapMarkup.explanation"),
1850
+ docsUrl: DOCS_PAYLOADS,
1851
+ docsLabel: t("choice.preset.mapMarkup.docs"),
1852
+ },
1853
+ screenMarkup: {
1854
+ summary: t("choice.preset.screenMarkup.summary"),
1855
+ explanation: t("choice.preset.screenMarkup.explanation"),
1856
+ docsUrl: DOCS_PAYLOADS,
1857
+ docsLabel: t("choice.preset.screenMarkup.docs"),
1858
+ },
1859
+ designReview: {
1860
+ summary: t("choice.preset.designReview.summary"),
1861
+ explanation: t("choice.preset.designReview.explanation"),
1862
+ docsUrl: DOCS_PAYLOADS,
1863
+ docsLabel: t("choice.preset.designReview.docs"),
1864
+ },
1865
+ pointer: {
1866
+ summary: t("choice.preset.pointer.summary"),
1867
+ explanation: t("choice.preset.pointer.explanation"),
1868
+ docsUrl: DOCS_PAYLOADS,
1869
+ docsLabel: t("choice.preset.pointer.docs"),
1870
+ },
1871
+ };
1872
+ }
1873
+ function packageManagerWizardDetails() {
1874
+ return {
1875
+ npm: {
1876
+ summary: t("choice.packageManager.npm.summary"),
1877
+ explanation: t("choice.packageManager.npm.explanation"),
1878
+ docsUrl: "https://docs.npmjs.com/",
1879
+ docsLabel: t("choice.packageManager.npm.docs"),
1880
+ },
1881
+ pnpm: {
1882
+ summary: t("choice.packageManager.pnpm.summary"),
1883
+ explanation: t("choice.packageManager.pnpm.explanation"),
1884
+ docsUrl: "https://pnpm.io/",
1885
+ docsLabel: t("choice.packageManager.pnpm.docs"),
1886
+ },
1887
+ yarn: {
1888
+ summary: t("choice.packageManager.yarn.summary"),
1889
+ explanation: t("choice.packageManager.yarn.explanation"),
1890
+ docsUrl: "https://yarnpkg.com/getting-started/usage",
1891
+ docsLabel: t("choice.packageManager.yarn.docs"),
1892
+ },
1893
+ bun: {
1894
+ summary: t("choice.packageManager.bun.summary"),
1895
+ explanation: t("choice.packageManager.bun.explanation"),
1896
+ docsUrl: "https://bun.com/docs/pm/cli/install",
1897
+ docsLabel: t("choice.packageManager.bun.docs"),
1898
+ },
1899
+ };
1427
1900
  }
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
1901
  function agentOptionCatalog() {
1531
1902
  const catalogOption = (choiceOption, compatibility) => ({
1532
1903
  value: choiceOption.value,
@@ -1535,13 +1906,13 @@ function agentOptionCatalog() {
1535
1906
  details: choiceOption.details,
1536
1907
  ...(compatibility ?? {}),
1537
1908
  });
1538
- const targets = wizardChoices(targetPromptChoices(), targetWizardDetails);
1909
+ const targets = wizardChoices(targetPromptChoices(), targetWizardDetails());
1539
1910
  const senders = senderChoices.map((value) => {
1540
1911
  const compatibleTargets = targetChoices.filter((target) => senderPromptChoices(target).some((option) => option.value === value));
1541
1912
  const prompt = compatibleTargets
1542
1913
  .flatMap((target) => senderPromptChoices(target))
1543
1914
  .find((option) => option.value === value);
1544
- return catalogOption(wizardChoices([prompt], senderWizardDetails)[0], {
1915
+ return catalogOption(wizardChoices([prompt], senderWizardDetails())[0], {
1545
1916
  compatibleTargets,
1546
1917
  });
1547
1918
  });
@@ -1551,7 +1922,7 @@ function agentOptionCatalog() {
1551
1922
  : []));
1552
1923
  const firstPlan = compatiblePlans[0];
1553
1924
  const prompt = sdkPromptChoices(firstPlan.target, firstPlan.sender).find((option) => option.value === value);
1554
- return catalogOption(wizardChoices([prompt], sdkWizardDetails)[0], {
1925
+ return catalogOption(wizardChoices([prompt], sdkWizardDetails())[0], {
1555
1926
  compatiblePlans,
1556
1927
  });
1557
1928
  });
@@ -1563,7 +1934,7 @@ function agentOptionCatalog() {
1563
1934
  {
1564
1935
  key: "appName",
1565
1936
  flag: "--app-name",
1566
- label: "Projectnaam",
1937
+ label: t("field.appName"),
1567
1938
  kind: "text",
1568
1939
  requiredFor: ["new"],
1569
1940
  options: [],
@@ -1571,48 +1942,44 @@ function agentOptionCatalog() {
1571
1942
  {
1572
1943
  key: "target",
1573
1944
  flag: "--target",
1574
- label: "Projecttype",
1945
+ label: t("field.target"),
1575
1946
  kind: "select",
1576
1947
  options: targets.map((option) => catalogOption(option)),
1577
1948
  },
1578
1949
  {
1579
1950
  key: "sender",
1580
1951
  flag: "--sender",
1581
- label: "Telefoonzender",
1952
+ label: t("field.sender"),
1582
1953
  kind: "select",
1583
1954
  options: senders,
1584
1955
  },
1585
1956
  {
1586
1957
  key: "sdk",
1587
1958
  flag: "--sdk",
1588
- label: "UI-framework / SDK",
1959
+ label: t("field.sdk"),
1589
1960
  kind: "select",
1590
1961
  options: sdks,
1591
1962
  },
1592
1963
  {
1593
1964
  key: "preset",
1594
1965
  flag: "--preset",
1595
- label: "Startpunt",
1966
+ label: t("field.preset"),
1596
1967
  kind: "select",
1597
- options: wizardChoices(presetPromptChoices(), presetWizardDetails).map((option) => catalogOption(option)),
1968
+ options: wizardChoices(presetPromptChoices(), presetWizardDetails()).map((option) => catalogOption(option)),
1598
1969
  },
1599
1970
  {
1600
1971
  key: "packageManager",
1601
1972
  flag: "--package-manager",
1602
- label: "Pakketbeheerder",
1973
+ label: t("field.packageManager"),
1603
1974
  kind: "select",
1604
- options: wizardChoices(packageManagerPromptChoices(), packageManagerWizardDetails).map((option) => catalogOption(option)),
1975
+ options: wizardChoices(packageManagerPromptChoices(), packageManagerWizardDetails()).map((option) => catalogOption(option)),
1605
1976
  },
1606
1977
  {
1607
1978
  key: "apiBaseUrl",
1608
1979
  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
- })),
1980
+ label: t("field.apiBaseUrl"),
1981
+ kind: "text",
1982
+ options: [],
1616
1983
  },
1617
1984
  ],
1618
1985
  defaults: {
@@ -1624,7 +1991,7 @@ function agentOptionCatalog() {
1624
1991
  ]))),
1625
1992
  preset: "signature",
1626
1993
  packageManager: "npm",
1627
- apiBaseUrl: "https://<deployment>.convex.site",
1994
+ apiBaseUrl: DEFAULT_REMOTEDRAW_API_BASE_URL,
1628
1995
  },
1629
1996
  safety: {
1630
1997
  dryRunFlag: "--dry-run",
@@ -1634,11 +2001,6 @@ function agentOptionCatalog() {
1634
2001
  },
1635
2002
  };
1636
2003
  }
1637
- function apiBaseUrlForEndpoint(endpoint) {
1638
- if (endpoint === "local")
1639
- return "http://localhost:3210";
1640
- return "https://<deployment>.convex.site";
1641
- }
1642
2004
  export function projectSetupDefinition() {
1643
2005
  return {
1644
2006
  defaults: {
@@ -1648,52 +2010,8 @@ export function projectSetupDefinition() {
1648
2010
  sdk: "react",
1649
2011
  preset: "signature",
1650
2012
  packageManager: "npm",
1651
- apiEndpoint: "deployment",
1652
2013
  },
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
- ],
2014
+ fields: projectSetupFields(),
1697
2015
  normalize(values, changed) {
1698
2016
  const next = { ...values };
1699
2017
  const target = choice(next.target, targetChoices, "target");
@@ -1713,76 +2031,63 @@ export function projectSetupDefinition() {
1713
2031
  },
1714
2032
  review(values) {
1715
2033
  const plan = planFromWizardValues(values);
2034
+ const label = (key, fallback) => wizardChoiceLabel(key, values) ?? fallback;
1716
2035
  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],
2036
+ [t("field.project"), plan.appName],
2037
+ [t("field.folder"), `./${plan.slug}`],
2038
+ [t("field.target"), label("target", plan.target)],
2039
+ [t("field.sender"), label("sender", plan.sender)],
2040
+ [t("field.sdkShort"), label("sdk", plan.sdk)],
2041
+ [t("field.packageManager"), label("packageManager", plan.packageManager)],
2042
+ [t("field.apiBaseUrl"), plan.apiBaseUrl],
1725
2043
  ];
1726
2044
  },
1727
2045
  };
1728
2046
  }
2047
+ function projectSetupFields() {
2048
+ return [
2049
+ {
2050
+ key: "appName",
2051
+ label: t("field.appName"),
2052
+ kind: "text",
2053
+ validate: validateWizardProjectName,
2054
+ preview: (values) => values.appName.trim() === ""
2055
+ ? undefined
2056
+ : t("field.appName.preview", { slug: slugify(values.appName) }),
2057
+ },
2058
+ {
2059
+ key: "target",
2060
+ label: t("field.target"),
2061
+ kind: "select",
2062
+ choices: () => wizardChoices(targetPromptChoices(), targetWizardDetails()),
2063
+ },
2064
+ {
2065
+ key: "sender",
2066
+ label: t("field.sender"),
2067
+ kind: "select",
2068
+ choices: (values) => wizardChoices(senderPromptChoices(choice(values.target, targetChoices, "target")), senderWizardDetails()),
2069
+ },
2070
+ {
2071
+ key: "sdk",
2072
+ label: t("field.sdk"),
2073
+ kind: "select",
2074
+ choices: (values) => wizardChoices(sdkPromptChoices(choice(values.target, targetChoices, "target"), choice(values.sender, senderChoices, "sender")), sdkWizardDetails()),
2075
+ },
2076
+ {
2077
+ key: "packageManager",
2078
+ label: t("field.packageManager"),
2079
+ kind: "select",
2080
+ choices: () => wizardChoices(packageManagerPromptChoices(), packageManagerWizardDetails()),
2081
+ },
2082
+ ];
2083
+ }
2084
+ function wizardChoiceLabel(key, values) {
2085
+ const field = projectSetupFields().find((entry) => entry.key === key);
2086
+ return field?.choices?.(values).find((option) => option.value === values[key])
2087
+ ?.label;
2088
+ }
1729
2089
  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
- }));
2090
+ return value.trim() === "" ? t("field.appName.error") : undefined;
1786
2091
  }
1787
2092
  function planFromWizardValues(values) {
1788
2093
  const appName = values.appName.trim();
@@ -1797,11 +2102,11 @@ function planFromWizardValues(values) {
1797
2102
  sdk,
1798
2103
  preset: choice(values.preset, presetChoices, "preset"),
1799
2104
  packageManager: choice(values.packageManager, packageManagerChoices, "package-manager"),
1800
- apiBaseUrl: apiBaseUrlForEndpoint(choice(values.apiEndpoint, apiEndpointChoices, "api endpoint")),
2105
+ apiBaseUrl: DEFAULT_REMOTEDRAW_API_BASE_URL,
1801
2106
  };
1802
2107
  const nameError = validateProjectName(plan.appName);
1803
2108
  if (nameError)
1804
- throw new Error(nameError);
2109
+ throw cliError("MISSING_ARGUMENT", "field.appName.error");
1805
2110
  validatePlan(plan);
1806
2111
  return plan;
1807
2112
  }
@@ -1810,7 +2115,7 @@ function planFromArgs(parsed, options) {
1810
2115
  options.defaults?.appName ??
1811
2116
  options.defaultAppName;
1812
2117
  if (options.appNameRequired && !readString(parsed, "app-name")) {
1813
- throw new Error("--app-name is required.");
2118
+ throw cliError("MISSING_ARGUMENT", "error.appNameRequired");
1814
2119
  }
1815
2120
  const target = choice(readString(parsed, "target") ?? options.defaults?.target ?? "web", targetChoices, "target");
1816
2121
  const sender = choice(readString(parsed, "sender") ??
@@ -1825,7 +2130,7 @@ function planFromArgs(parsed, options) {
1825
2130
  "npm", packageManagerChoices, "package-manager");
1826
2131
  const apiBaseUrl = readString(parsed, "api-base-url") ??
1827
2132
  options.defaults?.apiBaseUrl ??
1828
- "https://<deployment>.convex.site";
2133
+ DEFAULT_REMOTEDRAW_API_BASE_URL;
1829
2134
  validatePlan({ target, sender, sdk });
1830
2135
  return {
1831
2136
  appName,
@@ -1854,22 +2159,26 @@ function defaultSdkForChoices(target, sender) {
1854
2159
  }
1855
2160
  function validatePlan(plan) {
1856
2161
  if (plan.sender === "own-ios" && plan.sdk !== "swift") {
1857
- throw new Error("--sender own-ios requires --sdk swift.");
2162
+ throw cliError("INVALID_COMBINATION", "error.plan.ownIosNeedsSwift");
1858
2163
  }
1859
2164
  if (plan.sdk === "swift" && plan.sender !== "own-ios") {
1860
- throw new Error("--sdk swift is only supported with --sender own-ios.");
2165
+ throw cliError("INVALID_COMBINATION", "error.plan.swiftNeedsOwnIos");
1861
2166
  }
1862
2167
  if (plan.target === "ios" && plan.sender !== "own-ios") {
1863
- throw new Error("--target ios requires --sender own-ios.");
2168
+ throw cliError("INVALID_COMBINATION", "error.plan.iosNeedsOwnIos");
1864
2169
  }
1865
2170
  if (plan.sender === "headless" && plan.sdk === "react") {
1866
- throw new Error("--sender headless requires --sdk js or --sdk headless.");
2171
+ throw cliError("INVALID_COMBINATION", "error.plan.headlessSenderSdk");
1867
2172
  }
1868
2173
  }
1869
2174
  function choice(value, choices, label) {
1870
2175
  if (choices.includes(value))
1871
2176
  return value;
1872
- throw new Error(`Unknown ${label} "${value}". Expected one of: ${choices.join(", ")}.`);
2177
+ throw cliError("INVALID_ARGUMENT", "error.unknownChoice", {
2178
+ label,
2179
+ value,
2180
+ expected: choices.join(", "),
2181
+ });
1873
2182
  }
1874
2183
  function resolveOutputDir(cwd, parsed, fallback) {
1875
2184
  const requestedPath = readString(parsed, "path") ?? parsed.positionals[0] ?? fallback ?? ".";
@@ -1891,6 +2200,11 @@ function newProjectFiles(plan) {
1891
2200
  files.set("tsconfig.json", reactTsconfig());
1892
2201
  files.set("vite.config.ts", reactViteConfig());
1893
2202
  }
2203
+ else if (plan.sdk !== "swift") {
2204
+ // Without this the starter shipped a TypeScript toolchain and nothing to
2205
+ // point it at, so `tsc` had no config and no way to resolve node globals.
2206
+ files.set("tsconfig.json", nodeTsconfig());
2207
+ }
1894
2208
  return files;
1895
2209
  }
1896
2210
  function initProjectFiles(plan, packageJsonAlreadyExists) {
@@ -1905,7 +2219,7 @@ function initProjectFiles(plan, packageJsonAlreadyExists) {
1905
2219
  files.set("src/remotedraw/createRemoteDrawSession.ts", reactBackendSessionTs(plan));
1906
2220
  files.set("src/remotedraw/RemoteDrawReceiver.tsx", reactReceiverTsx());
1907
2221
  if (plan.sender === "embedded-web") {
1908
- files.set("src/remotedraw/RemoteDrawEmbeddedSender.tsx", reactEmbeddedSenderTsx());
2222
+ files.set("src/remotedraw/useRemoteDrawSender.ts", reactSenderTsx());
1909
2223
  }
1910
2224
  }
1911
2225
  else if (plan.sdk === "svelte") {
@@ -1945,7 +2259,9 @@ async function assertProjectFilesWritable(files, outputDir, runtime, dryRun, for
1945
2259
  return;
1946
2260
  for (const relativePath of files.keys()) {
1947
2261
  if (await runtime.exists(path.join(outputDir, relativePath))) {
1948
- throw new Error(`Refusing to overwrite ${relativePath}. Pass --force to replace generated files.`);
2262
+ throw cliError("FILE_CONFLICT", "error.refuseOverwrite", {
2263
+ file: relativePath,
2264
+ });
1949
2265
  }
1950
2266
  }
1951
2267
  }
@@ -2014,25 +2330,30 @@ function hasDependency(packageJson, dependencyName) {
2014
2330
  return (isRecord(packageJson.dependencies) &&
2015
2331
  typeof packageJson.dependencies[dependencyName] === "string");
2016
2332
  }
2333
+ /**
2334
+ * The RemoteDraw package an sdk choice installs, if any. The js starter calls
2335
+ * the HTTP API with plain fetch and takes only the request/response contract
2336
+ * from `@remotedraw/protocol`; the headless and swift starters take nothing.
2337
+ */
2338
+ function sdkPackageName(sdk) {
2339
+ if (sdk === "react")
2340
+ return "@remotedraw/react";
2341
+ if (sdk === "svelte")
2342
+ return "@remotedraw/svelte";
2343
+ if (sdk === "js")
2344
+ return "@remotedraw/protocol";
2345
+ return undefined;
2346
+ }
2017
2347
  function dependenciesForPlan(plan) {
2348
+ const sdkPackage = sdkPackageName(plan.sdk);
2349
+ const dependencies = sdkPackage
2350
+ ? { [sdkPackage]: REMOTEDRAW_SDK_RANGE }
2351
+ : {};
2018
2352
  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
- };
2353
+ dependencies.react = "^19.0.0";
2354
+ dependencies["react-dom"] = "^19.0.0";
2034
2355
  }
2035
- return {};
2356
+ return dependencies;
2036
2357
  }
2037
2358
  function newPackageJson(plan) {
2038
2359
  const dependencies = dependenciesForPlan(plan);
@@ -2048,15 +2369,24 @@ function newPackageJson(plan) {
2048
2369
  },
2049
2370
  dependencies,
2050
2371
  devDependencies: {
2372
+ // src/remotedraw/createRemoteDrawSession.ts reads process.env, so the
2373
+ // starter cannot typecheck without node types.
2374
+ "@types/node": "^25.0.0",
2051
2375
  "@types/react": "^19.0.0",
2052
2376
  "@types/react-dom": "^19.0.0",
2053
- "@vitejs/plugin-react": "latest",
2377
+ "@vitejs/plugin-react": "^6.0.0",
2054
2378
  typescript: "^6.0.0",
2055
- vite: "latest",
2379
+ vite: "^8.0.0",
2056
2380
  },
2057
2381
  };
2058
2382
  }
2059
- return initPackageJson(plan);
2383
+ const manifest = initPackageJson(plan);
2384
+ if (plan.sdk === "swift")
2385
+ return manifest;
2386
+ return {
2387
+ ...manifest,
2388
+ scripts: { ...manifest.scripts, typecheck: "tsc --noEmit" },
2389
+ };
2060
2390
  }
2061
2391
  function initPackageJson(plan) {
2062
2392
  return {
@@ -2069,14 +2399,21 @@ function initPackageJson(plan) {
2069
2399
  "remotedraw:create-input": "remotedraw create-input --preset " + plan.preset,
2070
2400
  },
2071
2401
  dependencies: dependenciesForPlan(plan),
2072
- devDependencies: {
2073
- typescript: "^6.0.0",
2074
- },
2402
+ // The scripts above shell out to `remotedraw`, so the CLI has to be a real
2403
+ // dependency of the project instead of an assumed global install. The swift
2404
+ // starter writes no TypeScript, so it gets no TypeScript toolchain.
2405
+ devDependencies: plan.sdk === "swift"
2406
+ ? { "@remotedraw/cli": REMOTEDRAW_CLI_RANGE }
2407
+ : {
2408
+ "@remotedraw/cli": REMOTEDRAW_CLI_RANGE,
2409
+ "@types/node": "^25.0.0",
2410
+ typescript: "^6.0.0",
2411
+ },
2075
2412
  };
2076
2413
  }
2077
2414
  function configJson(plan) {
2078
2415
  return {
2079
- $schema: "https://remotedraw.app/schemas/remotedraw.config.json",
2416
+ $schema: "https://www.remotedraw.com/schemas/remotedraw.config.json",
2080
2417
  appName: plan.appName,
2081
2418
  environment: "dev",
2082
2419
  target: plan.target,
@@ -2088,7 +2425,7 @@ function configJson(plan) {
2088
2425
  }
2089
2426
  function envExample(plan) {
2090
2427
  return [
2091
- "# RemoteDraw API origin. Use your Convex site URL in production.",
2428
+ "# RemoteDraw API origin. The hosted RemoteDraw API; leave as-is unless RemoteDraw support tells you otherwise.",
2092
2429
  `REMOTEDRAW_API_BASE_URL=${plan.apiBaseUrl}`,
2093
2430
  "",
2094
2431
  "# Non-secret dashboard project identifier. remotedraw new/init fills this in.",
@@ -2176,7 +2513,7 @@ function reactReceiverTsx() {
2176
2513
  "import {",
2177
2514
  " RemoteDrawProvider,",
2178
2515
  " RemoteDrawReceiver,",
2179
- " SubmissionStatus,",
2516
+ " RemoteDrawSessionControls,",
2180
2517
  " createHttpReceiverClient,",
2181
2518
  '} from "@remotedraw/react";',
2182
2519
  'import type { CreateSessionResponse } from "@remotedraw/react";',
@@ -2201,36 +2538,130 @@ function reactReceiverTsx() {
2201
2538
  " receiver={receiver}",
2202
2539
  " >",
2203
2540
  ' <RemoteDrawReceiver aria-label="RemoteDraw receiver surface" />',
2204
- ' <SubmissionStatus metadataKeys={["externalId", "senderLabel"]} />',
2541
+ " <RemoteDrawSessionControls />",
2205
2542
  " </RemoteDrawProvider>",
2206
2543
  " );",
2207
2544
  "}",
2208
2545
  "",
2209
2546
  ].join("\n");
2210
2547
  }
2211
- function reactEmbeddedSenderTsx() {
2548
+ function reactSenderTsx() {
2549
+ // Headless on purpose: the ready-made web sender is the hosted /join page
2550
+ // (the QR / Universal Link target). This starter is for products that must
2551
+ // host the pad themselves — RemoteDraw transports normalized points while
2552
+ // the canvas, tools, and styling stay app-owned.
2212
2553
  return [
2213
- 'import { EmbeddedSender, createHttpSenderClient } from "@remotedraw/react";',
2214
- "",
2215
- "type RemoteDrawEmbeddedSenderPanelProps = {",
2216
- " apiBaseUrl: string;",
2217
- " joinTokenOrUrl: string;",
2218
- "};",
2554
+ 'import { useEffect, useMemo, useRef, useState } from "react";',
2555
+ "import {",
2556
+ " createHttpSenderClient,",
2557
+ " createPacedDraftQueue,",
2558
+ " draftPointsForTransport,",
2559
+ " joinTokenFromInput,",
2560
+ '} from "@remotedraw/react";',
2561
+ "import type {",
2562
+ " DraftInputUpdate,",
2563
+ " JoinSessionResponse,",
2564
+ " NormalizedPoint,",
2565
+ '} from "@remotedraw/react";',
2219
2566
  "",
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
- " />",
2567
+ "// Headless RemoteDraw sender. The hosted /join page is the ready-made",
2568
+ "// pad; use this hook when the drawing surface must live inside your own",
2569
+ "// page. Points are normalized 0..1; the canvas and tools stay yours.",
2570
+ "export function useRemoteDrawSender(",
2571
+ " apiBaseUrl: string,",
2572
+ " joinTokenOrUrl: string,",
2573
+ ") {",
2574
+ " const client = useMemo(",
2575
+ " () => createHttpSenderClient(apiBaseUrl),",
2576
+ " [apiBaseUrl],",
2233
2577
  " );",
2578
+ " const [joined, setJoined] = useState<JoinSessionResponse | null>(null);",
2579
+ " const [error, setError] = useState<string | null>(null);",
2580
+ " const joinedRef = useRef<JoinSessionResponse | null>(null);",
2581
+ " const sequenceRef = useRef(0);",
2582
+ " // Drafts are throttled and latest-only: never POST every pointer event.",
2583
+ " const draftQueue = useMemo(",
2584
+ " () =>",
2585
+ " createPacedDraftQueue<DraftInputUpdate>({",
2586
+ " intervalMs: 32,",
2587
+ " run: async (request) => {",
2588
+ " await client.updateDraft?.(request);",
2589
+ " },",
2590
+ " }),",
2591
+ " [client],",
2592
+ " );",
2593
+ "",
2594
+ " useEffect(() => {",
2595
+ " const joinToken = joinTokenFromInput(joinTokenOrUrl);",
2596
+ " if (!joinToken) {",
2597
+ ' setError("Join token is required.");',
2598
+ " return;",
2599
+ " }",
2600
+ " let cancelled = false;",
2601
+ " client",
2602
+ " .join?.({",
2603
+ " joinToken,",
2604
+ ' device: { platform: "web", displayName: "Embedded sender" },',
2605
+ " })",
2606
+ " .then((result) => {",
2607
+ " if (cancelled || !result) return;",
2608
+ " // Resuming a token must continue its sequence: the server drops",
2609
+ " // anything at or below the last sequence it has seen.",
2610
+ " sequenceRef.current = result.lastSequence ?? 0;",
2611
+ " joinedRef.current = result;",
2612
+ " setJoined(result);",
2613
+ " })",
2614
+ " .catch((caught: unknown) => {",
2615
+ " if (!cancelled) setError(String(caught));",
2616
+ " });",
2617
+ " return () => {",
2618
+ " cancelled = true;",
2619
+ " };",
2620
+ " }, [client, joinTokenOrUrl]);",
2621
+ "",
2622
+ " return {",
2623
+ " joined,",
2624
+ " error,",
2625
+ " /** Call on pointermove with the whole in-progress stroke. */",
2626
+ " previewDraft(points: NormalizedPoint[]) {",
2627
+ " const senderToken = joinedRef.current?.senderToken;",
2628
+ " if (!senderToken || points.length === 0) return;",
2629
+ " draftQueue.offer(() => ({",
2630
+ " senderToken,",
2631
+ " sequence: ++sequenceRef.current,",
2632
+ ' tool: "freehand" as const,',
2633
+ ' pointerType: "touch" as const,',
2634
+ " points: draftPointsForTransport(points),",
2635
+ " occurredAt: Date.now(),",
2636
+ " }));",
2637
+ " },",
2638
+ " /** Call once on pointer-up with a stable id per stroke. */",
2639
+ " async commitStroke(points: NormalizedPoint[], clientStrokeId: string) {",
2640
+ " const senderToken = joinedRef.current?.senderToken;",
2641
+ " if (!senderToken || points.length < 2) return;",
2642
+ " draftQueue.clear();",
2643
+ " await client.commitStroke?.({",
2644
+ " senderToken,",
2645
+ " clientStrokeId,",
2646
+ " sequence: ++sequenceRef.current,",
2647
+ ' tool: "freehand",',
2648
+ ' pointerType: "touch",',
2649
+ " points,",
2650
+ " occurredAt: Date.now(),",
2651
+ " });",
2652
+ " },",
2653
+ " /** Call when the sender-side workflow is complete. */",
2654
+ " async submit(metadata?: Record<string, string>) {",
2655
+ " const senderToken = joinedRef.current?.senderToken;",
2656
+ " if (!senderToken) return;",
2657
+ " await client.submit?.({",
2658
+ " senderToken,",
2659
+ " clientSubmissionId: crypto.randomUUID(),",
2660
+ " occurredAt: Date.now(),",
2661
+ " ...(metadata ? { metadata } : {}),",
2662
+ " });",
2663
+ " },",
2664
+ " };",
2234
2665
  "}",
2235
2666
  "",
2236
2667
  ].join("\n");
@@ -2308,11 +2739,24 @@ function svelteReceiverSvelte() {
2308
2739
  ].join("\n");
2309
2740
  }
2310
2741
  function rawHttpSessionTs(plan) {
2742
+ // The js starter declares @remotedraw/protocol, so it uses it: the contract
2743
+ // types are the whole reason that dependency is there. The headless starter
2744
+ // declares nothing and stays import-free.
2745
+ const typed = plan.sdk === "js";
2311
2746
  return [
2747
+ ...(typed
2748
+ ? [
2749
+ "import type {",
2750
+ " CreateSessionRequest,",
2751
+ " CreateSessionResponse,",
2752
+ '} from "@remotedraw/protocol";',
2753
+ "",
2754
+ ]
2755
+ : []),
2312
2756
  "const apiBaseUrl = process.env.REMOTEDRAW_API_BASE_URL;",
2313
2757
  "const apiKey = process.env.REMOTEDRAW_API_KEY;",
2314
2758
  "",
2315
- "export function createRemoteDrawSessionRequest() {",
2759
+ `export function createRemoteDrawSessionRequest()${typed ? ": CreateSessionRequest" : ""} {`,
2316
2760
  " return " +
2317
2761
  JSON.stringify(createSessionPayload({
2318
2762
  preset: plan.preset,
@@ -2322,7 +2766,7 @@ function rawHttpSessionTs(plan) {
2322
2766
  ";",
2323
2767
  "}",
2324
2768
  "",
2325
- "export async function createRemoteDrawSession() {",
2769
+ `export async function createRemoteDrawSession()${typed ? ": Promise<CreateSessionResponse>" : ""} {`,
2326
2770
  " if (!apiBaseUrl || !apiKey) {",
2327
2771
  ' throw new Error("Set REMOTEDRAW_API_BASE_URL and REMOTEDRAW_API_KEY in backend secrets.");',
2328
2772
  " }",
@@ -2336,7 +2780,9 @@ function rawHttpSessionTs(plan) {
2336
2780
  " body: JSON.stringify(createRemoteDrawSessionRequest()),",
2337
2781
  " });",
2338
2782
  " if (!response.ok) throw new Error(await response.text());",
2339
- " return await response.json();",
2783
+ typed
2784
+ ? " return (await response.json()) as CreateSessionResponse;"
2785
+ : " return await response.json();",
2340
2786
  "}",
2341
2787
  "",
2342
2788
  ].join("\n");
@@ -2546,6 +2992,9 @@ function reactTsconfig() {
2546
2992
  ' "target": "ES2022",',
2547
2993
  ' "useDefineForClassFields": true,',
2548
2994
  ' "lib": ["ES2022", "DOM", "DOM.Iterable"],',
2995
+ // vite/client declares the CSS side-effect import in src/main.tsx; node
2996
+ // covers the process.env reads in the backend session helper.
2997
+ ' "types": ["node", "vite/client"],',
2549
2998
  ' "allowJs": false,',
2550
2999
  ' "skipLibCheck": true,',
2551
3000
  ' "esModuleInterop": true,',
@@ -2563,6 +3012,26 @@ function reactTsconfig() {
2563
3012
  "",
2564
3013
  ].join("\n");
2565
3014
  }
3015
+ function nodeTsconfig() {
3016
+ return [
3017
+ "{",
3018
+ ' "compilerOptions": {',
3019
+ ' "target": "ES2022",',
3020
+ ' "lib": ["ES2022", "DOM"],',
3021
+ ' "types": ["node"],',
3022
+ ' "module": "ESNext",',
3023
+ ' "moduleResolution": "Bundler",',
3024
+ ' "strict": true,',
3025
+ ' "skipLibCheck": true,',
3026
+ ' "resolveJsonModule": true,',
3027
+ ' "isolatedModules": true,',
3028
+ ' "noEmit": true',
3029
+ " },",
3030
+ ' "include": ["src"]',
3031
+ "}",
3032
+ "",
3033
+ ].join("\n");
3034
+ }
2566
3035
  function reactViteConfig() {
2567
3036
  return [
2568
3037
  'import react from "@vitejs/plugin-react";',
@@ -2716,10 +3185,10 @@ function shellQuote(value) {
2716
3185
  function normalizedOrigin(value) {
2717
3186
  const parsed = new URL(value);
2718
3187
  if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
2719
- throw new Error("Expected an HTTP(S) API base URL.");
3188
+ throw cliError("INVALID_ARGUMENT", "error.expectedHttpOrigin");
2720
3189
  }
2721
3190
  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.");
3191
+ throw cliError("INVALID_ARGUMENT", "error.expectedBareOrigin");
2723
3192
  }
2724
3193
  return parsed.origin;
2725
3194
  }
@@ -2734,95 +3203,80 @@ function resolveAgentPath(value, runtime) {
2734
3203
  if (value.startsWith("~/")) {
2735
3204
  const home = runtime.env.HOME;
2736
3205
  if (home == null) {
2737
- throw new Error("Cannot expand ~ because HOME is not set.");
3206
+ throw cliError("INVALID_ARGUMENT", "error.noHome");
2738
3207
  }
2739
3208
  return path.join(home, value.slice(2));
2740
3209
  }
2741
3210
  return path.resolve(runtime.cwd, value);
2742
3211
  }
3212
+ /**
3213
+ * The skill a coding agent installs is the same file this repository
3214
+ * documents. `docs/agents/remotedraw/SKILL.md` is the single source; the
3215
+ * module below is generated from it by `bun run generate:skill`, and
3216
+ * tests/agent-skill.test.ts fails if the two drift apart again.
3217
+ */
2743
3218
  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");
3219
+ return AGENT_SKILL_MARKDOWN;
3220
+ }
3221
+ /**
3222
+ * Doctor used to read REMOTEDRAW_API_BASE_URL raw and call an unset value a
3223
+ * problem, which disagreed with login/whoami/new — those all fall back to the
3224
+ * production API. Resolving it the same way here keeps every command pointed at
3225
+ * one deployment, and reporting the source explains a non-production URL
3226
+ * instead of leaving the reader to guess where it came from.
3227
+ */
3228
+ function resolveDoctorApiBaseUrl(projectEnv, requested) {
3229
+ const explicit = requested ?? projectEnv.REMOTEDRAW_API_BASE_URL;
3230
+ const source = requested == null ? "project" : "flag";
3231
+ if (explicit == null || explicit.trim() === "") {
3232
+ return {
3233
+ state: "ok",
3234
+ url: DEFAULT_REMOTEDRAW_API_BASE_URL,
3235
+ source: "default",
3236
+ isProductionDefault: true,
3237
+ };
3238
+ }
3239
+ // `init --offline` writes the literal placeholder, so an unreplaced value is
3240
+ // an expected next step rather than a mistake.
3241
+ if (explicit.includes("<"))
3242
+ return { state: "placeholder", value: explicit };
3243
+ if (!isHttpOrigin(explicit)) {
3244
+ return { state: "invalid", value: explicit, source };
3245
+ }
3246
+ const url = normalizedOrigin(explicit);
3247
+ return {
3248
+ state: "ok",
3249
+ url,
3250
+ source,
3251
+ isProductionDefault: url === DEFAULT_REMOTEDRAW_API_BASE_URL,
3252
+ };
3253
+ }
3254
+ function doctorApiBaseUrlMessage(resolved) {
3255
+ if (resolved.state === "placeholder") {
3256
+ return t("doctor.baseUrl.placeholder", {
3257
+ value: resolved.value,
3258
+ defaultUrl: DEFAULT_REMOTEDRAW_API_BASE_URL,
3259
+ });
3260
+ }
3261
+ if (resolved.state === "invalid") {
3262
+ return t("doctor.baseUrl.invalid", {
3263
+ value: resolved.value,
3264
+ defaultUrl: DEFAULT_REMOTEDRAW_API_BASE_URL,
3265
+ });
3266
+ }
3267
+ if (resolved.source === "default") {
3268
+ return t("doctor.baseUrl.default", { url: resolved.url });
3269
+ }
3270
+ const origin = resolved.source === "flag"
3271
+ ? t("doctor.baseUrl.origin.flag")
3272
+ : t("doctor.baseUrl.origin.project");
3273
+ return resolved.isProductionDefault
3274
+ ? t("doctor.baseUrl.production", { url: resolved.url, origin })
3275
+ : t("doctor.baseUrl.override", {
3276
+ url: resolved.url,
3277
+ origin,
3278
+ defaultUrl: DEFAULT_REMOTEDRAW_API_BASE_URL,
3279
+ });
2826
3280
  }
2827
3281
  function isHttpOrigin(value) {
2828
3282
  if (value == null || value.includes("<"))
@@ -2863,24 +3317,27 @@ function planDefaultsForExample(example) {
2863
3317
  };
2864
3318
  }
2865
3319
  function scaffoldSummary(verb, plan, outputDir, written, dryRun) {
2866
- const nextCommandPrefix = dryRun ? "Would write" : "Wrote";
2867
3320
  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}:`,
3321
+ t("scaffold.headline", {
3322
+ verb,
3323
+ target: plan.target,
3324
+ dir: outputDir,
3325
+ }),
3326
+ "",
3327
+ t("scaffold.choices"),
3328
+ t("scaffold.choice.app", { value: plan.appName }),
3329
+ t("scaffold.choice.sdk", { value: plan.sdk }),
3330
+ t("scaffold.choice.sender", { value: plan.sender }),
3331
+ t("scaffold.choice.starter", { value: plan.preset }),
3332
+ "",
3333
+ dryRun ? t("scaffold.wouldWrite") : t("scaffold.wrote"),
2877
3334
  ...written.map((file) => ` ${file}`),
2878
3335
  "",
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.",
3336
+ t("word.next"),
3337
+ t("scaffold.next.install", { command: installCommand(plan.packageManager) }),
3338
+ t("scaffold.next.secrets"),
3339
+ t("scaffold.next.createInput"),
3340
+ t("scaffold.next.render"),
2884
3341
  ].join("\n");
2885
3342
  }
2886
3343
  function scaffoldJsonResult(command, plan, outputDir, files, parsed, provisioning, offline, dryRun) {
@@ -2898,16 +3355,12 @@ function scaffoldJsonResult(command, plan, outputDir, files, parsed, provisionin
2898
3355
  .map(([field, , value]) => ({ field, value }));
2899
3356
  const warnings = [
2900
3357
  ...(plan.apiBaseUrl.includes("<deployment>")
2901
- ? ["Replace the deployment API placeholder before making live requests."]
3358
+ ? [t("cloud.warning.placeholder")]
2902
3359
  : []),
2903
3360
  ...(offline
2904
- ? [
2905
- "Cloud provisioning was skipped; no dashboard project or API key was created.",
2906
- ]
3361
+ ? [t("cloud.warning.offline")]
2907
3362
  : dryRun
2908
- ? [
2909
- "Cloud authentication and provisioning were not executed during dry-run.",
2910
- ]
3363
+ ? [t("cloud.warning.dryRun")]
2911
3364
  : []),
2912
3365
  ];
2913
3366
  return {