@calo-design/cli 0.9.7 → 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 +9 -0
- 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
|
@@ -838,6 +838,15 @@ async function cmdPush(args) {
|
|
|
838
838
|
|
|
839
839
|
log(c.b("\n✨ Live in the Mirror.") + " Open Calo Mirror and tap Refresh — your prototype is at the top.");
|
|
840
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
|
+
|
|
841
850
|
// Share target: the deep link the Mirror scanner (and iOS camera) opens.
|
|
842
851
|
// Print it + a scannable QR so a teammate can jump straight in.
|
|
843
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"
|