@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 +77 -40
- package/bin/cli.js +73 -26
- package/bin/login.js +29 -1
- package/bin/mirror-push.js +16 -1
- package/bin/share.js +170 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,54 +1,67 @@
|
|
|
1
|
-
# @calo-design/cli
|
|
1
|
+
# @calo-design/cli
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
design-system
|
|
6
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
12
|
+
Package: https://www.npmjs.com/package/@calo-design/cli
|
|
13
|
+
|
|
14
|
+
## Setup
|
|
12
15
|
|
|
13
16
|
```bash
|
|
14
|
-
npx @calo-design/cli login
|
|
15
|
-
npx @calo-design/cli init
|
|
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
|
-
|
|
19
|
-
|
|
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
|
|
23
|
-
|
|
24
|
-
npx
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
68
|
-
|
|
102
|
+
The broker is the only place the GitHub / Expo / Tigris secrets live.
|
|
103
|
+
|
|
104
|
+
## Local development
|
|
69
105
|
|
|
70
|
-
|
|
106
|
+
Point the CLI at a local broker and run the client-spine test:
|
|
71
107
|
|
|
72
108
|
```bash
|
|
73
|
-
|
|
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()) {
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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://
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
396
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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
|
|
411
|
-
|
|
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 +
|
|
445
|
-
${c.
|
|
446
|
-
${c.dim("init --
|
|
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 };
|
package/bin/mirror-push.js
CHANGED
|
@@ -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;
|
|
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.
|
|
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"],
|