@calo-design/cli 0.2.1 → 0.4.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,67 @@
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
+ > **`@calo-design/cli` is a public npm package.** If npx reports it can't be found — "not in
22
+ > this registry", "404", or "not a public package" — your npm is pointed at a private or
23
+ > corporate registry that doesn't carry it (common on managed laptops). Check with
24
+ > `npm config get registry` (it should be `https://registry.npmjs.org/`). To run regardless
25
+ > of your `.npmrc`, pin just this scope to public npm:
26
+ >
27
+ > ```bash
28
+ > npx --@calo-design:registry=https://registry.npmjs.org/ @calo-design/cli login
29
+ > ```
30
+
31
+ `init` does two machine-level things every time: it installs the skill into
32
+ `~/.claude/skills/`, and it ensures the shared runtime at `~/.designchef/runtime/` — one
33
+ copy of `@calo/design-system`, `@calo/flows`, Expo and the peers, shared by every
34
+ prototype. Then, if the current folder is empty or an existing Expo project, it links a
35
+ prototype here that points at that runtime.
36
+
37
+ To start a prototype from inside a workspace folder (where the current directory isn't
38
+ empty), give it a name and `init` creates the subfolder for you:
20
39
 
21
40
  ```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
41
+ npx @calo-design/cli init my-prototype # creates ./my-prototype/ and links it
42
+ cd my-prototype
43
+ npx expo start # press w for web, or scan the QR in Expo Go
25
44
  ```
26
45
 
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):
46
+ Other commands:
34
47
 
35
48
  ```bash
36
- calo-design push --slug <slug> --title "<Title>" --owner "<name>" --message "<msg>"
49
+ npx @calo-design/cli init --skip-packages # skill only, no runtime
50
+ npx @calo-design/cli init --isolated # install the stack into THIS folder (no shared runtime)
51
+ npx @calo-design/cli update # refresh the skill + re-pin the shared runtime
52
+ npx @calo-design/cli logout # forget the saved session
37
53
  ```
38
54
 
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:
55
+ The login session lives at `~/.designchef/session.json` (chmod 600) and refreshes
56
+ automatically. Set `CALO_BROKER_URL` to point at a local broker for development.
41
57
 
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.
58
+ ## Building a screen
47
59
 
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).
60
+ Start a Claude Code session in a prototype folder, load the skill with `/calo-design`,
61
+ and describe the screen for example, "build a Calo-native meal-swap screen." The skill
62
+ composes it from the design-system primitives.
50
63
 
51
- Fonts ship with `@calo/design-system`. Load them in your root layout:
64
+ Fonts ship with `@calo/design-system`; load them in your root layout:
52
65
 
53
66
  ```tsx
54
67
  import { useFonts } from "expo-font";
@@ -56,20 +69,44 @@ import { caloFonts } from "@calo/design-system";
56
69
  const [fontsLoaded] = useFonts(caloFonts);
57
70
  ```
58
71
 
72
+ ## Publishing to the Mirror
73
+
74
+ Run from inside a standalone prototype folder (a plain Expo app with a `src/app/` route
75
+ directory):
76
+
77
+ ```bash
78
+ calo-design push --slug <slug> --title "<Title>" --owner "<name>" --message "<msg>"
79
+ ```
80
+
81
+ No EAS or Tigris credentials needed — just `calo-design login`. The broker vends the Expo
82
+ token and writes the registry server-side. Under the hood it:
83
+
84
+ - runs `eas update --branch <slug>` to publish the prototype's JS bundle to its own EAS
85
+ Update channel in the shared project (created on first push; latest wins on re-push).
86
+ Bundling runs locally; auth uses the broker-vended Expo token.
87
+ - hands the registry entry (and `screenshot.png` if present) to the broker, which upserts
88
+ the Tigris `index.json` feed so the prototype appears in the Mirror's browsable list.
89
+
90
+ `--dry-run` stages a managed copy and prints what would happen without publishing.
91
+ `--direct` is the legacy path that publishes from your own local EAS + Tigris credentials
92
+ (maintainer/debug).
93
+
59
94
  ## How access works
60
95
 
61
96
  ```
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
97
+ login → broker verifies @calo.app + emails a code → session JWT in ~/.designchef
98
+ init → broker mints a 1-hour read-only GitHub token → private installs
99
+ push → broker vends the Expo token + writes the Mirror registry server-side
65
100
  ```
66
101
 
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.
102
+ The broker is the only place the GitHub / Expo / Tigris secrets live.
103
+
104
+ ## Local development
69
105
 
70
- ## Tests
106
+ Point the CLI at a local broker and run the client-spine test:
71
107
 
72
108
  ```bash
73
- # with a dev broker running (cd ../calo-broker && npm run dev):
109
+ cd ../calo-broker && npm run dev # one shell
110
+ CALO_BROKER_URL=http://localhost:8080 npx @calo-design/cli login # another shell
74
111
  BASE=http://localhost:8080 bash test/client-spine.sh
75
112
  ```
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";
@@ -130,9 +131,23 @@ function runtimeExists() {
130
131
  );
131
132
  }
132
133
 
134
+ // expo-router needs babel-preset-expo (its Babel plugin injects the route require.context);
135
+ // without a babel.config.js, `expo export` produces an empty, routeless app. The shared
136
+ // runtime isn't always scaffolded with one, so guarantee it. Linked prototypes inherit it
137
+ // via writeThinConfigs; push staging backstops it again (see mirror-push.js).
138
+ function ensureBabelConfig(dir) {
139
+ const p = path.join(dir, "babel.config.js");
140
+ if (fs.existsSync(p)) return;
141
+ fs.writeFileSync(p, 'module.exports = (api) => {\n api.cache(true);\n return { presets: ["babel-preset-expo"] };\n};\n');
142
+ }
143
+
133
144
  async function ensureRuntime({ force } = {}) {
134
145
  const rt = runtimeDir();
135
- if (!force && runtimeExists()) { ok(`shared runtime ready (${tilde(rt)})`); return; }
146
+ if (!force && runtimeExists()) {
147
+ ensureBabelConfig(rt); // backfill: runtimes built before this shipped no babel.config.js
148
+ ok(`shared runtime ready (${tilde(rt)})`);
149
+ return;
150
+ }
136
151
  log(c.b("\n[runtime] Building the shared Calo runtime (one-time, ~a couple of minutes)"));
137
152
  fs.mkdirSync(rt, { recursive: true });
138
153
  if (force) {
@@ -143,6 +158,7 @@ async function ensureRuntime({ force } = {}) {
143
158
  if (!scaffoldExpoApp(rt)) throw new Error("runtime scaffold failed");
144
159
  }
145
160
  await installCaloDeps(rt);
161
+ ensureBabelConfig(rt); // a freshly scaffolded runtime may also lack one
146
162
  fs.writeFileSync(
147
163
  runtimeManifestPath(),
148
164
  JSON.stringify({ builtAt: new Date().toISOString(), pkgSpecs: PKG_SPECS, peers: PEERS }, null, 2) + "\n"
@@ -369,18 +385,20 @@ function isExistingProject() {
369
385
 
370
386
  // ---------------------------------------------------------------- commands
371
387
 
372
- function nextSteps({ linked } = {}) {
388
+ function nextSteps({ linked, cd } = {}) {
373
389
  log(c.b("\n✨ Done. In Claude Code — no restart needed:"));
374
- log(`\n ${c.b("1.")} Load the skill — type:`);
390
+ let n = 1;
391
+ if (cd) log(`\n ${c.b(n++ + ".")} Enter the prototype — run ${c.b(c.g("cd " + cd))}`);
392
+ log(`\n ${c.b(n++ + ".")} Load the skill — type:`);
375
393
  log(` ${c.b(c.g("/calo-design"))}`);
376
- log(`\n ${c.b("2.")} Create a prototype — describe the screen, e.g.:`);
394
+ log(`\n ${c.b(n++ + ".")} Create a prototype — describe the screen, e.g.:`);
377
395
  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).`);
396
+ log(`\n ${c.b(n++ + ".")} Preview — run ${c.dim("npx expo start")} (press w for web, or scan the QR in Expo Go).`);
379
397
  if (linked) {
380
398
  log(c.dim(`\n This prototype shares one install at ${tilde(runtimeDir())} — \`calo-design update\` refreshes it for every prototype.`));
381
399
  log(c.dim(" (expo-doctor may flag dependency versions on linked projects; that's expected and harmless.)"));
382
400
  }
383
- log(c.dim(`\n Docs: https://github.com/${SKILL_REPO}`));
401
+ log(c.dim(`\n Docs: https://www.npmjs.com/package/@calo-design/cli`));
384
402
  }
385
403
 
386
404
  async function cmdInit() {
@@ -389,40 +407,63 @@ async function cmdInit() {
389
407
  if (has("--skip-packages")) return nextSteps({});
390
408
  if (has("--isolated")) { await installHere(); return nextSteps({}); }
391
409
 
392
- if (isLinkedProject()) {
393
- // Re-running in an already-linked prototype: ensure runtime + refresh the link.
410
+ // `init <name>`: create ./<name>/ and set the prototype up there, so you can spin
411
+ // one up from inside a workspace container without cd-ing into an empty folder first.
412
+ const targetName = args.slice(1).find((a) => !a.startsWith("-"));
413
+ if (targetName) {
414
+ const dir = path.resolve(process.cwd(), targetName);
415
+ fs.mkdirSync(dir, { recursive: true });
416
+ process.chdir(dir);
417
+ log(c.dim(`\n Prototype folder: ${tilde(dir)}`));
418
+ }
419
+ const cd = targetName;
420
+
421
+ // Machine setup runs every time, independent of this folder: the skill is installed
422
+ // above; ensure the shared runtime so @calo/design-system + @calo/flows live on this
423
+ // machine (one copy, shared by every prototype).
424
+ let runtimeReady = true;
425
+ try {
394
426
  await ensureRuntime({ force: false });
395
- linkProject(process.cwd());
396
- return nextSteps({ linked: true });
427
+ } catch (e) {
428
+ runtimeReady = false;
429
+ warn(`couldn't build the shared runtime (${e.message}).`);
430
+ }
431
+
432
+ // Per-folder prototype (a separate, optional step): link or scaffold one here.
433
+ if (isLinkedProject()) {
434
+ // Re-running in an already-linked prototype: refresh the link if the runtime is ok.
435
+ if (runtimeReady) linkProject(process.cwd());
436
+ else warn("shared runtime unavailable — left the existing link untouched.");
437
+ return nextSteps({ linked: true, cd });
397
438
  }
398
439
  if (isExistingProject()) {
399
440
  warn("existing project detected — installing the Calo stack here (not linking), to avoid clobbering your node_modules.");
400
441
  await installCaloDeps(process.cwd());
401
442
  ok("packages + peers installed in this project");
402
- return nextSteps({});
443
+ return nextSteps({ cd });
403
444
  }
404
445
  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."));
446
+ // Machine setup is done; we just won't scaffold a prototype into a non-empty,
447
+ // non-Expo folder (e.g. a workspace container). Say what's ready and how to proceed.
448
+ log("");
449
+ ok(`Machine setup complete — skill installed${runtimeReady ? ", ds + flows runtime ready" : ""}.`);
450
+ if (runtimeReady) log(c.dim(` ds + flows live at ${tilde(path.join(runtimeDir(), "node_modules", "@calo"))}, shared by every prototype.`));
451
+ warn("No prototype scaffolded here — this folder isn't empty and isn't an Expo project.");
452
+ log(` Create one with: ${c.b(c.g("npx @calo-design/cli init <name>"))} ${c.dim("(makes ./<name>/ and links it)")}`);
453
+ log(c.dim(" …or run init in an empty folder or an existing Expo project, or pass --isolated to install into this folder."));
407
454
  return;
408
455
  }
409
456
 
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
- }
457
+ // Fresh prototype folder → link to the shared runtime, with a per-project fallback.
458
+ if (!runtimeReady) { await installHere(); return nextSteps({ cd }); }
418
459
  try {
419
460
  linkProject(process.cwd());
420
461
  } catch (e) {
421
462
  warn(`couldn't link to the shared runtime (${e.message}); falling back to a per-project install.`);
422
463
  await installHere();
423
- return nextSteps({});
464
+ return nextSteps({ cd });
424
465
  }
425
- nextSteps({ linked: true });
466
+ nextSteps({ linked: true, cd });
426
467
  }
427
468
 
428
469
  async function cmdUpdate() {
@@ -441,15 +482,20 @@ function help() {
441
482
  log(`${c.b("calo-design")} — one-line setup for Calo design tooling
442
483
 
443
484
  ${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
485
+ ${c.b("npx @calo-design/cli init")} skill + shared ds/flows runtime, and link a prototype in this folder
486
+ ${c.b("npx @calo-design/cli init <name>")} same, but scaffold the prototype in ./<name>/ (handy inside a workspace)
487
+ ${c.dim("init --isolated")} install the Calo stack into THIS folder (no shared runtime)
488
+ ${c.dim("init --skip-packages")} skill only (no runtime)
447
489
  ${c.dim("update")} refresh the skill + re-pin the shared runtime (all linked prototypes float to latest)
448
490
  ${c.dim("logout")} forget the saved Calo session
449
491
  ${c.dim("push")} publish THIS prototype to the Calo Mirror (login only — no EAS/Tigris creds needed)
450
492
  ${c.dim(" push --slug x --title \"…\" --owner \"…\" --screenshot path --dry-run --direct")}
493
+ ${c.dim("share")} publish THIS web prototype (React/Vite/CRA/Next) to Cloudflare Pages → share link
494
+ ${c.dim(" share --slug x --dir dist --build --project calo-prototypes --dry-run")}
451
495
 
452
496
  Login is required once before init; the session refreshes automatically.
497
+ init always installs the skill and ensures the shared ds/flows runtime on your machine;
498
+ scaffolding a prototype is a separate step (needs an empty folder, an Expo project, or a name).
453
499
  Prototypes share one install at ${c.dim(tilde(runtimeDir()))} (override with DESIGNCHEF_HOME).
454
500
  `);
455
501
  }
@@ -461,6 +507,7 @@ function help() {
461
507
  else if (cmd === "init") await cmdInit();
462
508
  else if (cmd === "update") await cmdUpdate();
463
509
  else if (cmd === "push") await cmdPush(args.slice(1));
510
+ else if (cmd === "share") await cmdShare(args.slice(1));
464
511
  else help();
465
512
  } catch (err) {
466
513
  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}`, json.detail].filter(Boolean).join("\n "));
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 };
@@ -122,6 +122,18 @@ function writeJSON(p, obj) {
122
122
  fs.writeFileSync(p, JSON.stringify(obj, null, 2) + "\n");
123
123
  }
124
124
 
125
+ // A babel.config.js with babel-preset-expo MUST exist wherever `expo export` runs. Without
126
+ // it the expo-router Babel plugin never injects the route require.context, so the export is
127
+ // a routeless, empty shell (no screens, no design system, no assets). The shared runtime
128
+ // doesn't always ship one, so guarantee it: prefer the prototype's own config, else default.
129
+ function ensureBabelConfig(dir, fallbackFrom) {
130
+ const dst = path.join(dir, "babel.config.js");
131
+ if (fs.existsSync(dst)) return;
132
+ const fb = fallbackFrom && path.join(fallbackFrom, "babel.config.js");
133
+ if (fb && fs.existsSync(fb)) { fs.cpSync(fb, dst); return; }
134
+ fs.writeFileSync(dst, 'module.exports = (api) => {\n api.cache(true);\n return { presets: ["babel-preset-expo"] };\n};\n');
135
+ }
136
+
125
137
  function stageProject({ root, stage, slug, title, ad }) {
126
138
  // Copy the author's routes + assets verbatim.
127
139
  const stageSrcParent = path.join(stage, path.relative(root, path.dirname(ad)));
@@ -164,6 +176,9 @@ function stageProject({ root, stage, slug, title, ad }) {
164
176
  const src = path.join(runtimeDir(), f);
165
177
  if (fs.existsSync(src)) fs.cpSync(src, path.join(stage, f));
166
178
  }
179
+ // The runtime may not have a babel.config.js; without one the staged export is an empty,
180
+ // routeless app. Backstop it from the prototype's own config, else a default.
181
+ ensureBabelConfig(stage, root);
167
182
 
168
183
  writeMetroConfig(stage);
169
184
  linkNodeModules(stage);
@@ -213,7 +228,7 @@ function injectBackChrome({ stagedAppDir, mirrorChromeDir, libRel }) {
213
228
  if (fs.existsSync(layout)) {
214
229
  const cur = fs.readFileSync(layout, "utf8");
215
230
  const standard = /useFonts\(caloFonts\)/.test(cur) && /<Stack/.test(cur);
216
- if (!standard) warn("custom root _layout detected — Mirror wraps it with a Stack; custom providers won't carry over.");
231
+ if (!standard) warn("custom root _layout detected — Mirror wraps it with a Stack; providers in the ROOT layout won't carry over. Move them to a nested layout (e.g. src/app/(group)/_layout.tsx), which the Mirror preserves.");
217
232
  }
218
233
  fs.writeFileSync(
219
234
  layout,
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.1",
3
+ "version": "0.4.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"],