@mutmutco/cli 2.25.0 → 2.26.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.
Files changed (2) hide show
  1. package/dist/main.cjs +74 -24
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -10587,6 +10587,32 @@ async function planPush(deps, slug, opts = {}) {
10587
10587
  deps.log(`queued ${slug} for background push \u2014 expected, not a failure (\`mmi-cli northstar status\` to check; \`mmi-cli northstar sync\` or --wait for durable confirmation)`);
10588
10588
  return true;
10589
10589
  }
10590
+ function buildPutBody(project2, slug, content, opts = {}) {
10591
+ const body = { project: project2, slug, content };
10592
+ const frontmatterMeta = extractPlanMeta(content);
10593
+ if (Object.keys(frontmatterMeta).length) body.meta = frontmatterMeta;
10594
+ if (opts.force) body.force = true;
10595
+ else if (opts.baseEtag) body.baseEtag = opts.baseEtag;
10596
+ return body;
10597
+ }
10598
+ async function reconcilePushConflict(deps, project2, slug, content, baseHash) {
10599
+ const qs = new URLSearchParams({ project: project2, slug }).toString();
10600
+ const pull = await deps.fetch(`${deps.apiUrl}/plan/get?${qs}`, { method: "GET", headers: await deps.headers(), signal: AbortSignal.timeout(TIMEOUT_MS) }).catch(() => null);
10601
+ const doc = pull?.ok ? await pull.json() : null;
10602
+ if (!doc) return null;
10603
+ const remote = normalizeEol(doc.content ?? "");
10604
+ if (remote === content) return { etag: doc.etag };
10605
+ if (baseHash && hashContent(remote) === baseHash) {
10606
+ const res = await deps.fetch(`${deps.apiUrl}/plan/put`, {
10607
+ method: "POST",
10608
+ headers: await deps.headers({ "content-type": "application/json" }),
10609
+ body: JSON.stringify(buildPutBody(project2, slug, content, { baseEtag: doc.etag })),
10610
+ signal: AbortSignal.timeout(TIMEOUT_MS)
10611
+ }).catch(() => null);
10612
+ if (res?.ok) return await res.json();
10613
+ }
10614
+ return null;
10615
+ }
10590
10616
  async function planPushNow(deps, slug, opts = {}) {
10591
10617
  const raw = deps.readLocal(slug);
10592
10618
  if (raw == null) {
@@ -10597,25 +10623,21 @@ async function planPushNow(deps, slug, opts = {}) {
10597
10623
  const project2 = opts.project ?? await deps.project();
10598
10624
  const meta = parseMeta(deps.readMetaRaw());
10599
10625
  const entry = meta[metaKey(project2, slug)];
10600
- const body = { project: project2, slug, content };
10601
- const frontmatterMeta = extractPlanMeta(content);
10602
- if (Object.keys(frontmatterMeta).length) body.meta = frontmatterMeta;
10603
- if (opts.force) body.force = true;
10604
- else if (entry?.etag) body.baseEtag = entry.etag;
10605
10626
  const res = await deps.fetch(`${deps.apiUrl}/plan/put`, {
10606
10627
  method: "POST",
10607
10628
  headers: await deps.headers({ "content-type": "application/json" }),
10608
- body: JSON.stringify(body),
10629
+ body: JSON.stringify(buildPutBody(project2, slug, content, { force: opts.force, baseEtag: entry?.etag })),
10609
10630
  signal: AbortSignal.timeout(TIMEOUT_MS)
10610
10631
  });
10611
- if (res.ok) {
10612
- const out = await res.json();
10613
- meta[metaKey(project2, slug)] = { etag: out.etag, hash: hashContent(content), syncedAt: deps.now() };
10614
- deps.writeMetaRaw(serializeMeta(meta));
10615
- dropQueued(deps, project2, slug);
10616
- deps.log(`pushed ${slug}`);
10617
- return true;
10618
- } else if (res.status === 409) {
10632
+ if (res.ok || res.status === 409) {
10633
+ const adopted = res.ok ? await res.json() : await reconcilePushConflict(deps, project2, slug, content, entry?.hash);
10634
+ if (adopted) {
10635
+ meta[metaKey(project2, slug)] = { etag: adopted.etag, hash: hashContent(content), syncedAt: deps.now() };
10636
+ deps.writeMetaRaw(serializeMeta(meta));
10637
+ dropQueued(deps, project2, slug);
10638
+ deps.log(`pushed ${slug}`);
10639
+ return true;
10640
+ }
10619
10641
  deps.err(staleHint(slug));
10620
10642
  return false;
10621
10643
  } else {
@@ -10766,11 +10788,7 @@ async function planSync(deps, opts = {}) {
10766
10788
  const content = normalizeEol(raw);
10767
10789
  const meta = parseMeta(deps.readMetaRaw());
10768
10790
  const metaEntry = meta[metaKey(entry.project, entry.slug)];
10769
- const body = { project: entry.project, slug: entry.slug, content };
10770
- const frontmatterMeta = extractPlanMeta(content);
10771
- if (Object.keys(frontmatterMeta).length) body.meta = frontmatterMeta;
10772
- if (entry.force) body.force = true;
10773
- else if (metaEntry?.etag) body.baseEtag = metaEntry.etag;
10791
+ const body = buildPutBody(entry.project, entry.slug, content, { force: entry.force, baseEtag: metaEntry?.etag });
10774
10792
  let res;
10775
10793
  try {
10776
10794
  res = await deps.fetch(`${deps.apiUrl}/plan/put`, {
@@ -10789,12 +10807,11 @@ async function planSync(deps, opts = {}) {
10789
10807
  deps.writeMetaRaw(serializeMeta(meta));
10790
10808
  if (!opts.quiet) deps.log(`pushed ${entry.slug}`);
10791
10809
  } else if (res.status === 409) {
10792
- const qs = new URLSearchParams({ project: entry.project, slug: entry.slug }).toString();
10793
- const pull = await deps.fetch(`${deps.apiUrl}/plan/get?${qs}`, { method: "GET", headers: await deps.headers(), signal: AbortSignal.timeout(TIMEOUT_MS) }).catch(() => null);
10794
- const doc = pull?.ok ? await pull.json() : null;
10795
- if (doc && normalizeEol(doc.content ?? "") === content) {
10796
- meta[metaKey(entry.project, entry.slug)] = { etag: doc.etag, hash: hashContent(content), syncedAt: deps.now() };
10810
+ const adopted = await reconcilePushConflict(deps, entry.project, entry.slug, content, metaEntry?.hash);
10811
+ if (adopted) {
10812
+ meta[metaKey(entry.project, entry.slug)] = { etag: adopted.etag, hash: hashContent(content), syncedAt: deps.now() };
10797
10813
  deps.writeMetaRaw(serializeMeta(meta));
10814
+ if (!opts.quiet) deps.log(`pushed ${entry.slug}`);
10798
10815
  } else {
10799
10816
  kept.push({ ...entry, attempts: entry.attempts + 1, conflict: staleHint(entry.slug) });
10800
10817
  }
@@ -10822,6 +10839,38 @@ async function planStatus(deps) {
10822
10839
  deps.log(idx ? `plan index: ${idx.plans.length} plans, fetched ${idx.fetchedAt}` : "plan index: cold (first read will fetch)");
10823
10840
  if (queue.some((e) => e.conflict || e.deadLettered)) process.exitCode = 1;
10824
10841
  }
10842
+ async function planReconcile(deps) {
10843
+ const meta = parseMeta(deps.readMetaRaw());
10844
+ let refreshed = 0;
10845
+ let diverged = 0;
10846
+ let unreachable = 0;
10847
+ for (const [key, entry] of Object.entries(meta)) {
10848
+ const i = key.indexOf("/");
10849
+ if (i < 0) continue;
10850
+ const project2 = key.slice(0, i);
10851
+ const slug = key.slice(i + 1);
10852
+ const qs = new URLSearchParams({ project: project2, slug }).toString();
10853
+ const res = await deps.fetch(`${deps.apiUrl}/plan/get?${qs}`, { method: "GET", headers: await deps.headers(), signal: AbortSignal.timeout(TIMEOUT_MS) }).catch(() => null);
10854
+ if (!res || !res.ok) {
10855
+ unreachable++;
10856
+ continue;
10857
+ }
10858
+ const doc = await res.json();
10859
+ const remote = normalizeEol(doc.content ?? "");
10860
+ if (!entry.hash || hashContent(remote) !== entry.hash) {
10861
+ diverged++;
10862
+ continue;
10863
+ }
10864
+ if (doc.etag && doc.etag !== entry.etag) {
10865
+ meta[key] = { ...entry, etag: doc.etag, syncedAt: deps.now() };
10866
+ refreshed++;
10867
+ }
10868
+ }
10869
+ if (refreshed) deps.writeMetaRaw(serializeMeta(meta));
10870
+ deps.log(
10871
+ `reconcile: ${refreshed} etag(s) refreshed` + (diverged ? `, ${diverged} diverged (pull or push them)` : "") + (unreachable ? `, ${unreachable} unreachable` : "")
10872
+ );
10873
+ }
10825
10874
  async function planGraduate(deps, slug, opts = {}) {
10826
10875
  if (!opts.orgVisible) {
10827
10876
  deps.err("refusing to mark an org-visible graduation without --org-visible");
@@ -11913,6 +11962,7 @@ function registerNorthStarCommands(cmd) {
11913
11962
  }));
11914
11963
  cmd.command("sync").description("drain queued background pushes and refresh the local plan index").option("--quiet", "silent (background worker)").action((o) => withPlan(o.quiet ?? false, (d) => planSync(d, o)));
11915
11964
  cmd.command("status").description("show pending/conflicted background pushes and the plan-cache age").action(() => withPlan(false, (d) => planStatus(d)));
11965
+ cmd.command("reconcile").description("refresh stale local etags from the server without --force (recovers from an object-store re-stamp)").action(() => withPlan(false, (d) => planReconcile(d)));
11916
11966
  cmd.command("open <slug>").description("pull if needed, then open plans/<slug>.md in $EDITOR").option("--project <name>", "override the project key").action(
11917
11967
  (slug, o) => withPlan(false, async (d) => {
11918
11968
  const ok = await planPull(d, slug, { project: o.project });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "2.25.0",
3
+ "version": "2.26.0",
4
4
  "description": "MMI Future CLI — delivers the org rules (whole-file), plus saga and KB access. The cross-IDE engine the plugin's SessionStart hook drives.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",