@calo-design/cli 0.9.6 → 0.10.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.
@@ -0,0 +1,359 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Prototype checkpoints & sharing — `calo-design save | open | projects`.
5
+ *
6
+ * `save` tars this prototype's SOURCE (node_modules is a runtime symlink, .git and
7
+ * secrets are excluded, so a snapshot is kilobytes), hands it to the broker, and prints
8
+ * a share URL that unfurls in Slack. Every project is an append-only stream of immutable
9
+ * versions under the saver's handle (broker-derived from the login email — spoof-proof).
10
+ *
11
+ * `open owner/slug[@v]` unpacks any checkpoint into a fresh folder. The first save from
12
+ * that folder becomes YOUR fork (provenance recorded in meta.forkedFrom) — the original
13
+ * stream is never touched. No branches, no merges, no locks: a stale save gets a 409 and
14
+ * the human decides (--force saves on top; both versions stay in history forever).
15
+ *
16
+ * Reads go straight to the public bucket (same posture as art/screenshots); only writes
17
+ * need the broker session. Conflict/marker state lives in .calo-checkpoint.json at the
18
+ * prototype root (per-machine; excluded from snapshots).
19
+ */
20
+
21
+ const crypto = require("node:crypto");
22
+ const fs = require("node:fs");
23
+ const os = require("node:os");
24
+ const path = require("node:path");
25
+ const zlib = require("node:zlib");
26
+ const { spawnSync } = require("node:child_process");
27
+ const { ensureSession, loadSession, BROKER } = require("./login");
28
+ const { _publicBase: PUBLIC_BASE } = require("./mirror-push");
29
+
30
+ 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` };
31
+ const log = (s = "") => console.log(s);
32
+ const elog = (s = "") => console.error(s);
33
+ const ok = (s) => log(`${c.g("✓")} ${s}`);
34
+
35
+ const MARKER = ".calo-checkpoint.json";
36
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
37
+ const REF_RE = /^([a-z0-9][a-z0-9-]{0,63})\/([a-z0-9][a-z0-9-]{0,63})(?:@(\d{1,6}))?$/;
38
+
39
+ // What a snapshot leaves out. .git is the owner's private history; node_modules &
40
+ // build output are reproducible; .env* may hold secrets; the marker is per-machine
41
+ // state; backend.json is a machine-generated pointer to the OWNER's provisioned
42
+ // backend (a fork must provision its own — the worker/ source dir stays IN).
43
+ // EXCLUDE_NAMES (basename match at any depth) must stay in lockstep with the tar
44
+ // --exclude patterns below: the canonical hash walks exactly the set tar archives.
45
+ const EXCLUDE_NAMES = new Set([".git", "node_modules", ".expo", ".expo-shared", "dist", "build", "web-build", ".DS_Store", MARKER, "backend.json"]);
46
+ const EXCLUDES = [...EXCLUDE_NAMES, ".env*"];
47
+ const isExcluded = (name) => EXCLUDE_NAMES.has(name) || name.startsWith(".env");
48
+
49
+ // Keep byte-identical with the broker's handleOf (src/checkpoints.js) — it's only used
50
+ // here to *predict* fork-vs-continue; the broker's derivation is authoritative.
51
+ const handleOf = (email) => {
52
+ const local = String(email || "").toLowerCase().split("@")[0];
53
+ const h = local.replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
54
+ return SLUG_RE.test(h) ? h : null;
55
+ };
56
+
57
+ const flag = (args, name) => {
58
+ const hit = args.find((a) => a.startsWith(`--${name}=`));
59
+ if (hit) return hit.split("=").slice(1).join("=");
60
+ const i = args.indexOf(`--${name}`);
61
+ if (i >= 0 && args[i + 1] && !args[i + 1].startsWith("--")) return args[i + 1];
62
+ return undefined;
63
+ };
64
+ const has = (args, name) => args.includes(`--${name}`);
65
+ const positionals = (args, valueFlags) =>
66
+ args.filter((a, i, all) => !a.startsWith("--") && !valueFlags.includes(all[i - 1]));
67
+
68
+ const slugify = (s) =>
69
+ String(s || "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63);
70
+
71
+ const readMarker = (root) => {
72
+ try {
73
+ return JSON.parse(fs.readFileSync(path.join(root, MARKER), "utf8"));
74
+ } catch {
75
+ return null;
76
+ }
77
+ };
78
+ const writeMarker = (root, m) => fs.writeFileSync(path.join(root, MARKER), JSON.stringify(m, null, 2) + "\n");
79
+
80
+ const ago = (ts) => {
81
+ const s = Math.max(1, Math.floor(Date.now() / 1000) - ts);
82
+ if (s < 3600) return `${Math.floor(s / 60) || 1}m ago`;
83
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
84
+ return `${Math.floor(s / 86400)}d ago`;
85
+ };
86
+
87
+ // The one JSON-output rule: stdout carries EXACTLY the payload (last non-empty line
88
+ // contract, same as `feed --json`); everything human goes to stderr.
89
+ const emitJson = (obj) => process.stdout.write("\n" + JSON.stringify(obj) + "\n");
90
+
91
+ async function publicJson(key) {
92
+ const res = await fetch(`${PUBLIC_BASE}/${key}?t=${Date.now()}`, { signal: AbortSignal.timeout(20000) });
93
+ if (res.status === 404 || res.status === 403) return null;
94
+ if (!res.ok) throw new Error(`checkpoint storage read failed (HTTP ${res.status}) — try again shortly`);
95
+ return res.json();
96
+ }
97
+
98
+ // Canonical content hash: sorted relative paths + sizes + bytes of every included
99
+ // regular file. Deliberately NOT a hash of the tar (tar embeds mtimes, so byte-identical
100
+ // trees re-tarred later would look "changed" — that's how no-op detection breaks).
101
+ // The same function verifies an extracted checkpoint on `open` (an extracted tree has
102
+ // no excluded names left, so the exclusion walk is a no-op there). Symlinks are skipped
103
+ // on both sides (tar archives them as links, not content).
104
+ function walkFiles(root) {
105
+ const out = [];
106
+ const rec = (dir, rel) => {
107
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
108
+ if (isExcluded(e.name)) continue;
109
+ const r = rel ? `${rel}/${e.name}` : e.name;
110
+ if (e.isDirectory()) rec(path.join(dir, e.name), r);
111
+ else if (e.isFile()) out.push(r);
112
+ }
113
+ };
114
+ rec(root, "");
115
+ return out.sort();
116
+ }
117
+ function canonicalHash(root) {
118
+ const h = crypto.createHash("sha256");
119
+ for (const r of walkFiles(root)) {
120
+ const buf = fs.readFileSync(path.join(root, r));
121
+ h.update(r);
122
+ h.update("\0");
123
+ h.update(String(buf.length));
124
+ h.update("\0");
125
+ h.update(buf);
126
+ h.update("\0");
127
+ }
128
+ return h.digest("hex");
129
+ }
130
+
131
+ // tar the prototype source with the exclusion list; returns { work, tarPath, hash }
132
+ // in a tmpdir the caller must clean up.
133
+ function makeSnapshot(root) {
134
+ const work = fs.mkdtempSync(path.join(os.tmpdir(), "calo-ckpt-"));
135
+ const tarPath = path.join(work, "snapshot.tar");
136
+ const args = ["-cf", tarPath, ...EXCLUDES.map((e) => `--exclude=${e}`), "-C", root, "."];
137
+ const r = spawnSync("tar", args, { stdio: ["ignore", "pipe", "pipe"] });
138
+ if (r.status !== 0) {
139
+ fs.rmSync(work, { recursive: true, force: true });
140
+ throw new Error(`tar failed: ${String(r.stderr || r.stdout || "").slice(0, 300)}`);
141
+ }
142
+ return { work, tarPath, hash: canonicalHash(root) };
143
+ }
144
+
145
+ // ---------------------------------------------------------------------- save ----------
146
+
147
+ async function cmdSave(args = []) {
148
+ return saveFlow({
149
+ root: process.cwd(),
150
+ slugOverride: flag(args, "slug"),
151
+ note: flag(args, "note") || "",
152
+ force: has(args, "force"),
153
+ json: has(args, "json"),
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Deploy-stamped versioning: `share`/`push` call this after a successful publish, so
159
+ * every deploy leaves a source checkpoint (with the live URL in its meta) without the
160
+ * user doing anything. Best-effort by contract — a checkpoint hiccup must never turn a
161
+ * successful deploy into a failed command.
162
+ */
163
+ async function autoCheckpoint({ root, note, publishedUrl }) {
164
+ try {
165
+ await saveFlow({ root, note, publishedUrl, force: true, quiet: true });
166
+ } catch (e) {
167
+ elog(`${c.y("!")} deploy succeeded, but the source checkpoint didn't: ${e.message}`);
168
+ }
169
+ }
170
+
171
+ async function saveFlow({ root, slugOverride, note = "", force = false, json = false, quiet = false, publishedUrl }) {
172
+ if (!fs.existsSync(path.join(root, "package.json"))) {
173
+ throw new Error("no package.json here — run `calo-design save` from a prototype folder");
174
+ }
175
+
176
+ const session = await ensureSession();
177
+ const me = handleOf((loadSession() || {}).email);
178
+ const marker = readMarker(root);
179
+ const slug = slugify(slugOverride || (marker && marker.slug) || path.basename(root));
180
+ if (!SLUG_RE.test(slug)) throw new Error(`can't derive a valid slug from this folder name — pass --slug <name>`);
181
+ note = String(note).slice(0, 500);
182
+
183
+ // Fork detection: a marker owned by someone else means this folder came from
184
+ // `open` on their checkpoint — the first save starts YOUR stream, with provenance.
185
+ // A tampered/partial marker (hand-copied folder, missing basedOn) must degrade to a
186
+ // plain fresh save, never to a malformed header that fails the whole checkpoint.
187
+ const FORKREF = /^[a-z0-9][a-z0-9-]{0,63}\/[a-z0-9][a-z0-9-]{0,63}@\d{1,6}$/;
188
+ const isFork = !!(marker && marker.owner && me && marker.owner !== me);
189
+ const basedOn = !isFork && marker && Number.isInteger(marker.basedOn) && marker.basedOn >= 0 ? marker.basedOn : null;
190
+ let forkedFrom = isFork ? `${marker.owner}/${marker.slug}@${marker.basedOn}` : (marker && marker.forkedFrom) || null;
191
+ if (forkedFrom && !FORKREF.test(forkedFrom)) forkedFrom = null;
192
+
193
+ const snap = makeSnapshot(root);
194
+ try {
195
+ if (!force && marker && marker.lastHash === snap.hash) {
196
+ if (json) emitJson({ ok: true, noop: true, owner: marker.owner, slug, v: marker.basedOn });
197
+ else log(`${c.dim("Nothing changed since")} v${marker.basedOn} ${c.dim("— no new checkpoint.")}`);
198
+ return;
199
+ }
200
+ const tgz = zlib.gzipSync(fs.readFileSync(snap.tarPath));
201
+
202
+ let res;
203
+ try {
204
+ res = await fetch(`${BROKER}/v1/checkpoints/save`, {
205
+ method: "POST",
206
+ headers: {
207
+ authorization: `Bearer ${session}`,
208
+ "content-type": "application/gzip",
209
+ "x-calo-slug": slug,
210
+ "x-calo-note": encodeURIComponent(note),
211
+ "x-calo-hash": snap.hash,
212
+ ...(basedOn != null ? { "x-calo-based-on": String(basedOn) } : {}),
213
+ ...(isFork && forkedFrom ? { "x-calo-forked-from": forkedFrom } : {}),
214
+ ...(publishedUrl ? { "x-calo-published-url": publishedUrl } : {}),
215
+ ...(force ? { "x-calo-force": "1" } : {}),
216
+ },
217
+ body: tgz,
218
+ signal: AbortSignal.timeout(60000),
219
+ });
220
+ } catch (e) {
221
+ throw new Error(`can't reach the Calo broker at ${BROKER} (${e.message})`);
222
+ }
223
+ const body = await res.json().catch(() => ({}));
224
+
225
+ if (res.status === 409) {
226
+ if (json) {
227
+ emitJson({ ok: false, conflict: true, ...body });
228
+ process.exitCode = 1;
229
+ return;
230
+ }
231
+ elog(`\n${c.y("!")} ${body.error}`);
232
+ if (body.headNote) elog(c.dim(` their v${body.head}: "${body.headNote}" (${body.headAuthor || "?"})`));
233
+ return void (process.exitCode = 1);
234
+ }
235
+ if (!res.ok) throw new Error(body.error || `checkpoint save → HTTP ${res.status}`);
236
+
237
+ writeMarker(root, {
238
+ owner: body.owner,
239
+ slug: body.slug,
240
+ basedOn: body.v,
241
+ forkedFrom,
242
+ lastHash: snap.hash,
243
+ savedAt: Math.floor(Date.now() / 1000),
244
+ });
245
+
246
+ if (json) return void emitJson({ ok: true, ...body });
247
+ if (body.noop) return void log(`${c.dim("Nothing changed since")} v${body.v} ${c.dim("— no new checkpoint.")}`);
248
+ if (quiet) return void ok(`source checkpoint ${c.b(`v${body.v}`)} — ${body.url}`);
249
+ ok(`checkpoint ${c.b(`v${body.v}`)} — ${body.owner}/${body.slug}${note ? c.dim(` "${note}"`) : ""}`);
250
+ log(` ${c.b(c.g(body.url))}`);
251
+ log(c.dim(" Paste that link in Slack — it unfurls with the note. Teammates open it with:"));
252
+ log(c.dim(` calo-design open ${body.owner}/${body.slug}@${body.v}`));
253
+ } finally {
254
+ fs.rmSync(snap.work, { recursive: true, force: true });
255
+ }
256
+ }
257
+
258
+ // ---------------------------------------------------------------------- open ----------
259
+
260
+ async function cmdOpen(args = []) {
261
+ const json = has(args, "json");
262
+ const pos = positionals(args, []);
263
+ const ref = pos[0] || "";
264
+ const m = REF_RE.exec(ref);
265
+ if (!m) throw new Error("usage: calo-design open <owner>/<slug>[@version] [folder]");
266
+ const [, owner, slug, vRaw] = m;
267
+
268
+ const idx = await publicJson(`checkpoints/${owner}/${slug}/index.json`);
269
+ const versions = idx && Array.isArray(idx.versions) ? idx.versions : [];
270
+ const head = versions.reduce((mx, e) => Math.max(mx, e.v | 0), 0);
271
+ const v = vRaw ? Number(vRaw) : head;
272
+ const meta = versions.find((e) => (e.v | 0) === v) || (await publicJson(`checkpoints/${owner}/${slug}/v${v}/meta.json`));
273
+ if (!meta) throw new Error(`no checkpoint found at ${owner}/${slug}${vRaw ? `@${vRaw}` : ""} — check the link, or ask for a fresh one`);
274
+
275
+ const dir = path.resolve(process.cwd(), pos[1] || (vRaw && v !== head ? `${slug}-v${v}` : slug));
276
+ if (fs.existsSync(dir) && fs.readdirSync(dir).filter((f) => f !== ".DS_Store").length) {
277
+ throw new Error(`${path.basename(dir)}/ already exists and isn't empty — pass a new folder name: calo-design open ${ref} <folder>`);
278
+ }
279
+
280
+ const res = await fetch(`${PUBLIC_BASE}/checkpoints/${owner}/${slug}/v${v}/snapshot.tar.gz?t=${Date.now()}`, {
281
+ signal: AbortSignal.timeout(60000),
282
+ });
283
+ if (!res.ok) throw new Error(`snapshot download failed (HTTP ${res.status}) — try again shortly`);
284
+ const tgz = Buffer.from(await res.arrayBuffer());
285
+ const tar = zlib.gunzipSync(tgz, { maxOutputLength: 200e6 });
286
+
287
+ const work = fs.mkdtempSync(path.join(os.tmpdir(), "calo-ckpt-"));
288
+ try {
289
+ const tarPath = path.join(work, "snapshot.tar");
290
+ fs.writeFileSync(tarPath, tar);
291
+ fs.mkdirSync(dir, { recursive: true });
292
+ const r = spawnSync("tar", ["-xf", tarPath, "-C", dir], { stdio: ["ignore", "pipe", "pipe"] });
293
+ if (r.status !== 0) throw new Error(`extract failed: ${String(r.stderr || r.stdout || "").slice(0, 300)}`);
294
+ } finally {
295
+ fs.rmSync(work, { recursive: true, force: true });
296
+ }
297
+
298
+ // Integrity: recompute the canonical content hash over what actually landed on disk
299
+ // (an extracted tree has no excluded names, so this walks exactly the snapshot set).
300
+ const gotHash = canonicalHash(dir);
301
+ if (meta.contentHash && gotHash !== meta.contentHash) {
302
+ fs.rmSync(dir, { recursive: true, force: true });
303
+ throw new Error("snapshot failed its integrity check (content hash mismatch) — retry; if it persists, ask for a fresh checkpoint link");
304
+ }
305
+
306
+ writeMarker(dir, {
307
+ owner,
308
+ slug,
309
+ basedOn: v,
310
+ forkedFrom: `${owner}/${slug}@${v}`,
311
+ lastHash: meta.contentHash || gotHash,
312
+ openedAt: Math.floor(Date.now() / 1000),
313
+ });
314
+
315
+ if (json) return void emitJson({ ok: true, owner, slug, v, dir, note: meta.note || "" });
316
+ ok(`opened ${c.b(`${owner}/${slug} v${v}`)} → ${path.basename(dir)}/${meta.note ? c.dim(` "${meta.note}"`) : ""}`);
317
+ const mine = handleOf((loadSession() || {}).email);
318
+ log(`\n ${c.b("1.")} ${c.g(`cd ${path.relative(process.cwd(), dir) || "."}`)} then run ${c.g("calo-design init")} ${c.dim("(links the shared runtime)")}`);
319
+ log(` ${c.b("2.")} Work on it in Claude Code (${c.g("/calo-design")}).`);
320
+ log(` ${c.b("3.")} ${c.g("calo-design save")} ${c.dim(`publishes it as YOUR fork${mine ? ` (${mine}/${slug})` : ""} — ${owner}'s stream is never touched.`)}`);
321
+ if (fs.existsSync(path.join(dir, "worker"))) {
322
+ log(c.dim("\n This prototype has a worker/ backend — run `calo-design backend init` to provision your own copy of it."));
323
+ }
324
+ }
325
+
326
+ // ------------------------------------------------------------------- projects ---------
327
+
328
+ async function cmdProjects(args = []) {
329
+ const json = has(args, "json");
330
+ const session = await ensureSession();
331
+ let res;
332
+ try {
333
+ res = await fetch(`${BROKER}/v1/checkpoints/list`, {
334
+ headers: { authorization: `Bearer ${session}` },
335
+ signal: AbortSignal.timeout(15000),
336
+ });
337
+ } catch (e) {
338
+ throw new Error(`can't reach the Calo broker at ${BROKER} (${e.message})`);
339
+ }
340
+ const body = await res.json().catch(() => ({}));
341
+ if (!res.ok) throw new Error(body.error || `checkpoints list → HTTP ${res.status}`);
342
+ let projects = Array.isArray(body.projects) ? body.projects : [];
343
+ projects.sort((a, b) => (b.ts || 0) - (a.ts || 0));
344
+ if (has(args, "mine")) {
345
+ const me = handleOf((loadSession() || {}).email);
346
+ projects = projects.filter((p) => p.owner === me);
347
+ }
348
+ if (json) return void emitJson({ ok: true, projects });
349
+ if (!projects.length) return void log(c.dim("No shared prototypes yet — `calo-design save` in a prototype folder mints the first one."));
350
+ log(c.b(`\nShared prototypes (${projects.length}):\n`));
351
+ for (const p of projects) {
352
+ const who = handleOf(p.author) || p.owner;
353
+ log(` ${c.b(`${p.owner}/${p.slug}`)} ${c.g(`v${p.head}`)} ${p.note ? `"${p.note}"` : c.dim("(no note)")}`);
354
+ log(c.dim(` ${who} · ${p.ts ? ago(p.ts) : "?"}${p.forkedFrom ? ` · forked from ${p.forkedFrom}` : ""} → calo-design open ${p.owner}/${p.slug}`));
355
+ }
356
+ log("");
357
+ }
358
+
359
+ module.exports = { cmdSave, cmdOpen, cmdProjects, autoCheckpoint, _handleOf: handleOf, _makeSnapshot: makeSnapshot, _canonicalHash: canonicalHash, _EXCLUDES: EXCLUDES };
package/bin/cli.js CHANGED
@@ -21,6 +21,7 @@ const { cmdShare } = require("./share");
21
21
  const { cmdImage } = require("./imagegen");
22
22
  const { cmdLogin, cmdLogout, ensureLoggedIn, githubToken, reportEvent, ensureSession, loadSession, BROKER } = require("./login");
23
23
  const { cmdFeedback, cmdTasks } = require("./feedback");
24
+ const { cmdSave, cmdOpen, cmdProjects } = require("./checkpoints");
24
25
 
25
26
  const ORG = "Calo-Design";
26
27
  const SKILL_REPO = `${ORG}/calo-design`;
@@ -781,6 +782,10 @@ function help() {
781
782
  ${c.dim(" share --slug x --dir dist --build --project calo-prototypes --dry-run")}
782
783
  ${c.dim("gallery")} serve the @calo/design-system component gallery locally (live from the runtime)
783
784
  ${c.dim(" gallery --port 8091")}
785
+ ${c.dim("save")} checkpoint THIS prototype's source → share URL (append-only; login only)
786
+ ${c.dim(" save --note \"what changed\" --slug x --force --json")}
787
+ ${c.dim("open <owner>/<slug>[@v] [folder]")} unpack a teammate's checkpoint; your first save becomes your own fork
788
+ ${c.dim("projects")} all shared prototypes (--mine, --json)
784
789
  ${c.dim("feedback [slug]")} feedback inbox for shared prototypes (open threads; --all, --json)
785
790
  ${c.dim(" feedback new <slug> <text> comment|resolve|reopen <threadId> shot <slug> <png>")}
786
791
  ${c.dim("tasks [slug]")} triaged work items (--status todo|in_progress|done|wontfix, --json)
@@ -811,6 +816,9 @@ function help() {
811
816
  else if (cmd === "gallery") await cmdGallery();
812
817
  else if (cmd === "feedback") await cmdFeedback(args.slice(1));
813
818
  else if (cmd === "tasks") await cmdTasks(args.slice(1));
819
+ else if (cmd === "save") await cmdSave(args.slice(1));
820
+ else if (cmd === "open") await cmdOpen(args.slice(1));
821
+ else if (cmd === "projects") await cmdProjects(args.slice(1));
814
822
  else help();
815
823
  } catch (err) {
816
824
  // Diagnostics go to stderr so stdout stays pure for machine callers (The Pass parses
@@ -444,7 +444,7 @@ function switchToChannel(channel: string): void {
444
444
  await Updates.fetchUpdateAsync();
445
445
  // Defer the reload off the awaited chain (JSI teardown race — mirror.ts).
446
446
  setTimeout(() => { void Updates.reloadAsync().catch(() => { switching = false; }); }, 300);
447
- })().catch(() => { switching = false; });
447
+ })().catch((e) => { console.warn("[mirror-switch] switch to", channel, "failed:", String(e)); switching = false; });
448
448
  }
449
449
 
450
450
  // "/open/<slug>", "scheme://open/<slug>" (host parses as "open"), and
@@ -465,14 +465,6 @@ function launcherSlug(path: string): string | null {
465
465
  }
466
466
  }
467
467
 
468
- /** "/" when the URL is the launcher's (starting a switch if it names another prototype), null otherwise. */
469
- export function handleLauncherPath(path: string): string | null {
470
- const slug = launcherSlug(path);
471
- if (slug === null) return null;
472
- if (slug && slug !== PROTOTYPE) switchToChannel(slug);
473
- return "/";
474
- }
475
-
476
468
  // Warm-link coverage. After Updates.reloadAsync() the React Native
477
469
  // Linking 'url' event no longer reaches the reloaded JS context (verified on
478
470
  // Android: the launcher's original context gets warm links, a prototype's
@@ -480,32 +472,43 @@ export function handleLauncherPath(path: string): string | null {
480
472
  // URL store, so listen there — and on every foreground, sweep getLinkingURL()
481
473
  // in case the event itself was missed. lastHandled stops the sweep from
482
474
  // re-firing on URLs we've already acted on.
483
- let lastHandled: string | null = null;
484
-
485
- function handleWarmUrl(url: string | null): void {
486
- if (!url || url === lastHandled) return;
487
- lastHandled = url;
488
- handleLauncherPath(url);
475
+ /**
476
+ * "/" when the URL is the launcher's, null otherwise. NEVER starts a switch:
477
+ * the initial URL of a reloaded context can be the STALE activity intent (on
478
+ * Android the activity keeps the intent that cold-started the app), and
479
+ * switching on it ping-pongs between prototypes. Switching decisions belong to
480
+ * the sweep below, which reads expo-linking's always-current URL store.
481
+ */
482
+ export function handleLauncherPath(path: string): string | null {
483
+ return launcherSlug(path) === null ? null : "/";
484
+ }
485
+
486
+ // The launcher-URL store is handle-once: after acting on a URL, clear it so a
487
+ // context created later (e.g. by a feed tap, which carries no URL) can't
488
+ // replay it as a phantom switch.
489
+ function sweepLinkingURL(): void {
490
+ let url: string | null = null;
491
+ try { url = ExpoLinking.getLinkingURL() ?? null; } catch { return; }
492
+ if (!url) return;
493
+ const slug = launcherSlug(url);
494
+ if (slug === null) return; // the prototype's own deep link — not ours to touch
495
+ try { ExpoLinking.clearInitialURL(); } catch {}
496
+ if (slug && slug !== PROTOTYPE) switchToChannel(slug);
489
497
  }
490
498
 
491
499
  export function subscribeLauncherLinks(): { remove: () => void } {
492
- // Seed with the URL that launched this context so the sweeps below don't
493
- // replay it (+native-intent already handled the initial URL).
494
- try { lastHandled = ExpoLinking.getLinkingURL() ?? null; } catch {}
495
- const sweep = () => {
496
- try { handleWarmUrl(ExpoLinking.getLinkingURL() ?? null); } catch {}
497
- };
498
- const linkSub = ExpoLinking.addEventListener("url", ({ url }) => handleWarmUrl(url));
500
+ sweepLinkingURL(); // the URL that created this context (cold link or switch)
501
+ const linkSub = ExpoLinking.addEventListener("url", () => sweepLinkingURL());
499
502
  const stateSub = AppState.addEventListener("change", (state) => {
500
- if (state === "active") sweep();
503
+ if (state === "active") sweepLinkingURL();
501
504
  });
502
505
  // The load-bearing path: in a context created by Updates.reloadAsync() the
503
506
  // native->JS EVENT bridge (RN Linking, AppState, expo-linking's emitter) is
504
507
  // dead on Android, but expo-linking's native lifecycle listener still writes
505
- // every incoming URL to a static that getLinkingURL() reads — and timers are
506
- // pure JS. Poll it; the listeners above are kept for contexts where events
507
- // do work (they win the race and lastHandled dedupes the poll).
508
- const timer = setInterval(sweep, 1500);
508
+ // every incoming URL to the store getLinkingURL() reads — and timers are
509
+ // pure JS. Poll it; the listeners above cover contexts where events work,
510
+ // and clearInitialURL() makes whichever path runs first win cleanly.
511
+ const timer = setInterval(sweepLinkingURL, 1500);
509
512
  return {
510
513
  remove: () => {
511
514
  linkSub.remove();
@@ -835,6 +838,15 @@ async function cmdPush(args) {
835
838
 
836
839
  log(c.b("\n✨ Live in the Mirror.") + " Open Calo Mirror and tap Refresh — your prototype is at the top.");
837
840
 
841
+ // Deploy-stamped versioning: every successful push leaves a source checkpoint, so
842
+ // the thing on the phone and its exact source are permanently paired. Best-effort —
843
+ // never fails the push. (Lazy require: mirror-push is also loaded by art-push.)
844
+ await require("./checkpoints").autoCheckpoint({
845
+ root: process.cwd(),
846
+ note: flag(args, "note") || `pushed to the Mirror (${slug})`,
847
+ publishedUrl: `designchef://open/${slug}`,
848
+ });
849
+
838
850
  // Share target: the deep link the Mirror scanner (and iOS camera) opens.
839
851
  // Print it + a scannable QR so a teammate can jump straight in.
840
852
  const deepLink = `designchef://open/${slug}`;
package/bin/share.js CHANGED
@@ -21,6 +21,7 @@ const fs = require("node:fs");
21
21
  const os = require("node:os");
22
22
  const path = require("node:path");
23
23
  const { publishWeb } = require("./login");
24
+ const { autoCheckpoint } = require("./checkpoints");
24
25
 
25
26
  const PROJECT = process.env.CALO_PAGES_PROJECT || "calo-prototypes";
26
27
  const aliasUrl = (slug, project) => `https://${slug}.${project}.pages.dev`;
@@ -167,6 +168,10 @@ async function cmdShare(args = []) {
167
168
  log(c.dim(` Re-run \`calo-design share\` to update “${slug}” in place. Share the link above.`));
168
169
  }
169
170
  if (r.version) log(c.dim(` this version: ${r.version}`));
171
+ // Deploy-stamped versioning: every successful share leaves a source checkpoint
172
+ // (with the live URL in its meta), so the deployed thing and its exact source are
173
+ // permanently paired. Best-effort — never fails the share.
174
+ await autoCheckpoint({ root, note: flag(args, "note") || `shared to the web (${slug})`, publishedUrl: url });
170
175
  } finally {
171
176
  try { fs.rmSync(tgz); } catch {}
172
177
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calo-design/cli",
3
- "version": "0.9.6",
3
+ "version": "0.10.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": {
6
6
  "calo-design": "bin/cli.js"