@calo-design/cli 0.10.0 → 0.12.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.
@@ -160,15 +160,15 @@ async function cmdSave(args = []) {
160
160
  * user doing anything. Best-effort by contract — a checkpoint hiccup must never turn a
161
161
  * successful deploy into a failed command.
162
162
  */
163
- async function autoCheckpoint({ root, note, publishedUrl }) {
163
+ async function autoCheckpoint({ root, note, publishedUrl, artifact }) {
164
164
  try {
165
- await saveFlow({ root, note, publishedUrl, force: true, quiet: true });
165
+ await saveFlow({ root, note, publishedUrl, artifact, force: true, quiet: true });
166
166
  } catch (e) {
167
167
  elog(`${c.y("!")} deploy succeeded, but the source checkpoint didn't: ${e.message}`);
168
168
  }
169
169
  }
170
170
 
171
- async function saveFlow({ root, slugOverride, note = "", force = false, json = false, quiet = false, publishedUrl }) {
171
+ async function saveFlow({ root, slugOverride, note = "", force = false, json = false, quiet = false, publishedUrl, artifact }) {
172
172
  if (!fs.existsSync(path.join(root, "package.json"))) {
173
173
  throw new Error("no package.json here — run `calo-design save` from a prototype folder");
174
174
  }
@@ -212,6 +212,7 @@ async function saveFlow({ root, slugOverride, note = "", force = false, json = f
212
212
  ...(basedOn != null ? { "x-calo-based-on": String(basedOn) } : {}),
213
213
  ...(isFork && forkedFrom ? { "x-calo-forked-from": forkedFrom } : {}),
214
214
  ...(publishedUrl ? { "x-calo-published-url": publishedUrl } : {}),
215
+ ...(artifact ? { "x-calo-artifact": encodeURIComponent(JSON.stringify(artifact)) } : {}),
215
216
  ...(force ? { "x-calo-force": "1" } : {}),
216
217
  },
217
218
  body: tgz,
@@ -93,11 +93,28 @@ let easEnv = process.env;
93
93
  const easRun = (argv, opts = {}) => { const [b, ...p] = easPrefix(); return run(b, [...p, ...argv], { env: easEnv, ...opts }); };
94
94
  const easCapture = (argv, opts = {}) => { const [b, ...p] = easPrefix(); return capture(b, [...p, ...argv], { env: easEnv, ...opts }); };
95
95
 
96
+ // Pull the EAS update GROUP id (the immutable per-version ref checkpoints record) out
97
+ // of a `capture()` result from `eas update:list --json`. Regex, not JSON.parse: eas
98
+ // output shapes and banner noise vary by version, and this must stay best-effort.
99
+ function extractEasGroup(captured) {
100
+ if (!captured || captured.status !== 0 || typeof captured.out !== "string") return undefined;
101
+ const found = captured.out.match(/"group":\s*"([0-9a-fA-F-]{16,64})"/);
102
+ return found ? found[1] : undefined;
103
+ }
104
+
105
+ // The per-version EAS branch/channel name. Deterministic from (slug, group) so any
106
+ // party — CLI, broker page, Mirror launcher — can derive it, but it's also stored in
107
+ // artifact.easBranch so the convention can evolve without re-deriving. EAS channel
108
+ // names share the slug charset; slug ≤63 + "--g" + 8 = ≤74, under EAS's limits.
109
+ const versionBranchOf = (slug, group) => `${slug}--g${String(group).slice(0, 8).toLowerCase()}`;
110
+
96
111
  function runtimeDir() {
97
112
  const home = process.env.DESIGNCHEF_HOME || path.join(os.homedir(), ".designchef");
98
113
  return path.join(home, "runtime");
99
114
  }
100
- const slugify = (s) => String(s).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "prototype";
115
+ // Capped at 63 (SLUG_RE's bound everywhere else) an uncapped slug would silently
116
+ // break the checkpoint stamp and overflow EAS channel-name limits on version pins.
117
+ const slugify = (s) => String(s).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63).replace(/-+$/, "") || "prototype";
101
118
  const titleize = (s) => String(s).replace(/[-_]+/g, " ").replace(/\b\w/g, (m) => m.toUpperCase());
102
119
 
103
120
  function appDir(root) {
@@ -839,12 +856,49 @@ async function cmdPush(args) {
839
856
  log(c.b("\n✨ Live in the Mirror.") + " Open Calo Mirror and tap Refresh — your prototype is at the top.");
840
857
 
841
858
  // 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.)
859
+ // the thing on the phone and its exact source are permanently paired. The artifact
860
+ // records the EAS update GROUP id — the immutable per-version bundle so a future
861
+ // Mirror shell can load exactly this version (channel head keeps moving). Group id
862
+ // capture is best-effort (a quick update:list after the publish); so is the whole
863
+ // stamp — never fails the push. (Lazy require: mirror-push is also loaded by art-push.)
864
+ let easGroup;
865
+ let easBranch;
866
+ try {
867
+ // easCapture → { status, out }. Tolerant extraction: eas --json shapes (and
868
+ // banner noise) vary by version, so regex the output for the first uuid-shaped
869
+ // `group` value instead of parsing. Known race, accepted as best-effort: this
870
+ // lists the branch head, so a concurrent push to the same slug in this window
871
+ // could stamp the other push's group — rare, and a stamp is advisory metadata.
872
+ easGroup = extractEasGroup(easCapture(["update:list", "--branch", slug, "--limit", "1", "--json", "--non-interactive"], { cwd: stage }));
873
+ if (easGroup) {
874
+ // Pin this exact version behind its own channel: `slug--g<group[:8]>`.
875
+ // republish is a metadata copy (no rebundle); ensureChannel links channel →
876
+ // branch of the same name, which is what the Mirror's channel-name header
877
+ // switch loads. This is what makes "open v3 on your phone" possible after
878
+ // the channel head (slug) has moved on. Best-effort like the whole stamp.
879
+ const vb = versionBranchOf(slug, easGroup);
880
+ ensureChannel(stage, vb);
881
+ const rp = easCapture(
882
+ ["update:republish", "--group", easGroup, "--destination-branch", vb, "-m", `pin ${slug} @ ${easGroup.slice(0, 8)}`, "--non-interactive"],
883
+ { cwd: stage }
884
+ );
885
+ if (rp.status === 0) easBranch = vb;
886
+ else warn(`version pin skipped (republish → ${rp.out.trim().split("\n").slice(-1)[0] || rp.status})`);
887
+ }
888
+ } catch {
889
+ /* no artifact ref — the stamp still records source + deep link */
890
+ }
844
891
  await require("./checkpoints").autoCheckpoint({
845
892
  root: process.cwd(),
846
893
  note: flag(args, "note") || `pushed to the Mirror (${slug})`,
847
894
  publishedUrl: `designchef://open/${slug}`,
895
+ artifact: {
896
+ kind: "mirror",
897
+ channel: slug,
898
+ runtimeVersion: RUNTIME_VERSION,
899
+ ...(easGroup ? { easGroup } : {}),
900
+ ...(easBranch ? { easBranch } : {}),
901
+ },
848
902
  });
849
903
 
850
904
  // Share target: the deep link the Mirror scanner (and iOS camera) opens.
@@ -865,4 +919,4 @@ async function cmdPush(args) {
865
919
  }
866
920
  }
867
921
 
868
- module.exports = { cmdPush, _s3Put: s3Put, _upsertRegistry: upsertRegistry, _publicBase: PUBLIC_BASE };
922
+ module.exports = { cmdPush, _s3Put: s3Put, _upsertRegistry: upsertRegistry, _publicBase: PUBLIC_BASE, _extractEasGroup: extractEasGroup, _versionBranchOf: versionBranchOf };
package/bin/share.js CHANGED
@@ -168,10 +168,17 @@ async function cmdShare(args = []) {
168
168
  log(c.dim(` Re-run \`calo-design share\` to update “${slug}” in place. Share the link above.`));
169
169
  }
170
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 });
171
+ // Deploy-stamped versioning: every successful share leaves a source checkpoint, so
172
+ // the deployed thing and its exact source are permanently paired. publishedUrl
173
+ // prefers the IMMUTABLE per-deploy URL (Cloudflare Pages keeps every deployment
174
+ // forever) so the version page opens exactly this version, not whatever the alias
175
+ // points at by then; the artifact keeps both. Best-effort — never fails the share.
176
+ await autoCheckpoint({
177
+ root,
178
+ note: flag(args, "note") || `shared to the web (${slug})`,
179
+ publishedUrl: r.version || url,
180
+ artifact: { kind: "web", aliasUrl: url, ...(r.version ? { deployUrl: r.version } : {}) },
181
+ });
175
182
  } finally {
176
183
  try { fs.rmSync(tgz); } catch {}
177
184
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calo-design/cli",
3
- "version": "0.10.0",
3
+ "version": "0.12.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"