@camox/cli 0.42.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # Camox CLI
2
+
3
+ ## Runtime lookup and monorepos
4
+
5
+ Runtime-backed commands (`status`, `pages`, `blocks`, `layouts`, `env`, `files`,
6
+ and `logout`) accept `--cwd PATH`:
7
+
8
+ ```sh
9
+ camox pages list --cwd apps/site
10
+ camox status --cwd apps/site
11
+ camox files upload --cwd apps/site --file ./hero.webp --alt "Product screenshot"
12
+ ```
13
+
14
+ The CLI searches for `node_modules/.camox/runtime.json` starting in that directory
15
+ and walking upward. Without `--cwd`, lookup starts in the shell's current directory.
16
+ There is no workspace or child-directory discovery. Run the app's dev server or
17
+ build once to generate its runtime file.
18
+
19
+ Relative `--cwd` paths resolve against the shell's current directory. This option
20
+ only affects runtime lookup: other paths, such as `--file ./hero.webp`, still
21
+ resolve against the shell's current directory. Missing or non-directory paths fail
22
+ instead of falling back to the shell's directory. Environment selection is unchanged:
23
+ your development environment by default, with explicit `--production` where supported.
24
+
25
+ ## Page SEO
26
+
27
+ Use `pages get` to read metadata and `pages update` to edit it:
28
+
29
+ ```sh
30
+ camox pages get --path /about --json
31
+ camox pages update --path /about --meta-title "About us" --meta-description "Meet the team."
32
+ camox pages update --id 42 --meta-description ""
33
+ camox pages update --id 42 --ai-seo on
34
+ camox pages update --id 42 --ai-seo off
35
+ camox pages get --path /about --live
36
+ camox pages publish --path /about
37
+ ```
38
+
39
+ - Updates require exactly one of `--id` or `--path` and at least one update field.
40
+ Omitted fields are preserved; empty strings clear metadata.
41
+ - Setting either metadata field disables automatic SEO for **both** fields.
42
+ Manual metadata cannot be combined with `--ai-seo on`.
43
+ - `--ai-seo off` preserves current metadata. `--ai-seo on` schedules asynchronous
44
+ generation; the returned page contains current values, not necessarily generated
45
+ values. Use `pages get` to inspect them later.
46
+ - Changes affect the draft only. Publish separately to update live metadata.
47
+ `pages get --live` reads the published snapshot; without it, reads use the draft.
48
+ - Commands support `--project SLUG`, `--production`, and `--json`, with the usual
49
+ development-environment default and structured JSON output/errors. `--production`
50
+ selects the environment; it does not publish the draft.
51
+
52
+ See `camox pages update --help` for options and examples.
53
+
54
+ ## Media
55
+
56
+ Run `camox login` and start your project's dev server (or build once) so the CLI
57
+ can discover the project's runtime configuration.
58
+
59
+ ```sh
60
+ # Upload a local file with manual alt text.
61
+ camox files upload --file ./hero.webp --alt "Product screenshot" --json
62
+
63
+ # Download a remote file and store a copy in Camox.
64
+ camox files upload --url https://example.com/hero.webp --alt "Product screenshot"
65
+
66
+ # Optionally override the uploaded filename.
67
+ camox files upload --url https://example.com/download --filename hero.webp --ai-metadata off
68
+
69
+ # Inspect the returned ID and URL; use the ID in your block's media field.
70
+ camox files list
71
+ camox files get --id 123
72
+
73
+ camox files update --id 123 --filename hero.webp
74
+ camox files update --id 123 --alt "Updated description"
75
+ camox files update --id 123 --alt ""
76
+ camox files update --id 123 --ai-metadata on
77
+
78
+ # Replace the underlying file everywhere it is referenced, preserving its ID.
79
+ camox files update --id 123 --file ./new-hero.webp
80
+ camox files update --id 123 --url https://example.com/new-hero.webp
81
+
82
+ # Replace content and override metadata together.
83
+ camox files update --id 123 --file ./new-hero.webp --filename hero.webp --alt "New screenshot"
84
+ ```
85
+
86
+ - Upload requires exactly one of `--file` or `--url`. URLs are copied, not linked.
87
+ - `--alt` disables AI metadata, including when its value is an empty string.
88
+ It cannot be combined with `--ai-metadata on`. Manual edits are protected from
89
+ already-running AI jobs.
90
+ - Without metadata flags, raster-image uploads use automatic metadata. Other
91
+ file types do not. AI may update both the filename and alt text asynchronously.
92
+ - `--ai-metadata on` schedules generation for raster images; success does not mean
93
+ generation has completed. Use `files get` to inspect the current record.
94
+ - Update requires at least one of `--file`, `--url`, `--filename`, `--alt` or
95
+ `--ai-metadata`. `--file` and `--url` are mutually exclusive; without either,
96
+ only metadata changes. Renaming alone preserves the file ID, URL and AI setting.
97
+ - Replacing content preserves the file ID and all `_fileId` references in the
98
+ selected environment. The URL changes to avoid stale caches. Direct URLs in
99
+ stored block/repeatable-item content are migrated within that environment;
100
+ URLs hardcoded outside Camox cannot be rewritten.
101
+ - Replacements preserve omitted alt text and AI settings. The filename defaults
102
+ to the new source filename; `--filename` overrides it. Binary metadata and
103
+ explicit metadata changes are saved together. If AI remains enabled, raster
104
+ replacements schedule generation on the original file ID; other types skip
105
+ generation. Disable AI to prevent future automatic renaming or alt changes.
106
+ - Files are shared assets, not page drafts: replacing one affects every reference
107
+ to it in the selected environment. No page publish step is required.
108
+ - Failed replacements leave the original file unchanged and remove the unused
109
+ uploaded binary. After a network timeout, use `files get --id ID` before retrying
110
+ because the server may already have completed the operation.
111
+ - All commands support `--project SLUG`, `--production`, and `--json`. The default
112
+ environment is `dev:<your-email>`, not production. IDs are scoped to the selected
113
+ project and environment.
114
+ - Uploads return the stored file record, including `id`, `url`, `mimeType`, `alt`,
115
+ and `aiMetadataEnabled`. Output is JSON; errors are structured JSON on stderr.
116
+ - Files are limited to 100 MiB. URL downloads allow at most five redirects and
117
+ have a two-minute timeout; uploads have a separate two-minute timeout.
118
+ Downloads stream through a temporary file that is removed afterward.
119
+ - Only HTTP(S) URLs without embedded credentials are supported. Signed query
120
+ URLs work and are not included in download errors. Camox credentials are never
121
+ sent to the source URL. Remote authentication flags and batch uploads are not
122
+ supported.
123
+
124
+ See `camox files --help`, `camox files upload --help`, and
125
+ `camox files update --help` for command options.
package/dist/index.mjs CHANGED
@@ -12,9 +12,11 @@ import { execSync, spawn, spawnSync } from "node:child_process";
12
12
  import http from "node:http";
13
13
  import * as p from "@clack/prompts";
14
14
  import { log } from "@clack/prompts";
15
- import fs from "node:fs";
16
- import path from "node:path";
15
+ import fs, { openAsBlob } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import path, { basename, extname, join } from "node:path";
17
18
  import { ZodError, z } from "zod";
19
+ import { mkdtemp, open, rm, stat } from "node:fs/promises";
18
20
  import { fileURLToPath } from "node:url";
19
21
  import slugify from "slugify";
20
22
  //#region src/lib/auth.ts
@@ -188,11 +190,17 @@ const SIDECAR = path.join("node_modules", ".camox", "runtime.json");
188
190
  var RuntimeNotFoundError = class extends Error {
189
191
  cwd;
190
192
  constructor(cwd) {
191
- super(`No camox runtime found. Looked for "${SIDECAR}" walking up from ${cwd}. The camox vite plugin writes this file on dev/build — start your project's dev server (or run a build) once before invoking camox.`);
193
+ super(`No Camox runtime found. Looked for "${SIDECAR}" starting from ${cwd} and its parent directories. Change into your app directory or pass --cwd <app-path>. If you're already targeting the correct app, run its dev server or build once to generate the runtime file.`);
192
194
  this.cwd = cwd;
193
195
  this.name = "RuntimeNotFoundError";
194
196
  }
195
197
  };
198
+ var RuntimeDirectoryError = class extends Error {
199
+ constructor(directory) {
200
+ super(`Runtime lookup directory "${directory}" does not exist, is not a directory, or cannot be accessed. Check --cwd <app-path>.`);
201
+ this.name = "RuntimeDirectoryError";
202
+ }
203
+ };
196
204
  var RuntimeMalformedError = class extends Error {
197
205
  constructor(file, reason) {
198
206
  super(`Camox runtime sidecar at ${file} is malformed (${reason}). Re-run your project's dev server to regenerate it.`);
@@ -205,7 +213,13 @@ var RuntimeMalformedError = class extends Error {
205
213
  * that tell the user how to fix it.
206
214
  */
207
215
  function loadRuntime(cwd = process.cwd()) {
208
- let dir = cwd;
216
+ let dir = path.resolve(cwd);
217
+ try {
218
+ if (!fs.statSync(dir).isDirectory()) throw new RuntimeDirectoryError(dir);
219
+ } catch {
220
+ throw new RuntimeDirectoryError(dir);
221
+ }
222
+ const start = dir;
209
223
  while (true) {
210
224
  const candidate = path.join(dir, SIDECAR);
211
225
  if (fs.existsSync(candidate)) {
@@ -223,7 +237,7 @@ function loadRuntime(cwd = process.cwd()) {
223
237
  }
224
238
  }
225
239
  const parent = path.dirname(dir);
226
- if (parent === dir) throw new RuntimeNotFoundError(cwd);
240
+ if (parent === dir) throw new RuntimeNotFoundError(start);
227
241
  dir = parent;
228
242
  }
229
243
  }
@@ -239,7 +253,7 @@ function compact(input) {
239
253
  for (const [k, v] of Object.entries(input)) if (v !== void 0) out[k] = v;
240
254
  return out;
241
255
  }
242
- function fail(err, code) {
256
+ function fail$1(err, code) {
243
257
  printError(err);
244
258
  process.exit(code);
245
259
  }
@@ -247,7 +261,7 @@ async function resolveProjectId(token, slug, apiUrl) {
247
261
  try {
248
262
  return (await getProjectBySlug(token, slug, apiUrl)).id;
249
263
  } catch (err) {
250
- return fail({
264
+ return fail$1({
251
265
  code: "PROJECT_LOOKUP_FAILED",
252
266
  message: `Could not load project "${slug}".`,
253
267
  details: err instanceof Error ? err.message : String(err)
@@ -258,7 +272,7 @@ async function callRemote(params) {
258
272
  try {
259
273
  return await callTool(params);
260
274
  } catch (err) {
261
- return fail(asCliError(err), 1);
275
+ return fail$1(asCliError(err), 1);
262
276
  }
263
277
  }
264
278
  /**
@@ -272,12 +286,16 @@ async function callRemote(params) {
272
286
  * from the sidecar — it's derived from auth (`dev:<email>`) by default,
273
287
  * with `--production` as the only opt-in for prod.
274
288
  */
275
- async function dispatch(opts) {
289
+ async function resolveCommandContext(opts) {
276
290
  let runtime;
277
291
  try {
278
- runtime = loadRuntime();
292
+ runtime = loadRuntime(opts.cwd);
279
293
  } catch (err) {
280
- if (err instanceof RuntimeNotFoundError || err instanceof RuntimeMalformedError) return fail({
294
+ if (err instanceof RuntimeDirectoryError) return fail$1({
295
+ code: "INVALID_CWD",
296
+ message: err.message
297
+ }, 2);
298
+ if (err instanceof RuntimeNotFoundError || err instanceof RuntimeMalformedError) return fail$1({
281
299
  code: "RUNTIME_NOT_FOUND",
282
300
  message: err.message
283
301
  }, 2);
@@ -285,24 +303,29 @@ async function dispatch(opts) {
285
303
  }
286
304
  const slug = opts.projectFlag?.trim() || process.env.CAMOX_PROJECT?.trim() || runtime.projectSlug;
287
305
  const token = readAuthTokenForUrl(runtime.authenticationUrl);
288
- if (!token) return fail({
306
+ if (!token) return fail$1({
289
307
  code: "NOT_AUTHENTICATED",
290
308
  message: `No stored credentials for ${runtime.authenticationUrl}. Run \`camox login\` against this backend first.`
291
309
  }, 2);
292
- if (!opts.production && !token.email) return fail({
310
+ if (!opts.production && !token.email) return fail$1({
293
311
  code: "AUTH_INCOMPLETE",
294
312
  message: "Auth token is missing an email. Run `camox login` to refresh credentials."
295
313
  }, 2);
296
314
  const environmentName = opts.production ? "production" : `dev:${token.email}`;
297
315
  const projectId = await resolveProjectId(token.token, slug, runtime.apiUrl);
298
- const response = await callRemote({
316
+ return {
299
317
  token: token.token,
300
318
  apiUrl: runtime.apiUrl,
301
319
  environmentName,
302
320
  projectId,
303
- name: opts.toolName,
304
- args: compact(opts.args),
305
321
  disableTelemetry: runtime.disableTelemetry
322
+ };
323
+ }
324
+ async function dispatch(opts) {
325
+ const response = await callRemote({
326
+ ...await resolveCommandContext(opts),
327
+ name: opts.toolName,
328
+ args: compact(opts.args)
306
329
  });
307
330
  if (!response.ok) {
308
331
  printError(response.error);
@@ -312,19 +335,24 @@ async function dispatch(opts) {
312
335
  process.exit(0);
313
336
  }
314
337
  //#endregion
338
+ //#region src/lib/runtime-options.ts
339
+ const cwdFlag = optional(option("--cwd", string({ metavar: "PATH" }), { description: message`Start runtime lookup in this directory and walk upward. Relative to the shell's current directory; does not change how other file paths resolve.` }));
340
+ //#endregion
315
341
  //#region src/commands/blocks.ts
316
342
  const projectFlag$3 = optional(option("--project", string({ metavar: "SLUG" })));
317
343
  const jsonFlag$3 = option("--json");
318
344
  const productionFlag$2 = option("--production");
319
345
  const liveFlag$1 = option("--live");
320
- const parser$8 = command("blocks", or(command("types", object({
346
+ const parser$9 = command("blocks", or(command("types", object({
321
347
  command: constant("blocks.types"),
348
+ cwd: cwdFlag,
322
349
  project: projectFlag$3,
323
350
  production: productionFlag$2,
324
351
  json: jsonFlag$3
325
352
  })), command("describe", object({
326
353
  command: constant("blocks.describe"),
327
354
  type: multiple(option("--type", string({ metavar: "TYPE" })), { min: 1 }),
355
+ cwd: cwdFlag,
328
356
  project: projectFlag$3,
329
357
  production: productionFlag$2,
330
358
  json: jsonFlag$3
@@ -332,6 +360,7 @@ const parser$8 = command("blocks", or(command("types", object({
332
360
  command: constant("blocks.get"),
333
361
  id: option("--id", integer({ metavar: "ID" })),
334
362
  live: liveFlag$1,
363
+ cwd: cwdFlag,
335
364
  project: projectFlag$3,
336
365
  production: productionFlag$2,
337
366
  json: jsonFlag$3
@@ -339,6 +368,7 @@ const parser$8 = command("blocks", or(command("types", object({
339
368
  command: constant("blocks.get-many"),
340
369
  id: multiple(option("--id", integer({ metavar: "ID" })), { min: 1 }),
341
370
  live: liveFlag$1,
371
+ cwd: cwdFlag,
342
372
  project: projectFlag$3,
343
373
  production: productionFlag$2,
344
374
  json: jsonFlag$3
@@ -353,6 +383,7 @@ const parser$8 = command("blocks", or(command("types", object({
353
383
  beforeId: optional(option("--before-id", integer({ metavar: "ID" }))),
354
384
  afterPosition: optional(option("--after-position", string({ metavar: "POS" }))),
355
385
  beforePosition: optional(option("--before-position", string({ metavar: "POS" }))),
386
+ cwd: cwdFlag,
356
387
  project: projectFlag$3,
357
388
  production: productionFlag$2,
358
389
  json: jsonFlag$3
@@ -361,6 +392,7 @@ const parser$8 = command("blocks", or(command("types", object({
361
392
  id: option("--id", integer({ metavar: "ID" })),
362
393
  content: optional(option("--content", string({ metavar: "JSON" }))),
363
394
  settings: optional(option("--settings", string({ metavar: "JSON" }))),
395
+ cwd: cwdFlag,
364
396
  project: projectFlag$3,
365
397
  production: productionFlag$2,
366
398
  json: jsonFlag$3
@@ -372,12 +404,14 @@ const parser$8 = command("blocks", or(command("types", object({
372
404
  beforeId: optional(option("--before-id", integer({ metavar: "ID" }))),
373
405
  afterPosition: optional(option("--after-position", string({ metavar: "POS" }))),
374
406
  beforePosition: optional(option("--before-position", string({ metavar: "POS" }))),
407
+ cwd: cwdFlag,
375
408
  project: projectFlag$3,
376
409
  production: productionFlag$2,
377
410
  json: jsonFlag$3
378
411
  })), command("delete", object({
379
412
  command: constant("blocks.delete"),
380
413
  id: option("--id", integer({ metavar: "ID" })),
414
+ cwd: cwdFlag,
381
415
  project: projectFlag$3,
382
416
  production: productionFlag$2,
383
417
  json: jsonFlag$3
@@ -408,8 +442,9 @@ function collectPositioningArgs(args) {
408
442
  toolArgs
409
443
  };
410
444
  }
411
- async function handler$8(args) {
445
+ async function handler$9(args) {
412
446
  const outputMode = args.json ? "json" : "auto";
447
+ const cwd = args.cwd;
413
448
  const projectFlag = args.project;
414
449
  const production = args.production;
415
450
  try {
@@ -417,6 +452,7 @@ async function handler$8(args) {
417
452
  case "blocks.types": return dispatch({
418
453
  toolName: "listBlockTypes",
419
454
  args: {},
455
+ cwd,
420
456
  projectFlag,
421
457
  production,
422
458
  outputMode
@@ -424,6 +460,7 @@ async function handler$8(args) {
424
460
  case "blocks.describe": return dispatch({
425
461
  toolName: "describeBlockTypes",
426
462
  args: { types: [...args.type] },
463
+ cwd,
427
464
  projectFlag,
428
465
  production,
429
466
  outputMode
@@ -434,6 +471,7 @@ async function handler$8(args) {
434
471
  id: args.id,
435
472
  source: args.live ? "live" : "draft"
436
473
  },
474
+ cwd,
437
475
  projectFlag,
438
476
  production,
439
477
  outputMode
@@ -444,6 +482,7 @@ async function handler$8(args) {
444
482
  ids: [...args.id],
445
483
  source: args.live ? "live" : "draft"
446
484
  },
485
+ cwd,
447
486
  projectFlag,
448
487
  production,
449
488
  outputMode
@@ -468,6 +507,7 @@ async function handler$8(args) {
468
507
  settings,
469
508
  ...toolArgs
470
509
  },
510
+ cwd,
471
511
  projectFlag,
472
512
  production,
473
513
  outputMode
@@ -483,6 +523,7 @@ async function handler$8(args) {
483
523
  content,
484
524
  settings
485
525
  },
526
+ cwd,
486
527
  projectFlag,
487
528
  production,
488
529
  outputMode
@@ -510,6 +551,7 @@ async function handler$8(args) {
510
551
  id: args.id,
511
552
  ...toolArgs
512
553
  },
554
+ cwd,
513
555
  projectFlag,
514
556
  production,
515
557
  outputMode
@@ -518,6 +560,7 @@ async function handler$8(args) {
518
560
  case "blocks.delete": return dispatch({
519
561
  toolName: "deleteBlock",
520
562
  args: { id: args.id },
563
+ cwd,
521
564
  projectFlag,
522
565
  production,
523
566
  outputMode
@@ -533,28 +576,33 @@ async function handler$8(args) {
533
576
  const projectFlag$2 = optional(option("--project", string({ metavar: "SLUG" })));
534
577
  const jsonFlag$2 = option("--json");
535
578
  const yesFlag = option("--yes", "-y");
536
- const parser$7 = command("env", or(command("check", object({
579
+ const parser$8 = command("env", or(command("check", object({
537
580
  command: constant("env.check"),
581
+ cwd: cwdFlag,
538
582
  project: projectFlag$2,
539
583
  json: jsonFlag$2
540
584
  })), command("push", object({
541
585
  command: constant("env.push"),
542
586
  yes: yesFlag,
587
+ cwd: cwdFlag,
543
588
  project: projectFlag$2,
544
589
  json: jsonFlag$2
545
590
  })), command("pull", object({
546
591
  command: constant("env.pull"),
547
592
  yes: yesFlag,
593
+ cwd: cwdFlag,
548
594
  project: projectFlag$2,
549
595
  json: jsonFlag$2
550
596
  }))));
551
- async function handler$7(args) {
597
+ async function handler$8(args) {
552
598
  const outputMode = args.json ? "json" : "auto";
599
+ const cwd = args.cwd;
553
600
  const projectFlag = args.project;
554
601
  switch (args.command) {
555
602
  case "env.check": return dispatch({
556
603
  toolName: "checkEnvironmentCompatibility",
557
604
  args: {},
605
+ cwd,
558
606
  projectFlag,
559
607
  outputMode
560
608
  });
@@ -571,12 +619,265 @@ async function handler$7(args) {
571
619
  return dispatch({
572
620
  toolName: "replicateEnvironment",
573
621
  args: { direction: isPush ? "push" : "pull" },
622
+ cwd,
574
623
  projectFlag,
575
624
  outputMode
576
625
  });
577
626
  }
578
627
  }
579
628
  }
629
+ const TIMEOUT_MS = 12e4;
630
+ const MAX_REDIRECTS = 5;
631
+ function fail(code, message) {
632
+ throw Object.assign(new Error(message), { __cliError: {
633
+ code,
634
+ message
635
+ } });
636
+ }
637
+ function validateMetadata(options) {
638
+ if (options.alt !== void 0 && options.aiMetadata === "on") fail("INVALID_ARGS", "--alt cannot be combined with --ai-metadata on.");
639
+ }
640
+ function validateUpload(options) {
641
+ if (options.file !== void 0 === (options.url !== void 0)) fail("INVALID_ARGS", "Pass exactly one of --file or --url.");
642
+ validateMetadata(options);
643
+ validateFilename(options.filename);
644
+ }
645
+ function validateFilename(filename) {
646
+ if (filename !== void 0 && (!filename.trim() || /[\\/]/.test(filename) || filename.split("").some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127) || filename === "." || filename === "..")) fail("INVALID_ARGS", "--filename must be a non-empty filename without directories or control characters.");
647
+ }
648
+ function httpUrl(value) {
649
+ let url;
650
+ try {
651
+ url = new URL(value);
652
+ } catch {
653
+ return fail("DOWNLOAD_FAILED", "Invalid media URL.");
654
+ }
655
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) fail("DOWNLOAD_FAILED", "Media URLs must use HTTP(S) without embedded credentials.");
656
+ return url;
657
+ }
658
+ const MIME_TYPES = {
659
+ ".jpg": "image/jpeg",
660
+ ".jpeg": "image/jpeg",
661
+ ".png": "image/png",
662
+ ".webp": "image/webp",
663
+ ".gif": "image/gif",
664
+ ".avif": "image/avif",
665
+ ".svg": "image/svg+xml",
666
+ ".ico": "image/x-icon",
667
+ ".bmp": "image/bmp",
668
+ ".pdf": "application/pdf",
669
+ ".mp4": "video/mp4",
670
+ ".webm": "video/webm",
671
+ ".mov": "video/quicktime",
672
+ ".mp3": "audio/mpeg",
673
+ ".wav": "audio/wav",
674
+ ".ogg": "audio/ogg",
675
+ ".txt": "text/plain",
676
+ ".json": "application/json"
677
+ };
678
+ function mimeType(filename) {
679
+ return MIME_TYPES[extname(filename).toLowerCase()] ?? "application/octet-stream";
680
+ }
681
+ async function download(source, destination) {
682
+ try {
683
+ let url = httpUrl(source);
684
+ const signal = AbortSignal.timeout(TIMEOUT_MS);
685
+ for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
686
+ const response = await fetch(url, {
687
+ redirect: "manual",
688
+ signal
689
+ });
690
+ if ([
691
+ 301,
692
+ 302,
693
+ 303,
694
+ 307,
695
+ 308
696
+ ].includes(response.status)) {
697
+ await response.body?.cancel();
698
+ const location = response.headers.get("location");
699
+ if (!location || redirects === MAX_REDIRECTS) fail("DOWNLOAD_FAILED", "Missing redirect location or too many redirects.");
700
+ url = httpUrl(new URL(location, url).href);
701
+ continue;
702
+ }
703
+ if (!response.ok || !response.body) {
704
+ await response.body?.cancel();
705
+ fail("DOWNLOAD_FAILED", `Media download failed (HTTP ${response.status}).`);
706
+ }
707
+ if (Number(response.headers.get("content-length")) > 104857600) {
708
+ await response.body.cancel();
709
+ fail("FILE_TOO_LARGE", "File exceeds the 100 MiB limit.");
710
+ }
711
+ const file = await open(destination, "w");
712
+ let size = 0;
713
+ try {
714
+ for await (const chunk of response.body) {
715
+ size += chunk.byteLength;
716
+ if (size > 104857600) fail("FILE_TOO_LARGE", "File exceeds the 100 MiB limit.");
717
+ await file.writeFile(chunk);
718
+ }
719
+ } finally {
720
+ await file.close();
721
+ }
722
+ const filename = basename(decodeURIComponent(url.pathname)) || "upload";
723
+ return {
724
+ filename,
725
+ mimeType: response.headers.get("content-type")?.split(";")[0]?.trim() || mimeType(filename)
726
+ };
727
+ }
728
+ return fail("DOWNLOAD_FAILED", "Too many redirects.");
729
+ } catch (error) {
730
+ if (error && typeof error === "object" && "__cliError" in error) throw error;
731
+ return fail("DOWNLOAD_FAILED", "Could not download media (network, timeout, or local I/O failure).");
732
+ }
733
+ }
734
+ async function uploadFile(context, options, targetId) {
735
+ validateUpload(options);
736
+ let temporaryDirectory;
737
+ try {
738
+ let path = options.file;
739
+ let filename = path ? basename(path) : "upload";
740
+ let type = mimeType(filename);
741
+ if (options.url !== void 0) {
742
+ temporaryDirectory = await mkdtemp(join(tmpdir(), "camox-upload-"));
743
+ path = join(temporaryDirectory, "download");
744
+ const downloaded = await download(options.url, path);
745
+ filename = downloaded.filename;
746
+ type = downloaded.mimeType;
747
+ }
748
+ if (!path) fail("INVALID_ARGS", "Provide a file path.");
749
+ const info = await stat(path);
750
+ if (!info.isFile()) fail("INVALID_ARGS", "Upload source must be a regular file.");
751
+ if (info.size > 104857600) fail("FILE_TOO_LARGE", "File exceeds the 100 MiB limit.");
752
+ const body = new FormData();
753
+ if (type === "application/octet-stream") type = mimeType(options.filename ?? filename);
754
+ body.set("file", await openAsBlob(path, { type }), options.filename ?? filename);
755
+ body.set("projectId", String(context.projectId));
756
+ if (options.alt !== void 0) body.set("alt", options.alt);
757
+ if (options.aiMetadata !== void 0 || options.alt !== void 0) body.set("aiMetadataEnabled", String(options.alt === void 0 && options.aiMetadata === "on"));
758
+ const headers = {
759
+ Authorization: `Bearer ${context.token}`,
760
+ "x-camox-client": "cli",
761
+ "x-environment-name": context.environmentName
762
+ };
763
+ if (context.disableTelemetry) headers["x-camox-telemetry-disabled"] = "1";
764
+ let response;
765
+ try {
766
+ const endpoint = targetId === void 0 ? "/files/upload" : `/files/${targetId}/content`;
767
+ response = await fetch(`${context.apiUrl}${endpoint}`, {
768
+ method: "POST",
769
+ headers,
770
+ body,
771
+ signal: AbortSignal.timeout(TIMEOUT_MS),
772
+ redirect: "error"
773
+ });
774
+ } catch {
775
+ return fail("UPLOAD_FAILED", targetId === void 0 ? "Media upload failed (network or timeout). Check files list before retrying." : `File replacement failed (network or timeout). Check files get --id ${targetId} before retrying.`);
776
+ }
777
+ if (!response.ok) fail("UPLOAD_FAILED", `${targetId === void 0 ? "Media upload" : "File replacement"} failed (HTTP ${response.status}).`);
778
+ return await response.json();
779
+ } finally {
780
+ if (temporaryDirectory) await rm(temporaryDirectory, {
781
+ recursive: true,
782
+ force: true
783
+ });
784
+ }
785
+ }
786
+ //#endregion
787
+ //#region src/commands/files.ts
788
+ const common = {
789
+ cwd: cwdFlag,
790
+ project: optional(option("--project", string({ metavar: "SLUG" }))),
791
+ production: option("--production"),
792
+ json: option("--json")
793
+ };
794
+ const metadata = {
795
+ alt: optional(option("--alt", string({ metavar: "TEXT" }), { description: message`Alt text (including an empty string). Disables automatic metadata.` })),
796
+ aiMetadata: optional(option("--ai-metadata", choice(["on", "off"]), { description: message`Enable or disable automatic metadata. Cannot enable alongside --alt. Enabling schedules generation for raster images.` }))
797
+ };
798
+ const parser$7 = command("files", or(command("upload", object({
799
+ command: constant("files.upload"),
800
+ file: optional(option("--file", string({ metavar: "PATH" }), { description: message`Local file to upload. Pass exactly one of --file or --url.` })),
801
+ url: optional(option("--url", string({ metavar: "URL" }), { description: message`HTTP(S) URL to download and copy into Camox, not an external reference.` })),
802
+ filename: optional(option("--filename", string({ metavar: "NAME" }), { description: message`Override the local basename or final URL path filename.` })),
803
+ ...metadata,
804
+ ...common
805
+ }), { description: message`Upload media (maximum 100 MiB). Example: camox files upload --url https://example.com/hero.webp --alt "Product screenshot". Returns the stored file ID and URL. Without metadata options, raster images use automatic metadata.` }), command("update", object({
806
+ command: constant("files.update"),
807
+ id: option("--id", integer({
808
+ min: 1,
809
+ metavar: "ID"
810
+ })),
811
+ file: optional(option("--file", string({ metavar: "PATH" }), { description: message`Replace file content from a local path, keeping its ID and references. Mutually exclusive with --url.` })),
812
+ url: optional(option("--url", string({ metavar: "URL" }), { description: message`Download and replace file content from an HTTP(S) URL, keeping its ID and references. Mutually exclusive with --file.` })),
813
+ filename: optional(option("--filename", string({ metavar: "NAME" }), { description: message`Set the filename. Metadata-only renames preserve the URL; replacement uses the source filename unless overridden. Disable AI to prevent future automatic renaming.` })),
814
+ ...metadata,
815
+ ...common
816
+ }), { description: message`Update metadata or replace file content with --file or --url (maximum 100 MiB). Replacing affects every reference to this file in the selected environment; its ID stays unchanged but its URL changes. Omitted alt text and AI settings are preserved. Example: camox files update --id 123 --file ./hero.jpg --alt "Updated description". Use --alt "" for intentionally empty alt text.` }), command("get", object({
817
+ command: constant("files.get"),
818
+ id: option("--id", integer({
819
+ min: 1,
820
+ metavar: "ID"
821
+ })),
822
+ ...common
823
+ }), { description: message`Inspect a file in the selected project and environment.` }), command("list", object({
824
+ command: constant("files.list"),
825
+ ...common
826
+ }), { description: message`List files in the selected project and environment.` })), { description: message`Upload and manage media. Defaults to your development environment; --production explicitly targets production.` });
827
+ async function handler$7(args) {
828
+ const outputMode = args.json ? "json" : "auto";
829
+ const options = {
830
+ cwd: args.cwd,
831
+ projectFlag: args.project,
832
+ production: args.production,
833
+ outputMode
834
+ };
835
+ try {
836
+ if (args.command === "files.upload") {
837
+ validateUpload(args);
838
+ printResult(await uploadFile(await resolveCommandContext(options), args), outputMode);
839
+ process.exit(0);
840
+ }
841
+ if (args.command === "files.list") return dispatch({
842
+ ...options,
843
+ toolName: "listFiles",
844
+ args: {}
845
+ });
846
+ if (args.command === "files.get") return dispatch({
847
+ ...options,
848
+ toolName: "getFile",
849
+ args: { id: args.id }
850
+ });
851
+ if (args.file !== void 0 || args.url !== void 0) {
852
+ validateUpload(args);
853
+ printResult(await uploadFile(await resolveCommandContext(options), args, args.id), outputMode);
854
+ process.exit(0);
855
+ }
856
+ validateMetadata(args);
857
+ validateFilename(args.filename);
858
+ if (args.alt === void 0 && args.aiMetadata === void 0 && args.filename === void 0) {
859
+ printError({
860
+ code: "INVALID_ARGS",
861
+ message: "Pass --file, --url, --filename, --alt or --ai-metadata."
862
+ });
863
+ process.exit(2);
864
+ }
865
+ return dispatch({
866
+ ...options,
867
+ toolName: "updateFile",
868
+ args: {
869
+ id: args.id,
870
+ alt: args.alt,
871
+ filename: args.filename,
872
+ aiMetadataEnabled: args.aiMetadata === void 0 ? void 0 : args.aiMetadata === "on"
873
+ }
874
+ });
875
+ } catch (err) {
876
+ const error = asCliError(err);
877
+ printError(error);
878
+ process.exit(error.code === "INVALID_ARGS" ? 2 : 1);
879
+ }
880
+ }
580
881
  //#endregion
581
882
  //#region src/lib/agent-skills.ts
582
883
  function createAgentSkillLinks(targetDir) {
@@ -584,8 +885,10 @@ function createAgentSkillLinks(targetDir) {
584
885
  const claudeSkillsDir = path.join(targetDir, ".claude", "skills");
585
886
  fs.mkdirSync(agentsSkillsDir, { recursive: true });
586
887
  fs.mkdirSync(claudeSkillsDir, { recursive: true });
587
- fs.symlinkSync("../../node_modules/camox/skills/camox", path.join(agentsSkillsDir, "camox"));
588
- fs.symlinkSync("../../.agents/skills/camox", path.join(claudeSkillsDir, "camox"));
888
+ for (const skill of ["camox", "camoxify"]) {
889
+ fs.symlinkSync(`../../node_modules/camox/skills/${skill}`, path.join(agentsSkillsDir, skill));
890
+ fs.symlinkSync(`../../.agents/skills/${skill}`, path.join(claudeSkillsDir, skill));
891
+ }
589
892
  }
590
893
  //#endregion
591
894
  //#region src/lib/utils.ts
@@ -931,18 +1234,21 @@ const jsonFlag$1 = option("--json");
931
1234
  const productionFlag$1 = option("--production");
932
1235
  const parser$5 = command("layouts", or(command("list", object({
933
1236
  command: constant("layouts.list"),
1237
+ cwd: cwdFlag,
934
1238
  project: projectFlag$1,
935
1239
  production: productionFlag$1,
936
1240
  json: jsonFlag$1
937
1241
  }))));
938
1242
  async function handler$5(args) {
939
1243
  const outputMode = args.json ? "json" : "auto";
1244
+ const cwd = args.cwd;
940
1245
  const projectFlag = args.project;
941
1246
  const production = args.production;
942
1247
  switch (args.command) {
943
1248
  case "layouts.list": return dispatch({
944
1249
  toolName: "listLayouts",
945
1250
  args: {},
1251
+ cwd,
946
1252
  projectFlag,
947
1253
  production,
948
1254
  outputMode
@@ -966,15 +1272,18 @@ async function login() {
966
1272
  }
967
1273
  //#endregion
968
1274
  //#region src/commands/logout.ts
969
- const parser$3 = command("logout", object({ command: constant("logout") }));
1275
+ const parser$3 = command("logout", object({
1276
+ command: constant("logout"),
1277
+ cwd: cwdFlag
1278
+ }));
970
1279
  const handler$3 = logout;
971
- function logout() {
1280
+ function logout(args = {}) {
972
1281
  p.intro("camox logout");
973
1282
  let authenticationUrl = null;
974
1283
  try {
975
- authenticationUrl = loadRuntime().authenticationUrl;
1284
+ authenticationUrl = loadRuntime(args.cwd).authenticationUrl;
976
1285
  } catch (error) {
977
- if (error instanceof RuntimeMalformedError) {
1286
+ if (error instanceof RuntimeMalformedError || error instanceof RuntimeDirectoryError || args.cwd !== void 0 && error instanceof RuntimeNotFoundError) {
978
1287
  log.error(error.message);
979
1288
  return;
980
1289
  }
@@ -997,6 +1306,7 @@ const productionFlag = option("--production");
997
1306
  const liveFlag = option("--live");
998
1307
  const list = command("list", object({
999
1308
  command: constant("pages.list"),
1309
+ cwd: cwdFlag,
1000
1310
  project: projectFlag,
1001
1311
  production: productionFlag,
1002
1312
  json: jsonFlag
@@ -1006,6 +1316,7 @@ const get = command("get", object({
1006
1316
  id: optional(option("--id", integer({ metavar: "ID" }))),
1007
1317
  path: optional(option("--path", string({ metavar: "PATH" }))),
1008
1318
  live: liveFlag,
1319
+ cwd: cwdFlag,
1009
1320
  project: projectFlag,
1010
1321
  production: productionFlag,
1011
1322
  json: jsonFlag
@@ -1015,6 +1326,7 @@ const publish = command("publish", object({
1015
1326
  id: optional(option("--id", integer({ metavar: "ID" }))),
1016
1327
  path: optional(option("--path", string({ metavar: "PATH" }))),
1017
1328
  noLayout: option("--no-layout"),
1329
+ cwd: cwdFlag,
1018
1330
  project: projectFlag,
1019
1331
  production: productionFlag,
1020
1332
  json: jsonFlag
@@ -1023,6 +1335,7 @@ const unpublish = command("unpublish", object({
1023
1335
  command: constant("pages.unpublish"),
1024
1336
  id: optional(option("--id", integer({ metavar: "ID" }))),
1025
1337
  path: optional(option("--path", string({ metavar: "PATH" }))),
1338
+ cwd: cwdFlag,
1026
1339
  project: projectFlag,
1027
1340
  production: productionFlag,
1028
1341
  json: jsonFlag
@@ -1031,6 +1344,7 @@ const discardChanges = command("discard-changes", object({
1031
1344
  command: constant("pages.discard-changes"),
1032
1345
  id: optional(option("--id", integer({ metavar: "ID" }))),
1033
1346
  path: optional(option("--path", string({ metavar: "PATH" }))),
1347
+ cwd: cwdFlag,
1034
1348
  project: projectFlag,
1035
1349
  production: productionFlag,
1036
1350
  json: jsonFlag
@@ -1041,40 +1355,53 @@ const parser$2 = command("pages", or(list, get, command("create", object({
1041
1355
  pathSegment: option("--path-segment", string({ metavar: "SEGMENT" })),
1042
1356
  layoutId: option("--layout-id", integer({ metavar: "ID" })),
1043
1357
  parentPageId: optional(option("--parent-page-id", integer({ metavar: "ID" }))),
1358
+ cwd: cwdFlag,
1044
1359
  project: projectFlag,
1045
1360
  production: productionFlag,
1046
1361
  json: jsonFlag
1047
1362
  })), command("update", object({
1048
1363
  command: constant("pages.update"),
1049
- id: option("--id", integer({ metavar: "ID" })),
1364
+ id: optional(option("--id", integer({
1365
+ min: 1,
1366
+ metavar: "ID"
1367
+ }))),
1368
+ path: optional(option("--path", string({ metavar: "PATH" }))),
1369
+ metaTitle: optional(option("--meta-title", string({ metavar: "TEXT" }), { description: message`Set the SEO title. An empty string clears it. Disables automatic SEO for both fields.` })),
1370
+ metaDescription: optional(option("--meta-description", string({ metavar: "TEXT" }), { description: message`Set the SEO description. An empty string clears it. Disables automatic SEO for both fields.` })),
1371
+ aiSeo: optional(option("--ai-seo", choice(["on", "off"]), { description: message`Enable or disable automatic SEO. Enabling schedules asynchronous generation; it cannot be combined with manual metadata. Disabling preserves current metadata.` })),
1050
1372
  nickname: optional(option("--nickname", string({ metavar: "TEXT" }))),
1051
1373
  pathSegment: optional(option("--path-segment", string({ metavar: "SEGMENT" }))),
1052
1374
  parentPageId: optional(option("--parent-page-id", integer({ metavar: "ID" }))),
1375
+ cwd: cwdFlag,
1053
1376
  project: projectFlag,
1054
1377
  production: productionFlag,
1055
1378
  json: jsonFlag
1056
- })), command("set-layout", object({
1379
+ }), { description: message`Update a page draft, including in --production. Pass exactly one of --id or --path and at least one update field. Omitted fields are preserved. Publish separately with pages publish. Example: camox pages update --path /about --meta-title "About us" --meta-description "Meet the team". Read metadata with pages get; add --live for published metadata.` }), command("set-layout", object({
1057
1380
  command: constant("pages.set-layout"),
1058
1381
  id: option("--id", integer({ metavar: "ID" })),
1059
1382
  layoutId: option("--layout-id", integer({ metavar: "ID" })),
1383
+ cwd: cwdFlag,
1060
1384
  project: projectFlag,
1061
1385
  production: productionFlag,
1062
1386
  json: jsonFlag
1063
1387
  })), command("delete", object({
1064
1388
  command: constant("pages.delete"),
1065
1389
  id: option("--id", integer({ metavar: "ID" })),
1390
+ cwd: cwdFlag,
1066
1391
  project: projectFlag,
1067
1392
  production: productionFlag,
1068
1393
  json: jsonFlag
1069
1394
  })), publish, unpublish, discardChanges));
1070
1395
  async function handler$2(args) {
1071
1396
  const outputMode = args.json ? "json" : "auto";
1397
+ const cwd = args.cwd;
1072
1398
  const projectFlag = args.project;
1073
1399
  const production = args.production;
1074
1400
  switch (args.command) {
1075
1401
  case "pages.list": return dispatch({
1076
1402
  toolName: "listPages",
1077
1403
  args: {},
1404
+ cwd,
1078
1405
  projectFlag,
1079
1406
  production,
1080
1407
  outputMode
@@ -1093,6 +1420,7 @@ async function handler$2(args) {
1093
1420
  ...args.id != null ? { id: args.id } : { path: args.path },
1094
1421
  source: args.live ? "live" : "draft"
1095
1422
  },
1423
+ cwd,
1096
1424
  projectFlag,
1097
1425
  production,
1098
1426
  outputMode
@@ -1105,28 +1433,63 @@ async function handler$2(args) {
1105
1433
  layoutId: args.layoutId,
1106
1434
  parentPageId: args.parentPageId
1107
1435
  },
1436
+ cwd,
1108
1437
  projectFlag,
1109
1438
  production,
1110
1439
  outputMode
1111
1440
  });
1112
- case "pages.update": return dispatch({
1113
- toolName: "updatePage",
1114
- args: {
1115
- id: args.id,
1116
- nickname: args.nickname,
1117
- pathSegment: args.pathSegment,
1118
- parentPageId: args.parentPageId
1119
- },
1120
- projectFlag,
1121
- production,
1122
- outputMode
1123
- });
1441
+ case "pages.update":
1442
+ if (args.id == null === (args.path == null)) {
1443
+ printError({
1444
+ code: "INVALID_ARGS",
1445
+ message: "Pass exactly one of --id or --path."
1446
+ });
1447
+ process.exit(2);
1448
+ }
1449
+ if (args.aiSeo === "on" && (args.metaTitle !== void 0 || args.metaDescription !== void 0)) {
1450
+ printError({
1451
+ code: "INVALID_ARGS",
1452
+ message: "Cannot enable automatic SEO alongside manual metadata."
1453
+ });
1454
+ process.exit(2);
1455
+ }
1456
+ if ([
1457
+ args.nickname,
1458
+ args.pathSegment,
1459
+ args.parentPageId,
1460
+ args.metaTitle,
1461
+ args.metaDescription,
1462
+ args.aiSeo
1463
+ ].every((value) => value === void 0)) {
1464
+ printError({
1465
+ code: "INVALID_ARGS",
1466
+ message: "Pass at least one field to update."
1467
+ });
1468
+ process.exit(2);
1469
+ }
1470
+ return dispatch({
1471
+ toolName: "updatePage",
1472
+ args: {
1473
+ ...args.id != null ? { id: args.id } : { path: args.path },
1474
+ metaTitle: args.metaTitle,
1475
+ metaDescription: args.metaDescription,
1476
+ aiSeoEnabled: args.aiSeo === void 0 ? void 0 : args.aiSeo === "on",
1477
+ nickname: args.nickname,
1478
+ pathSegment: args.pathSegment,
1479
+ parentPageId: args.parentPageId
1480
+ },
1481
+ cwd,
1482
+ projectFlag,
1483
+ production,
1484
+ outputMode
1485
+ });
1124
1486
  case "pages.set-layout": return dispatch({
1125
1487
  toolName: "setPageLayout",
1126
1488
  args: {
1127
1489
  id: args.id,
1128
1490
  layoutId: args.layoutId
1129
1491
  },
1492
+ cwd,
1130
1493
  projectFlag,
1131
1494
  production,
1132
1495
  outputMode
@@ -1134,6 +1497,7 @@ async function handler$2(args) {
1134
1497
  case "pages.delete": return dispatch({
1135
1498
  toolName: "deletePage",
1136
1499
  args: { id: args.id },
1500
+ cwd,
1137
1501
  projectFlag,
1138
1502
  production,
1139
1503
  outputMode
@@ -1152,6 +1516,7 @@ async function handler$2(args) {
1152
1516
  ...args.id != null ? { id: args.id } : { path: args.path },
1153
1517
  alsoPublishLayout: !args.noLayout
1154
1518
  },
1519
+ cwd,
1155
1520
  projectFlag,
1156
1521
  production,
1157
1522
  outputMode
@@ -1167,6 +1532,7 @@ async function handler$2(args) {
1167
1532
  return dispatch({
1168
1533
  toolName: "unpublishPage",
1169
1534
  args: args.id != null ? { id: args.id } : { path: args.path },
1535
+ cwd,
1170
1536
  projectFlag,
1171
1537
  production,
1172
1538
  outputMode
@@ -1182,6 +1548,7 @@ async function handler$2(args) {
1182
1548
  return dispatch({
1183
1549
  toolName: "discardPageChanges",
1184
1550
  args: args.id != null ? { id: args.id } : { path: args.path },
1551
+ cwd,
1185
1552
  projectFlag,
1186
1553
  production,
1187
1554
  outputMode
@@ -1220,14 +1587,22 @@ function handler$1() {
1220
1587
  //#region src/commands/status.ts
1221
1588
  const parser = command("status", object({
1222
1589
  command: constant("status"),
1590
+ cwd: cwdFlag,
1223
1591
  production: option("--production"),
1224
1592
  json: option("--json")
1225
1593
  }));
1226
1594
  async function handler(args) {
1227
1595
  let runtime;
1228
1596
  try {
1229
- runtime = loadRuntime();
1597
+ runtime = loadRuntime(args.cwd);
1230
1598
  } catch (err) {
1599
+ if (err instanceof RuntimeDirectoryError) {
1600
+ printError({
1601
+ code: "INVALID_CWD",
1602
+ message: err.message
1603
+ });
1604
+ process.exit(2);
1605
+ }
1231
1606
  if (err instanceof RuntimeNotFoundError || err instanceof RuntimeMalformedError) {
1232
1607
  printError({
1233
1608
  code: "RUNTIME_NOT_FOUND",
@@ -1267,7 +1642,7 @@ async function handler(args) {
1267
1642
  //#endregion
1268
1643
  //#region src/index.ts
1269
1644
  const result = runSync(defineProgram({
1270
- parser: or(parser$6, parser$4, parser$3, parser$1, parser, parser$2, parser$8, parser$5, parser$7),
1645
+ parser: or(parser$6, parser$4, parser$3, parser$1, parser, parser$2, parser$9, parser$5, parser$8, parser$7),
1271
1646
  metadata: {
1272
1647
  name: "camox",
1273
1648
  brief: message`Camox CLI`
@@ -1281,7 +1656,7 @@ switch (result.command) {
1281
1656
  await handler$4();
1282
1657
  break;
1283
1658
  case "logout":
1284
- handler$3();
1659
+ handler$3(result);
1285
1660
  break;
1286
1661
  case "release":
1287
1662
  handler$1();
@@ -1308,7 +1683,13 @@ switch (result.command) {
1308
1683
  case "blocks.edit":
1309
1684
  case "blocks.move":
1310
1685
  case "blocks.delete":
1311
- await handler$8(result);
1686
+ await handler$9(result);
1687
+ break;
1688
+ case "files.upload":
1689
+ case "files.update":
1690
+ case "files.get":
1691
+ case "files.list":
1692
+ await handler$7(result);
1312
1693
  break;
1313
1694
  case "layouts.list":
1314
1695
  await handler$5(result);
@@ -1316,7 +1697,7 @@ switch (result.command) {
1316
1697
  case "env.check":
1317
1698
  case "env.push":
1318
1699
  case "env.pull":
1319
- await handler$7(result);
1700
+ await handler$8(result);
1320
1701
  break;
1321
1702
  }
1322
1703
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camox/cli",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "license": "MIT",
5
5
  "bin": {
6
6
  "camox": "./dist/index.mjs"
@@ -31,7 +31,7 @@
31
31
  "@types/node": "^24.12.4",
32
32
  "@typescript/native-preview": "7.0.0-dev.20260412.1",
33
33
  "vite-plus": "^0.1.21",
34
- "@camox/api-contract": "0.42.0"
34
+ "@camox/api-contract": "0.43.0"
35
35
  },
36
36
  "nx": {
37
37
  "tags": [
@@ -42,6 +42,7 @@
42
42
  "build": "node scripts/sync-templates.mjs && vp pack",
43
43
  "dev": "node scripts/sync-templates.mjs --watch --build",
44
44
  "lint": "vp lint",
45
+ "test": "vp pack && node --test test/*.test.mjs",
45
46
  "check": "tsgo --noEmit && vp lint --fix"
46
47
  }
47
48
  }
@@ -8,6 +8,7 @@
8
8
  "dev": "vp dev --port 7400",
9
9
  "start": "node .output/server/index.mjs",
10
10
  "build": "vp build",
11
+ "test:production": "node scripts/production-smoke.mjs",
11
12
  "release": "camox release",
12
13
  "serve": "vp preview",
13
14
  "lint": "vp lint",
@@ -0,0 +1,35 @@
1
+ # Production runtime smoke test
2
+
3
+ From the repository root:
4
+
5
+ ```sh
6
+ pnpm exec nx run camox:build
7
+ pnpm --filter @camox/template-default test:production
8
+ ```
9
+
10
+ The test builds a temporary copy of the starter using Nitro's Node middleware
11
+ preset, moves only `.output` into an isolated deployment, removes build-time
12
+ `node_modules` links, and serves a real HTTP request. It asserts that React/Base UI
13
+ can render HTML and Camox's OG renderer can produce a PNG. No Camox API, real auth,
14
+ application-level dependency tracing overrides, or direct Takumi dependency is
15
+ needed. A temporary home directory supplies fake build authentication without
16
+ modifying the developer's credentials. CI runs this test after building the SDK.
17
+
18
+ ## Issue #106 investigation
19
+
20
+ - **Takumi:** the original `createRequire(import.meta.url).resolve(...)` survives
21
+ bundling, but the WASM package/asset is absent from the deployment. The isolated
22
+ smoke test reproduced `Cannot find module '@takumi-rs/wasm/takumi_wasm_bg.wasm'`
23
+ before the fix. Camox now uses a static module import so Nitro's WASM loader
24
+ owns asset inclusion, including when dependencies are fully bundled. The
25
+ server-only guard in `createLayout` prevents this import reaching client builds.
26
+ - **tslib:** a package containing only `package.json` and `tslib.es6.mjs`, as
27
+ reported, reproduces Node's missing `tslib/modules/index.js` error: Node selects
28
+ that entry from the package exports. The complete upstream package is valid;
29
+ the partial traced copy is not. The current starter build did not independently
30
+ reproduce that partial copy. Camox now adds `tslib` to Nitro's `noExternals`,
31
+ keeping its existing ESM alias and removing reliance on partial package tracing.
32
+ Plugin tests cover preservation of user options, including `noExternals: true`.
33
+
34
+ These are SDK integration fixes, not starter workarounds. Applications should not
35
+ need to add Takumi as a direct dependency or configure `traceDeps`/`noExternals`.
@@ -0,0 +1,124 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawnSync } from "node:child_process";
3
+ import { cp, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
4
+ import { createRequire } from "node:module";
5
+ import { tmpdir } from "node:os";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
10
+ const temp = await mkdtemp(join(tmpdir(), "camox-production-"));
11
+
12
+ try {
13
+ const root = join(temp, "app");
14
+ const home = join(temp, "home");
15
+ await mkdir(join(home, ".camox"), { recursive: true });
16
+ await writeFile(
17
+ join(home, ".camox/auth.json"),
18
+ JSON.stringify({
19
+ "http://localhost:3274": { token: "smoke", name: "Smoke", email: "smoke@example.com" },
20
+ }),
21
+ );
22
+ await mkdir(root);
23
+ await mkdir(join(temp, "node_modules"));
24
+ // Nitro resolves its builder from the project root. Match Vite+'s own Vite
25
+ // dependency without relying on workspace hoisting in this temporary app.
26
+ const vitePackage = createRequire(import.meta.resolve("vite-plus")).resolve("vite/package.json");
27
+ await symlink(dirname(vitePackage), join(temp, "node_modules/vite"), "dir");
28
+ for (const file of ["src", "vite.config.ts", "tsconfig.json", "package.json"]) {
29
+ await cp(join(appRoot, file), join(root, file), { recursive: true });
30
+ }
31
+ await symlink(join(appRoot, "node_modules"), join(root, "node_modules"), "dir");
32
+ const configPath = join(root, "vite.config.ts");
33
+ await writeFile(
34
+ configPath,
35
+ (await readFile(configPath, "utf8")).replace(
36
+ "nitro()",
37
+ 'nitro({ routes: { "/smoke": "./routes/smoke.ts" } })',
38
+ ),
39
+ );
40
+ await mkdir(join(root, "routes"), { recursive: true });
41
+ await writeFile(
42
+ join(root, "routes/smoke.ts"),
43
+ `import { Dialog } from "@base-ui/react/dialog";
44
+ import { createElement } from "react";
45
+ import { renderToStaticMarkup } from "react-dom/server";
46
+ import { ImageResponse } from "camox/_internal/imageResponse";
47
+
48
+ export default async () => {
49
+ const element = createElement("div", { style: { color: "black", background: "white" } }, "Production smoke");
50
+ const image = new ImageResponse(element, { width: 320, height: 160 });
51
+ const bytes = new Uint8Array(await image.arrayBuffer());
52
+ const html = renderToStaticMarkup(createElement(Dialog.Root, null,
53
+ createElement(Dialog.Trigger, null, "Production smoke")));
54
+ return Response.json({ html, png: Array.from(bytes.slice(0, 8)) });
55
+ };
56
+ `,
57
+ );
58
+ const build = spawnSync(
59
+ process.execPath,
60
+ [
61
+ "--input-type=module",
62
+ "-e",
63
+ `
64
+ const { createBuilder } = await import(${JSON.stringify(import.meta.resolve("vite-plus"))});
65
+ const builder = await createBuilder({ root: ${JSON.stringify(root)} });
66
+ await builder.buildApp();
67
+ `,
68
+ ],
69
+ {
70
+ cwd: root,
71
+ env: {
72
+ ...process.env,
73
+ HOME: home,
74
+ CAMOX_INTERNAL_RELEASE: "0",
75
+ NITRO_PRESET: "node-middleware",
76
+ },
77
+ encoding: "utf8",
78
+ timeout: 180_000,
79
+ killSignal: "SIGKILL",
80
+ },
81
+ );
82
+ assert.equal(build.status, 0, `${build.stdout}\n${build.stderr}`);
83
+
84
+ // Move only the deployment artifact away from all workspace node_modules.
85
+ const deployment = join(temp, "deployment");
86
+ await cp(join(root, ".output"), deployment, { recursive: true });
87
+ await rm(root, { recursive: true, force: true });
88
+ await rm(join(temp, "node_modules"), { recursive: true, force: true });
89
+ const runtime = spawnSync(
90
+ process.execPath,
91
+ [
92
+ "--input-type=module",
93
+ "-e",
94
+ `
95
+ import assert from "node:assert/strict";
96
+ import { createServer } from "node:http";
97
+ const { middleware } = await import("./server/index.mjs");
98
+ const server = createServer(middleware);
99
+ await new Promise(resolve => server.listen(0, "127.0.0.1", resolve));
100
+ try {
101
+ const response = await fetch("http://127.0.0.1:" + server.address().port + "/smoke");
102
+ assert.equal(response.status, 200, await response.clone().text());
103
+ const result = await response.json();
104
+ assert.match(result.html, /Production smoke/);
105
+ assert.deepEqual(result.png, [137, 80, 78, 71, 13, 10, 26, 10]);
106
+ } finally {
107
+ server.closeAllConnections();
108
+ server.close();
109
+ }
110
+ `,
111
+ ],
112
+ {
113
+ cwd: deployment,
114
+ env: { ...process.env, NODE_PATH: "", NODE_ENV: "production" },
115
+ encoding: "utf8",
116
+ timeout: 30_000,
117
+ killSignal: "SIGKILL",
118
+ },
119
+ );
120
+ assert.equal(runtime.status, 0, `${runtime.stdout}\n${runtime.stderr}`);
121
+ console.log("Production server rendered HTML and a Takumi PNG from an isolated deployment.");
122
+ } finally {
123
+ await rm(temp, { recursive: true, force: true });
124
+ }