@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.
- package/bin/checkpoints.js +359 -0
- package/bin/cli.js +8 -0
- package/bin/mirror-push.js +39 -27
- package/bin/share.js +5 -0
- package/package.json +1 -1
|
@@ -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
|
package/bin/mirror-push.js
CHANGED
|
@@ -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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
//
|
|
493
|
-
|
|
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")
|
|
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
|
|
506
|
-
// pure JS. Poll it; the listeners above
|
|
507
|
-
//
|
|
508
|
-
const timer = setInterval(
|
|
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.
|
|
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"
|