@calo-design/cli 0.2.0 → 0.3.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 CHANGED
@@ -1,54 +1,57 @@
1
- # @calo-design/cli — one-line Calo design setup
1
+ # @calo-design/cli
2
2
 
3
- A tiny **installer** (not a runtime). It logs you in with your Calo email, sets up the
4
- [`calo-design`](https://github.com/Calo-Design/calo-design) Claude Code skill and the
5
- design-system packages, then gets out of the way — the Claude Code agent is the
6
- runtime, the skill is the guidance.
3
+ One-line setup for Calo design tooling. It's a small **installer**, not a runtime: it
4
+ logs you in with your Calo email, installs the `calo-design` Claude Code skill, and sets
5
+ up the design-system stack (`@calo/design-system` + `@calo/flows` + the React Native
6
+ peers) on your machine. After that it gets out of the way — Claude Code is the runtime,
7
+ the skill is the guidance.
7
8
 
8
- **No GitHub account, PAT, or SSH key needed.** Access is gated by your `@calo.app`
9
- email via the Calo broker, which mints a short-lived GitHub token at install time.
9
+ Access is gated by your `@calo.app` email through the Calo broker, which mints a
10
+ short-lived GitHub token at install time. No GitHub account, PAT, or SSH key needed.
10
11
 
11
- ## Use
12
+ Package: https://www.npmjs.com/package/@calo-design/cli
13
+
14
+ ## Setup
12
15
 
13
16
  ```bash
14
- npx @calo-design/cli login # one-time: enter your Calo email + the emailed code
15
- npx @calo-design/cli init # skill + @calo/design-system + @calo/flows + RN peers
17
+ npx @calo-design/cli login # one-time: your Calo email + the emailed code
18
+ npx @calo-design/cli init # skill + the shared ds/flows runtime, and a prototype here
16
19
  ```
17
20
 
18
- Then start a new Claude Code session and ask for a screen
19
- (e.g. *"build a Calo-native meal-swap screen"*).
21
+ `init` does two machine-level things every time: it installs the skill into
22
+ `~/.claude/skills/`, and it ensures the shared runtime at `~/.designchef/runtime/` — one
23
+ copy of `@calo/design-system`, `@calo/flows`, Expo and the peers, shared by every
24
+ prototype. Then, if the current folder is empty or an existing Expo project, it links a
25
+ prototype here that points at that runtime.
26
+
27
+ To start a prototype from inside a workspace folder (where the current directory isn't
28
+ empty), give it a name and `init` creates the subfolder for you:
20
29
 
21
30
  ```bash
22
- npx @calo-design/cli init --skip-packages # skill only
23
- npx @calo-design/cli update # re-install latest skill + re-pin the shared runtime
24
- npx @calo-design/cli logout # forget the saved session
31
+ npx @calo-design/cli init my-prototype # creates ./my-prototype/ and links it
32
+ cd my-prototype
33
+ npx expo start # press w for web, or scan the QR in Expo Go
25
34
  ```
26
35
 
27
- The login session is stored at `~/.designchef/session.json` (chmod 600) and refreshes
28
- automatically. Override the broker with `CALO_BROKER_URL` (used for local dev).
29
-
30
- ## Publish
31
-
32
- Share a prototype with the team via the [Calo Mirror](https://github.com/Calo-Design/calo-design-mirror).
33
- Run from **inside a standalone prototype folder** (a plain Expo app with a `src/app/` route dir):
36
+ Other commands:
34
37
 
35
38
  ```bash
36
- calo-design push --slug <slug> --title "<Title>" --owner "<name>" --message "<msg>"
39
+ npx @calo-design/cli init --skip-packages # skill only, no runtime
40
+ npx @calo-design/cli init --isolated # install the stack into THIS folder (no shared runtime)
41
+ npx @calo-design/cli update # refresh the skill + re-pin the shared runtime
42
+ npx @calo-design/cli logout # forget the saved session
37
43
  ```
38
44
 
39
- **No EAS or Tigris credentials needed** — just `calo-design login`. The broker vends the
40
- Expo token for the `eas update` and performs the registry write server-side. It does two things:
45
+ The login session lives at `~/.designchef/session.json` (chmod 600) and refreshes
46
+ automatically. Set `CALO_BROKER_URL` to point at a local broker for development.
41
47
 
42
- - `eas update --branch <slug>` — publishes the prototype's JS bundle to its own EAS Update
43
- channel in the shared project (creating the channel on first push; latest wins on re-push).
44
- Bundling runs locally; auth uses the broker-vended Expo token.
45
- - Hands the registry entry (and `screenshot.png` if present) to the broker, which upserts the
46
- Tigris `index.json` feed so the prototype shows up in the Mirror's browsable list.
48
+ ## Building a screen
47
49
 
48
- Use `--dry-run` to stage a managed copy and print what would happen, without publishing.
49
- `--direct` is the legacy path that publishes from your own local EAS + Tigris creds (maintainer/debug).
50
+ Start a Claude Code session in a prototype folder, load the skill with `/calo-design`,
51
+ and describe the screen for example, "build a Calo-native meal-swap screen." The skill
52
+ composes it from the design-system primitives.
50
53
 
51
- Fonts ship with `@calo/design-system`. Load them in your root layout:
54
+ Fonts ship with `@calo/design-system`; load them in your root layout:
52
55
 
53
56
  ```tsx
54
57
  import { useFonts } from "expo-font";
@@ -56,20 +59,44 @@ import { caloFonts } from "@calo/design-system";
56
59
  const [fontsLoaded] = useFonts(caloFonts);
57
60
  ```
58
61
 
62
+ ## Publishing to the Mirror
63
+
64
+ Run from inside a standalone prototype folder (a plain Expo app with a `src/app/` route
65
+ directory):
66
+
67
+ ```bash
68
+ calo-design push --slug <slug> --title "<Title>" --owner "<name>" --message "<msg>"
69
+ ```
70
+
71
+ No EAS or Tigris credentials needed — just `calo-design login`. The broker vends the Expo
72
+ token and writes the registry server-side. Under the hood it:
73
+
74
+ - runs `eas update --branch <slug>` to publish the prototype's JS bundle to its own EAS
75
+ Update channel in the shared project (created on first push; latest wins on re-push).
76
+ Bundling runs locally; auth uses the broker-vended Expo token.
77
+ - hands the registry entry (and `screenshot.png` if present) to the broker, which upserts
78
+ the Tigris `index.json` feed so the prototype appears in the Mirror's browsable list.
79
+
80
+ `--dry-run` stages a managed copy and prints what would happen without publishing.
81
+ `--direct` is the legacy path that publishes from your own local EAS + Tigris credentials
82
+ (maintainer/debug).
83
+
59
84
  ## How access works
60
85
 
61
86
  ```
62
- npx @calo-design/cli login → broker verifies @calo.app + emails OTP → session JWT (~/.designchef)
63
- npx @calo-design/cli init → broker mints a 1h read-only GitHub token → private npm installs
64
- calo-design push → broker vends the Expo token + writes the Mirror registry server-side
87
+ login → broker verifies @calo.app + emails a code → session JWT in ~/.designchef
88
+ init → broker mints a 1-hour read-only GitHub token → private installs
89
+ push → broker vends the Expo token + writes the Mirror registry server-side
65
90
  ```
66
91
 
67
- The broker (`../calo-broker`) is the only place the GitHub / Expo / Tigris secrets
68
- live. See its README for the one-time provisioning checklist.
92
+ The broker is the only place the GitHub / Expo / Tigris secrets live.
93
+
94
+ ## Local development
69
95
 
70
- ## Tests
96
+ Point the CLI at a local broker and run the client-spine test:
71
97
 
72
98
  ```bash
73
- # with a dev broker running (cd ../calo-broker && npm run dev):
99
+ cd ../calo-broker && npm run dev # one shell
100
+ CALO_BROKER_URL=http://localhost:8080 npx @calo-design/cli login # another shell
74
101
  BASE=http://localhost:8080 bash test/client-spine.sh
75
102
  ```
package/bin/cli.js CHANGED
@@ -16,6 +16,7 @@ const fs = require("node:fs");
16
16
  const os = require("node:os");
17
17
  const path = require("node:path");
18
18
  const { cmdPush } = require("./mirror-push");
19
+ const { cmdShare } = require("./share");
19
20
  const { cmdLogin, cmdLogout, ensureLoggedIn, githubToken } = require("./login");
20
21
 
21
22
  const ORG = "Calo-Design";
@@ -369,18 +370,20 @@ function isExistingProject() {
369
370
 
370
371
  // ---------------------------------------------------------------- commands
371
372
 
372
- function nextSteps({ linked } = {}) {
373
+ function nextSteps({ linked, cd } = {}) {
373
374
  log(c.b("\n✨ Done. In Claude Code — no restart needed:"));
374
- log(`\n ${c.b("1.")} Load the skill — type:`);
375
+ let n = 1;
376
+ if (cd) log(`\n ${c.b(n++ + ".")} Enter the prototype — run ${c.b(c.g("cd " + cd))}`);
377
+ log(`\n ${c.b(n++ + ".")} Load the skill — type:`);
375
378
  log(` ${c.b(c.g("/calo-design"))}`);
376
- log(`\n ${c.b("2.")} Create a prototype — describe the screen, e.g.:`);
379
+ log(`\n ${c.b(n++ + ".")} Create a prototype — describe the screen, e.g.:`);
377
380
  log(` ${c.b(c.g("build a Calo-native meal-swap screen"))}`);
378
- log(`\n ${c.b("3.")} Preview — run ${c.dim("npx expo start")} (press w for web, or scan the QR in Expo Go).`);
381
+ log(`\n ${c.b(n++ + ".")} Preview — run ${c.dim("npx expo start")} (press w for web, or scan the QR in Expo Go).`);
379
382
  if (linked) {
380
383
  log(c.dim(`\n This prototype shares one install at ${tilde(runtimeDir())} — \`calo-design update\` refreshes it for every prototype.`));
381
384
  log(c.dim(" (expo-doctor may flag dependency versions on linked projects; that's expected and harmless.)"));
382
385
  }
383
- log(c.dim(`\n Docs: https://github.com/${SKILL_REPO}`));
386
+ log(c.dim(`\n Docs: https://www.npmjs.com/package/@calo-design/cli`));
384
387
  }
385
388
 
386
389
  async function cmdInit() {
@@ -389,40 +392,63 @@ async function cmdInit() {
389
392
  if (has("--skip-packages")) return nextSteps({});
390
393
  if (has("--isolated")) { await installHere(); return nextSteps({}); }
391
394
 
392
- if (isLinkedProject()) {
393
- // Re-running in an already-linked prototype: ensure runtime + refresh the link.
395
+ // `init <name>`: create ./<name>/ and set the prototype up there, so you can spin
396
+ // one up from inside a workspace container without cd-ing into an empty folder first.
397
+ const targetName = args.slice(1).find((a) => !a.startsWith("-"));
398
+ if (targetName) {
399
+ const dir = path.resolve(process.cwd(), targetName);
400
+ fs.mkdirSync(dir, { recursive: true });
401
+ process.chdir(dir);
402
+ log(c.dim(`\n Prototype folder: ${tilde(dir)}`));
403
+ }
404
+ const cd = targetName;
405
+
406
+ // Machine setup runs every time, independent of this folder: the skill is installed
407
+ // above; ensure the shared runtime so @calo/design-system + @calo/flows live on this
408
+ // machine (one copy, shared by every prototype).
409
+ let runtimeReady = true;
410
+ try {
394
411
  await ensureRuntime({ force: false });
395
- linkProject(process.cwd());
396
- return nextSteps({ linked: true });
412
+ } catch (e) {
413
+ runtimeReady = false;
414
+ warn(`couldn't build the shared runtime (${e.message}).`);
415
+ }
416
+
417
+ // Per-folder prototype (a separate, optional step): link or scaffold one here.
418
+ if (isLinkedProject()) {
419
+ // Re-running in an already-linked prototype: refresh the link if the runtime is ok.
420
+ if (runtimeReady) linkProject(process.cwd());
421
+ else warn("shared runtime unavailable — left the existing link untouched.");
422
+ return nextSteps({ linked: true, cd });
397
423
  }
398
424
  if (isExistingProject()) {
399
425
  warn("existing project detected — installing the Calo stack here (not linking), to avoid clobbering your node_modules.");
400
426
  await installCaloDeps(process.cwd());
401
427
  ok("packages + peers installed in this project");
402
- return nextSteps({});
428
+ return nextSteps({ cd });
403
429
  }
404
430
  if (!dirIsScaffoldable()) {
405
- warn("this folder isn't empty and isn't an Expo project.");
406
- log(c.dim(" Run init in an empty folder to use the shared runtime, or `init --isolated` to install here."));
431
+ // Machine setup is done; we just won't scaffold a prototype into a non-empty,
432
+ // non-Expo folder (e.g. a workspace container). Say what's ready and how to proceed.
433
+ log("");
434
+ ok(`Machine setup complete — skill installed${runtimeReady ? ", ds + flows runtime ready" : ""}.`);
435
+ if (runtimeReady) log(c.dim(` ds + flows live at ${tilde(path.join(runtimeDir(), "node_modules", "@calo"))}, shared by every prototype.`));
436
+ warn("No prototype scaffolded here — this folder isn't empty and isn't an Expo project.");
437
+ log(` Create one with: ${c.b(c.g("npx @calo-design/cli init <name>"))} ${c.dim("(makes ./<name>/ and links it)")}`);
438
+ log(c.dim(" …or run init in an empty folder or an existing Expo project, or pass --isolated to install into this folder."));
407
439
  return;
408
440
  }
409
441
 
410
- // Fresh prototype folder → shared runtime + symlink, with graceful fallbacks.
411
- try {
412
- await ensureRuntime({ force: false });
413
- } catch (e) {
414
- warn(`couldn't build the shared runtime (${e.message}); falling back to a per-project install.`);
415
- await installHere();
416
- return nextSteps({});
417
- }
442
+ // Fresh prototype folder → link to the shared runtime, with a per-project fallback.
443
+ if (!runtimeReady) { await installHere(); return nextSteps({ cd }); }
418
444
  try {
419
445
  linkProject(process.cwd());
420
446
  } catch (e) {
421
447
  warn(`couldn't link to the shared runtime (${e.message}); falling back to a per-project install.`);
422
448
  await installHere();
423
- return nextSteps({});
449
+ return nextSteps({ cd });
424
450
  }
425
- nextSteps({ linked: true });
451
+ nextSteps({ linked: true, cd });
426
452
  }
427
453
 
428
454
  async function cmdUpdate() {
@@ -441,15 +467,20 @@ function help() {
441
467
  log(`${c.b("calo-design")} — one-line setup for Calo design tooling
442
468
 
443
469
  ${c.b("npx @calo-design/cli login")} log in with your Calo email (one-time; no GitHub account needed)
444
- ${c.b("npx @calo-design/cli init")} skill + a prototype linked to the shared runtime (empty folder, or an existing project)
445
- ${c.dim("init --isolated")} install the Calo stack into THIS project (no shared runtime)
446
- ${c.dim("init --skip-packages")} skill only
470
+ ${c.b("npx @calo-design/cli init")} skill + shared ds/flows runtime, and link a prototype in this folder
471
+ ${c.b("npx @calo-design/cli init <name>")} same, but scaffold the prototype in ./<name>/ (handy inside a workspace)
472
+ ${c.dim("init --isolated")} install the Calo stack into THIS folder (no shared runtime)
473
+ ${c.dim("init --skip-packages")} skill only (no runtime)
447
474
  ${c.dim("update")} refresh the skill + re-pin the shared runtime (all linked prototypes float to latest)
448
475
  ${c.dim("logout")} forget the saved Calo session
449
476
  ${c.dim("push")} publish THIS prototype to the Calo Mirror (login only — no EAS/Tigris creds needed)
450
477
  ${c.dim(" push --slug x --title \"…\" --owner \"…\" --screenshot path --dry-run --direct")}
478
+ ${c.dim("share")} publish THIS web prototype (React/Vite/CRA/Next) to Cloudflare Pages → share link
479
+ ${c.dim(" share --slug x --dir dist --build --project calo-prototypes --dry-run")}
451
480
 
452
481
  Login is required once before init; the session refreshes automatically.
482
+ init always installs the skill and ensures the shared ds/flows runtime on your machine;
483
+ scaffolding a prototype is a separate step (needs an empty folder, an Expo project, or a name).
453
484
  Prototypes share one install at ${c.dim(tilde(runtimeDir()))} (override with DESIGNCHEF_HOME).
454
485
  `);
455
486
  }
@@ -461,6 +492,7 @@ function help() {
461
492
  else if (cmd === "init") await cmdInit();
462
493
  else if (cmd === "update") await cmdUpdate();
463
494
  else if (cmd === "push") await cmdPush(args.slice(1));
495
+ else if (cmd === "share") await cmdShare(args.slice(1));
464
496
  else help();
465
497
  } catch (err) {
466
498
  log(`\n\x1b[31m✗\x1b[0m ${err.message}`);
package/bin/login.js CHANGED
@@ -155,6 +155,34 @@ async function easToken() {
155
155
  return r.token;
156
156
  }
157
157
 
158
+ // Hand a built web prototype (gzipped tarball) to the broker, which runs
159
+ // `wrangler pages deploy` server-side — its Cloudflare token never reaches the client —
160
+ // and returns the share URL. slug = Pages branch alias. Raw body (not JSON), so it
161
+ // doesn't use api().
162
+ async function publishWeb({ slug, project, tarball }) {
163
+ const session = await ensureSession();
164
+ let res;
165
+ try {
166
+ res = await fetch(BROKER + "/v1/publish-web", {
167
+ method: "POST",
168
+ headers: {
169
+ authorization: `Bearer ${session}`,
170
+ "content-type": "application/gzip",
171
+ "x-calo-slug": slug,
172
+ ...(project ? { "x-calo-project": project } : {}),
173
+ },
174
+ body: tarball,
175
+ });
176
+ } catch (e) {
177
+ throw new Error(`can't reach the Calo broker at ${BROKER} (${e.message})`);
178
+ }
179
+ const text = await res.text();
180
+ let json;
181
+ try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
182
+ if (!res.ok) throw new Error(json.error || `publish-web → HTTP ${res.status}`);
183
+ return json;
184
+ }
185
+
158
186
  // Hand a built registry entry (+ optional base64 screenshot) to the broker, which
159
187
  // performs the Tigris writes server-side. Returns the broker's JSON result.
160
188
  async function publishToMirror(payload) {
@@ -162,4 +190,4 @@ async function publishToMirror(payload) {
162
190
  return api("/v1/publish", payload, session);
163
191
  }
164
192
 
165
- module.exports = { cmdLogin, cmdLogout, ensureSession, ensureLoggedIn, githubToken, easToken, publishToMirror, BROKER, loadSession };
193
+ module.exports = { cmdLogin, cmdLogout, ensureSession, ensureLoggedIn, githubToken, easToken, publishWeb, publishToMirror, BROKER, loadSession };
@@ -487,10 +487,19 @@ async function cmdPush(args) {
487
487
  easRun(["update", "--branch", slug, "--message", message, "--environment", "production", "--non-interactive"], { cwd: stage });
488
488
  ok(`published EAS update → channel ${c.b(slug)}`);
489
489
  const screenshotBase64 = screenshot ? fs.readFileSync(screenshot).toString("base64") : undefined;
490
- const res = await publishToMirror({
491
- entry: { slug, title, owner, description, runtimeVersion: RUNTIME_VERSION },
492
- screenshotBase64,
493
- });
490
+ let res;
491
+ try {
492
+ res = await publishToMirror({
493
+ entry: { slug, title, owner, description, runtimeVersion: RUNTIME_VERSION },
494
+ screenshotBase64,
495
+ });
496
+ } catch (e) {
497
+ // eas update already published the bundle; only the registry listing failed.
498
+ throw new Error(
499
+ `EAS update for "${slug}" published, but the Mirror registry write failed: ${e.message}\n` +
500
+ ` The bundle is live on its channel — it just isn't listed yet. Re-run \`calo-design push\` to retry the listing (or \`--direct\` with local Tigris creds).`
501
+ );
502
+ }
494
503
  ok(`registry updated via broker${res && typeof res.count === "number" ? ` (${res.count} prototypes live)` : ""}`);
495
504
  }
496
505
 
package/bin/share.js ADDED
@@ -0,0 +1,170 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * `calo-design share` — publish THIS web prototype (a static React / Vite / CRA /
5
+ * Next-export build) to Cloudflare Pages and print a share link.
6
+ *
7
+ * Default: tar the build → POST it to the broker (/v1/publish-web) → the broker runs
8
+ * `wrangler pages deploy` server-side with its own token and returns the URL. Teammates
9
+ * need only `calo-design login` — no Cloudflare account, no wrangler, nothing to install.
10
+ *
11
+ * `--direct`: deploy from here with your own `wrangler login` (no broker) — for
12
+ * debugging or when the broker isn't configured. Mirrors `push --direct`.
13
+ *
14
+ * Either way each prototype is a Pages branch alias (slug) → a stable
15
+ * https://<slug>.calo-prototypes.pages.dev that updates on re-share. SPA deep links
16
+ * work via an injected `_redirects`.
17
+ */
18
+
19
+ const { spawnSync } = require("node:child_process");
20
+ const fs = require("node:fs");
21
+ const os = require("node:os");
22
+ const path = require("node:path");
23
+ const { publishWeb } = require("./login");
24
+
25
+ const PROJECT = process.env.CALO_PAGES_PROJECT || "calo-prototypes";
26
+ const aliasUrl = (slug, project) => `https://${slug}.${project}.pages.dev`;
27
+
28
+ const c = { dim: (s) => `\x1b[2m${s}\x1b[0m`, b: (s) => `\x1b[1m${s}\x1b[0m`, g: (s) => `\x1b[32m${s}\x1b[0m`, y: (s) => `\x1b[33m${s}\x1b[0m` };
29
+ const log = (s = "") => console.log(s);
30
+ const ok = (s) => log(`${c.g("✓")} ${s}`);
31
+ const warn = (s) => log(`${c.y("!")} ${s}`);
32
+ const tilde = (p) => p.replace(os.homedir(), "~");
33
+
34
+ // Supports both `--slug x` and `--slug=x`.
35
+ const flag = (args, name) => {
36
+ const hit = args.find((a) => a.startsWith(`--${name}=`));
37
+ if (hit) return hit.split("=").slice(1).join("=");
38
+ const i = args.indexOf(`--${name}`);
39
+ if (i >= 0 && args[i + 1] && !args[i + 1].startsWith("--")) return args[i + 1];
40
+ return undefined;
41
+ };
42
+ const has = (args, name) => args.includes(`--${name}`);
43
+
44
+ // Cloudflare Pages branch aliases must be a DNS label: [a-z0-9-], <=63 chars.
45
+ function slugify(s) {
46
+ return String(s || "")
47
+ .toLowerCase()
48
+ .replace(/[^a-z0-9-]+/g, "-")
49
+ .replace(/^-+|-+$/g, "")
50
+ .slice(0, 63);
51
+ }
52
+
53
+ function readPkg(root) {
54
+ try { return JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); } catch { return null; }
55
+ }
56
+
57
+ function run(bin, argv, opts = {}) {
58
+ const r = spawnSync(bin, argv, { stdio: "inherit", ...opts });
59
+ if (r.error) throw r.error;
60
+ if (typeof r.status === "number" && r.status !== 0) throw new Error(`${bin} ${argv.join(" ")} exited ${r.status}`);
61
+ return r;
62
+ }
63
+
64
+ // Run while capturing combined output (to parse the deploy URL) but still show it.
65
+ function runCapture(bin, argv, opts = {}) {
66
+ const r = spawnSync(bin, argv, { encoding: "utf8", ...opts });
67
+ if (r.error) throw r.error;
68
+ const out = (r.stdout || "") + (r.stderr || "");
69
+ process.stdout.write(out);
70
+ if (typeof r.status === "number" && r.status !== 0) throw new Error(`${bin} pages deploy exited ${r.status}`);
71
+ return out;
72
+ }
73
+
74
+ // Prefer an installed wrangler (project-local, then global); else npx. Only used by
75
+ // --direct — the default path needs no wrangler on this machine.
76
+ function wranglerCmd(root) {
77
+ const local = path.join(root, "node_modules", ".bin", "wrangler");
78
+ if (fs.existsSync(local)) return [local, []];
79
+ const probe = spawnSync("wrangler", ["--version"], { stdio: "ignore" });
80
+ if (!probe.error && probe.status === 0) return ["wrangler", []];
81
+ return ["npx", ["--yes", "wrangler@latest"]];
82
+ }
83
+
84
+ // Find the static build to deploy: an explicit dir, a framework output (Vite=dist,
85
+ // CRA=build, Next-export=out), building first if needed, or a plain static folder.
86
+ function resolveBuildDir(root, { build }) {
87
+ const candidates = ["dist", "build", "out"];
88
+ const hasIndex = (d) => fs.existsSync(path.join(d, "index.html"));
89
+ const pj = readPkg(root);
90
+ const hasBuildScript = pj && pj.scripts && pj.scripts.build;
91
+
92
+ const existing = candidates.map((d) => path.join(root, d)).find(hasIndex);
93
+ if (existing && !build) return existing;
94
+
95
+ if (hasBuildScript && (build || !existing)) {
96
+ log(c.b("\n[build] npm run build"));
97
+ run("npm", ["run", "build"], { cwd: root });
98
+ }
99
+ const built = candidates.map((d) => path.join(root, d)).find(hasIndex);
100
+ if (built) return built;
101
+
102
+ if (hasIndex(root)) return root; // already-static folder (no build tooling)
103
+
104
+ throw new Error("no build output found — looked for dist/, build/, out/ with an index.html. Build first, or pass --dir <folder>.");
105
+ }
106
+
107
+ // SPA fallback so client-side routes (React Router etc.) don't 404 on deep links /
108
+ // refresh. Respect a prototype's own _redirects if it ships one.
109
+ function ensureSpaRedirects(dir) {
110
+ const f = path.join(dir, "_redirects");
111
+ if (fs.existsSync(f)) return false;
112
+ fs.writeFileSync(f, "/* /index.html 200\n");
113
+ return true;
114
+ }
115
+
116
+ // Gzipped tarball of the build (relative entries), for the broker to extract + deploy.
117
+ function createTarball(buildDir) {
118
+ const tgz = path.join(os.tmpdir(), `calo-share-${path.basename(buildDir)}-${process.pid}.tgz`);
119
+ run("tar", ["-czf", tgz, "-C", buildDir, "."]);
120
+ return tgz;
121
+ }
122
+
123
+ async function cmdShare(args = []) {
124
+ const root = process.cwd();
125
+ const project = flag(args, "project") || PROJECT;
126
+ const slug = slugify(flag(args, "slug") || (readPkg(root) || {}).name || path.basename(root));
127
+ if (!slug) throw new Error("couldn't determine a slug — pass --slug <name>.");
128
+
129
+ log(c.b(`\nShare “${slug}” → Cloudflare Pages (${project})`));
130
+
131
+ const dirFlag = flag(args, "dir");
132
+ const buildDir = dirFlag ? path.resolve(root, dirFlag) : resolveBuildDir(root, { build: has(args, "build") });
133
+ if (!fs.existsSync(path.join(buildDir, "index.html"))) throw new Error(`no index.html in ${buildDir}`);
134
+ ok(`build dir: ${tilde(buildDir)}`);
135
+
136
+ if (ensureSpaRedirects(buildDir)) ok("added _redirects (SPA fallback: /* → /index.html 200)");
137
+
138
+ if (has(args, "direct")) return shareDirect({ buildDir, slug, project, args });
139
+
140
+ // Default: hand the build to the broker, which deploys server-side with its own token.
141
+ if (has(args, "dry-run")) { log(c.dim("\n--dry-run: stopping before upload.")); return; }
142
+ const tgz = createTarball(buildDir);
143
+ try {
144
+ const sizeMb = (fs.statSync(tgz).size / 1e6).toFixed(1);
145
+ log(c.b(`\n[upload] ${sizeMb} MB → broker → wrangler pages deploy`));
146
+ const r = await publishWeb({ slug, project, tarball: fs.readFileSync(tgz) });
147
+ log("");
148
+ ok(`Live: ${c.g(r.url || aliasUrl(slug, project))}`);
149
+ if (r.version) log(c.dim(` this version: ${r.version}`));
150
+ log(c.dim(` Re-run \`calo-design share\` to update “${slug}” in place. Share the link above.`));
151
+ } finally {
152
+ try { fs.rmSync(tgz); } catch {}
153
+ }
154
+ }
155
+
156
+ // --direct: deploy from this machine with the caller's own `wrangler login`.
157
+ function shareDirect({ buildDir, slug, project, args }) {
158
+ if (has(args, "dry-run")) { log(c.dim("\n--dry-run: stopping before deploy.")); return; }
159
+ warn("--direct: deploying with your local `wrangler login` (not via the broker)");
160
+ const [bin, pre] = wranglerCmd(process.cwd());
161
+ const argv = [...pre, "pages", "deploy", buildDir, "--project-name", project, "--branch", slug, "--commit-dirty=true"];
162
+ log(c.b(`\n[deploy] wrangler pages deploy → branch ${slug}`));
163
+ const out = runCapture(bin, argv);
164
+ const version = (out.match(/https:\/\/[a-z0-9]+\.[a-z0-9-]+\.pages\.dev/gi) || []).find((u) => !u.includes(`${slug}.`));
165
+ log("");
166
+ ok(`Live: ${c.g(aliasUrl(slug, project))}`);
167
+ if (version) log(c.dim(` this version: ${version}`));
168
+ }
169
+
170
+ module.exports = { cmdShare };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calo-design/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "One-line setup for Calo design tooling: logs in with your Calo email and installs the calo-design skill + design-system packages. No GitHub account needed.",
5
5
  "bin": { "calo-design": "bin/cli.js" },
6
6
  "files": ["bin"],