@myna-sh/cli 0.7.0 → 0.9.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/dist/main.js CHANGED
@@ -130,10 +130,10 @@ var HttpClient = class {
130
130
  * while in flight — this is request coalescing, not a cache.
131
131
  */
132
132
  inFlight = /* @__PURE__ */ new Map();
133
- dedupe(key, run) {
133
+ dedupe(key, run2) {
134
134
  const existing = this.inFlight.get(key);
135
135
  if (existing) return existing.then((response) => response.clone());
136
- const started = run();
136
+ const started = run2();
137
137
  this.inFlight.set(key, started);
138
138
  const cleanup = () => {
139
139
  if (this.inFlight.get(key) === started) this.inFlight.delete(key);
@@ -468,6 +468,16 @@ var ManagementClient = class {
468
468
  collection ? { origin, collection } : { origin },
469
469
  signal
470
470
  ),
471
+ /**
472
+ * Why a call into this project would be denied: whether the credential
473
+ * holds a capability, whether it is confined to other collections, and
474
+ * whether the collection exists at all.
475
+ */
476
+ access: (project, opts = {}, signal) => this.get(
477
+ `/projects/${enc(project)}/access`,
478
+ { capability: opts.capability, collection: opts.collection },
479
+ signal
480
+ ),
471
481
  views: (project, signal) => this.get(`/projects/${enc(project)}/views`, void 0, signal),
472
482
  createView: (project, body) => this.mutate("POST", `/projects/${enc(project)}/views`, body),
473
483
  deleteView: (project, view) => this.mutate("DELETE", `/projects/${enc(project)}/views/${enc(view)}`)
@@ -590,12 +600,40 @@ var ManagementClient = class {
590
600
  body
591
601
  ),
592
602
  /** Field-level before/after for everything this change set would change. */
603
+ /**
604
+ * Replay the change set's edits on top of what is published now.
605
+ *
606
+ * Refuses by default when a field changed on both sides; pass
607
+ * `onConflict` to take one side, or `dryRun` to see the plan first.
608
+ */
609
+ rebase: (project, changeSet, body = {}) => this.mutate(
610
+ "POST",
611
+ `/projects/${enc(project)}/change-sets/${enc(changeSet)}/rebase`,
612
+ body
613
+ ),
593
614
  diff: (project, changeSet, signal) => this.get(
594
615
  `/projects/${enc(project)}/change-sets/${enc(changeSet)}/diff`,
595
616
  void 0,
596
617
  signal
597
618
  )
598
619
  };
620
+ // --- Releases -------------------------------------------------------------
621
+ /**
622
+ * What shipped, and how to take it back.
623
+ *
624
+ * `revert` returns an open change set rather than publishing: it goes through
625
+ * the same review, preview, and gate path as anything written by hand.
626
+ */
627
+ releases = {
628
+ list: (project, opts = {}) => this.page(`/projects/${enc(project)}/releases`, opts),
629
+ /** `release` is a release number or the published change set's id. */
630
+ get: (project, release, signal) => this.get(`/projects/${enc(project)}/releases/${enc(String(release))}`, void 0, signal),
631
+ revert: (project, release, body = {}) => this.mutate(
632
+ "POST",
633
+ `/projects/${enc(project)}/releases/${enc(String(release))}/revert`,
634
+ body
635
+ )
636
+ };
599
637
  // --- Declared external checks ---------------------------------------------
600
638
  checks = {
601
639
  list: (project, signal) => this.get(`/projects/${enc(project)}/checks`, void 0, signal),
@@ -621,6 +659,19 @@ var ManagementClient = class {
621
659
  }),
622
660
  get: (project, asset, signal) => this.get(`/projects/${enc(project)}/assets/${enc(asset)}`, void 0, signal),
623
661
  usage: (project, asset, signal) => this.get(`/projects/${enc(project)}/assets/${enc(asset)}`, { usage: "true" }, signal),
662
+ /**
663
+ * Assets that look like the same picture, by perceptual hash — a resized or
664
+ * re-encoded copy, which `checksum` cannot see. `maxDistance` is in bits out
665
+ * of 64 (default 5, maximum 16).
666
+ */
667
+ similar: (project, asset, opts = {}, signal) => this.get(
668
+ `/projects/${enc(project)}/assets/${enc(asset)}/similar`,
669
+ {
670
+ maxDistance: opts.maxDistance === void 0 ? void 0 : String(opts.maxDistance),
671
+ limit: opts.limit === void 0 ? void 0 : String(opts.limit)
672
+ },
673
+ signal
674
+ ).then((r) => r.similar),
624
675
  update: (project, asset, body) => this.mutate("PATCH", `/projects/${enc(project)}/assets/${enc(asset)}`, body),
625
676
  replace: (project, asset, body) => this.mutate("POST", `/projects/${enc(project)}/assets/${enc(asset)}/replace`, body),
626
677
  delete: (project, asset) => this.mutate("DELETE", `/projects/${enc(project)}/assets/${enc(asset)}`),
@@ -1166,7 +1217,33 @@ function startDevice(apiUrl) {
1166
1217
  function pollDevice(apiUrl, deviceCode) {
1167
1218
  return apiPost(apiUrl, `/auth/device/${deviceCode}/token`, { deviceCode });
1168
1219
  }
1220
+ function registerAccess(program) {
1221
+ program.command("access").description("Explain what this credential may do in a project, and why a call was denied").option("--capability <cap>", "capability to check, e.g. content:publish").option("--collection <key>", "collection the denied call touched").action(
1222
+ handle(async (ctx, _args, opts) => {
1223
+ const project = ctx.requireProject();
1224
+ const access = await ctx.management().projects.access(project, {
1225
+ capability: opts.capability,
1226
+ collection: opts.collection
1227
+ });
1228
+ emit(access, () => {
1229
+ keyValues([
1230
+ ["Project", access.projectSlug],
1231
+ ["Credential", access.credential + (access.role ? ` (${access.role})` : "")],
1232
+ ["Capabilities", access.capabilities.join(", ") || "(none)"],
1233
+ ["Collections", access.allowedCollections?.join(", ") ?? "all"]
1234
+ ]);
1235
+ for (const check2 of access.checks) {
1236
+ diag(` ${check2.ok ? "ok " : "fail"} ${check2.id} \u2014 ${check2.detail}`);
1237
+ }
1238
+ if (access.remedy) diag(`
1239
+ remedy: ${access.remedy}`);
1240
+ });
1241
+ if (!access.allowed) process.exitCode = 1;
1242
+ })
1243
+ );
1244
+ }
1169
1245
  function registerAuth(program) {
1246
+ registerAccess(program);
1170
1247
  const login = program.command("login").description("Authenticate via the browser device-authorization flow").action(
1171
1248
  handle(async (ctx) => {
1172
1249
  const start = await startDevice(ctx.apiUrl);
@@ -1455,7 +1532,7 @@ function registerWorkspace(program) {
1455
1532
  }
1456
1533
 
1457
1534
  // src/commands/schema.ts
1458
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
1535
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
1459
1536
  import { join as join4 } from "path";
1460
1537
 
1461
1538
  // src/schema-loader.ts
@@ -1689,6 +1766,89 @@ function varName(name) {
1689
1766
  return /^[A-Za-z_$]/.test(camel) ? camel : `_${camel}`;
1690
1767
  }
1691
1768
 
1769
+ // src/git.ts
1770
+ import { execFileSync as execFileSync2 } from "child_process";
1771
+ import { createHash as createHash2 } from "crypto";
1772
+ function run(command, args, cwd) {
1773
+ try {
1774
+ return execFileSync2(command, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
1775
+ } catch (error) {
1776
+ const stderr = error.stderr;
1777
+ const detail = typeof stderr === "string" ? stderr : stderr?.toString("utf8");
1778
+ throw new CliError(`${command} ${args[0]} failed: ${(detail || error.message).trim()}`);
1779
+ }
1780
+ }
1781
+ function git(args, cwd) {
1782
+ return run("git", args, cwd).trim();
1783
+ }
1784
+ function gitQuiet(args, cwd) {
1785
+ try {
1786
+ execFileSync2("git", args, { cwd, stdio: "ignore" });
1787
+ } catch {
1788
+ }
1789
+ }
1790
+ function available(command) {
1791
+ try {
1792
+ execFileSync2(command, ["--version"], { stdio: "ignore" });
1793
+ return true;
1794
+ } catch {
1795
+ return false;
1796
+ }
1797
+ }
1798
+ function branchForContent(contents) {
1799
+ const digest = createHash2("sha256").update(contents.join("\0")).digest("hex").slice(0, 8);
1800
+ return `myna/schema-${digest}`;
1801
+ }
1802
+ function openPullRequest(input) {
1803
+ if (!available("git")) throw new CliError("git is required to open a pull request.");
1804
+ if (!available("gh")) {
1805
+ throw new CliError("The GitHub CLI (gh) is required to open a pull request. See https://cli.github.com.");
1806
+ }
1807
+ if (git(["rev-parse", "--is-inside-work-tree"], input.cwd) !== "true") {
1808
+ throw new CliError(`${input.cwd} is not inside a git repository.`);
1809
+ }
1810
+ const base = input.base ?? git(["rev-parse", "--abbrev-ref", "HEAD"], input.cwd);
1811
+ if (base === "HEAD") {
1812
+ throw new CliError("HEAD is detached; pass --base to say which branch to open the pull request against.");
1813
+ }
1814
+ const exists = (() => {
1815
+ try {
1816
+ execFileSync2("git", ["rev-parse", "--verify", `refs/heads/${input.branch}`], {
1817
+ cwd: input.cwd,
1818
+ stdio: "ignore"
1819
+ });
1820
+ return true;
1821
+ } catch {
1822
+ return false;
1823
+ }
1824
+ })();
1825
+ if (exists) return { opened: false, branch: input.branch, base, url: null, reason: "branch-exists" };
1826
+ git(["checkout", "-b", input.branch], input.cwd);
1827
+ let pushed = false;
1828
+ try {
1829
+ git(["add", "--", ...input.files], input.cwd);
1830
+ git(["commit", "-m", input.title, "-m", input.body], input.cwd);
1831
+ git(["push", "--set-upstream", "origin", input.branch], input.cwd);
1832
+ pushed = true;
1833
+ const url = run(
1834
+ "gh",
1835
+ ["pr", "create", "--base", base, "--head", input.branch, "--title", input.title, "--body", input.body],
1836
+ input.cwd
1837
+ ).trim().split("\n").filter((line) => line.startsWith("http")).pop();
1838
+ git(["checkout", base], input.cwd);
1839
+ return { opened: true, branch: input.branch, base, url: url ?? null };
1840
+ } catch (error) {
1841
+ gitQuiet(["checkout", base], input.cwd);
1842
+ if (pushed) gitQuiet(["push", "origin", "--delete", input.branch], input.cwd);
1843
+ gitQuiet(["branch", "-D", input.branch], input.cwd);
1844
+ throw error;
1845
+ }
1846
+ }
1847
+ function dirtyFiles(cwd, paths) {
1848
+ if (paths.length === 0) return [];
1849
+ return run("git", ["status", "--porcelain", "--", ...paths], cwd).split("\n").filter(Boolean).map((line) => line.slice(3).trim());
1850
+ }
1851
+
1692
1852
  // src/commands/schema.ts
1693
1853
  async function deployedSchemas(ctx, project) {
1694
1854
  const mgmt = ctx.management();
@@ -1734,21 +1894,59 @@ function registerSchema(program) {
1734
1894
  });
1735
1895
  })
1736
1896
  );
1737
- schema.command("pull").description("Write deployed schemas to local DSL files").option("--schema-dir <dir>", "target schema directory").action(
1897
+ schema.command("pull").description("Write deployed schemas to local DSL files").option("--schema-dir <dir>", "target schema directory").option("--open-pr", "commit the drift onto a branch and open a pull request instead of writing in place", false).option("--branch <name>", "branch to use with --open-pr (default: derived from the content)").option("--base <branch>", "branch to open the pull request against (default: the current branch)").action(
1738
1898
  handle(async (ctx, _args, opts) => {
1739
1899
  const project = ctx.requireProject();
1740
1900
  const dir = schemaDirFor(ctx.linkedRoot, opts.schemaDir);
1741
1901
  const schemas = await deployedSchemas(ctx, project);
1742
- mkdirSync3(dir, { recursive: true });
1743
- const written = [];
1744
- for (const s of schemas) {
1745
- const file = join4(dir, `${s.name}.ts`);
1746
- writeFileSync3(file, schemaToDsl(s));
1747
- written.push(file);
1748
- }
1749
- emit({ pulled: schemas.length, files: written }, () => {
1750
- for (const f of written) process.stdout.write(` wrote ${f}
1902
+ const rendered = schemas.map((s) => ({ file: join4(dir, `${s.name}.ts`), code: schemaToDsl(s) }));
1903
+ const changed = rendered.filter(
1904
+ (r) => !existsSync4(r.file) || readFileSync3(r.file, "utf8") !== r.code
1905
+ );
1906
+ const write = () => {
1907
+ mkdirSync3(dir, { recursive: true });
1908
+ for (const r of rendered) writeFileSync3(r.file, r.code);
1909
+ return rendered.map((r) => r.file);
1910
+ };
1911
+ if (!opts.openPr) {
1912
+ const written = write();
1913
+ emit({ pulled: schemas.length, files: written, changed: changed.map((c) => c.file) }, () => {
1914
+ for (const f of written) process.stdout.write(` wrote ${f}
1751
1915
  `);
1916
+ });
1917
+ return;
1918
+ }
1919
+ if (changed.length === 0) {
1920
+ emit(
1921
+ { pulled: schemas.length, changed: [], pullRequest: null },
1922
+ () => diag("No drift: the local schema files already match the deployed schema.")
1923
+ );
1924
+ return;
1925
+ }
1926
+ const root = ctx.linkedRoot ?? process.cwd();
1927
+ const dirty = dirtyFiles(root, [dir]);
1928
+ if (dirty.length > 0) {
1929
+ throw new UsageError(
1930
+ `Commit or stash changes under ${dir} first: ${dirty.join(", ")}.`
1931
+ );
1932
+ }
1933
+ const body = await driftBody(ctx, project, dir, changed.map((c) => c.file));
1934
+ write();
1935
+ const branch = opts.branch ?? branchForContent(changed.map((c) => c.code));
1936
+ const result = openPullRequest({
1937
+ cwd: root,
1938
+ files: changed.map((c) => c.file),
1939
+ branch,
1940
+ base: opts.base,
1941
+ title: `chore(schema): sync ${changed.length} collection(s) from ${project}`,
1942
+ body
1943
+ });
1944
+ emit({ pulled: schemas.length, changed: changed.map((c) => c.file), pullRequest: result }, () => {
1945
+ if (!result.opened) {
1946
+ diag(`Branch ${result.branch} already exists \u2014 this drift is already proposed.`);
1947
+ return;
1948
+ }
1949
+ diag(`Opened ${result.url ?? `pull request from ${result.branch}`} against ${result.base}.`);
1752
1950
  });
1753
1951
  })
1754
1952
  );
@@ -1866,6 +2064,29 @@ function registerSchema(program) {
1866
2064
  })
1867
2065
  );
1868
2066
  }
2067
+ async function driftBody(ctx, project, dir, files) {
2068
+ const lines = [
2069
+ `Deployed schema for \`${project}\`, pulled into this repository by \`myna schema pull --open-pr\`.`,
2070
+ "",
2071
+ "Files:",
2072
+ ...files.map((f) => `- \`${f}\``)
2073
+ ];
2074
+ try {
2075
+ const local = await loadLocalSchemas(dir);
2076
+ const diff = await ctx.management().schema.diff(project, canonicalJson(local));
2077
+ if (diff.ops.length > 0) {
2078
+ lines.push(
2079
+ "",
2080
+ `Before this change, pushing this repository would have applied ${diff.ops.length} operation(s) (${diff.classification}):`,
2081
+ "",
2082
+ ...diff.ops.map((op) => `- \`${op.kind}\` ${op.collection}${op.field ? `.${op.field}` : ""} \u2014 ${op.detail}`)
2083
+ );
2084
+ }
2085
+ } catch {
2086
+ lines.push("", "_The local schema could not be diffed; review the file changes directly._");
2087
+ }
2088
+ return lines.join("\n");
2089
+ }
1869
2090
  function renderDiff(diff) {
1870
2091
  if (diff.ops.length === 0) {
1871
2092
  diag("No changes.");
@@ -2454,11 +2675,46 @@ function registerChanges(program) {
2454
2675
  { header: "BEFORE", value: (c) => preview(c.before) },
2455
2676
  { header: "AFTER", value: (c) => preview(c.after) }
2456
2677
  ]);
2678
+ for (const change of item.changes) {
2679
+ if (change.segments) diag(` ${change.path}: ${inlineWords(change.segments)}`);
2680
+ }
2457
2681
  }
2458
2682
  if (diff.items.length === 0) diag("No items.");
2459
2683
  });
2460
2684
  })
2461
2685
  );
2686
+ changes.command("rebase").description("Replay a change set's edits on top of what is published now").argument("<id>", "change set id").option("--dry-run", "show what a rebase would do without writing", false).option("--take-mine", "on conflict, keep this change set's value", false).option("--take-theirs", "on conflict, accept what was published", false).action(
2687
+ handle(async (ctx, args, opts) => {
2688
+ const project = ctx.requireProject();
2689
+ if (opts.takeMine && opts.takeTheirs) {
2690
+ throw new UsageError("Provide at most one of --take-mine or --take-theirs.");
2691
+ }
2692
+ const result = await ctx.management().changeSets.rebase(project, args[0], {
2693
+ ...opts.dryRun ? { dryRun: true } : {},
2694
+ ...opts.takeMine ? { onConflict: "take-mine" } : {},
2695
+ ...opts.takeTheirs ? { onConflict: "take-theirs" } : {}
2696
+ });
2697
+ emit(result, () => {
2698
+ if (result.items.length === 0) {
2699
+ diag(`Nothing to rebase; ${result.upToDate.length} item(s) already on the current head.`);
2700
+ return;
2701
+ }
2702
+ for (const item of result.items) {
2703
+ const label = item.collection && item.slug ? `${item.collection}/${item.slug}` : item.entryId;
2704
+ diag(`${label} \u2014 ${item.appliedPaths.length} field(s) replayed, ${item.conflicts.length} conflict(s)`);
2705
+ if (item.conflicts.length > 0) {
2706
+ table(item.conflicts, [
2707
+ { header: "FIELD", value: (c) => c.path },
2708
+ { header: "MINE", value: (c) => preview(c.mine) },
2709
+ { header: "THEIRS", value: (c) => preview(c.theirs) }
2710
+ ]);
2711
+ }
2712
+ }
2713
+ diag(result.dryRun ? "Dry run \u2014 nothing written." : "Rebased. Approvals are now stale; re-review.");
2714
+ });
2715
+ if (result.items.some((i) => i.conflicts.length > 0) && result.dryRun) process.exitCode = 1;
2716
+ })
2717
+ );
2462
2718
  changes.command("update").description("Retitle a change set or mark it ready for review").argument("<id>", "change set id").option("--title <title>", "new title").option("--description <text>", "new description").option("--ready", "mark ready for review", false).action(
2463
2719
  handle(async (ctx, args, opts) => {
2464
2720
  const project = ctx.requireProject();
@@ -2503,6 +2759,15 @@ function registerChanges(program) {
2503
2759
  })
2504
2760
  );
2505
2761
  }
2762
+ function inlineWords(segments) {
2763
+ const KEEP = 32;
2764
+ return segments.map((s) => {
2765
+ const flat = s.value.replaceAll(/\s+/g, " ");
2766
+ if (s.op === "insert") return `{+${flat}+}`;
2767
+ if (s.op === "delete") return `[-${flat}-]`;
2768
+ return flat.length > KEEP * 2 ? `${flat.slice(0, KEEP)} \u2026 ${flat.slice(-KEEP)}` : flat;
2769
+ }).join("");
2770
+ }
2506
2771
  function preview(value) {
2507
2772
  if (value === void 0 || value === null) return "\u2014";
2508
2773
  const text = typeof value === "string" ? value : JSON.stringify(value);
@@ -2510,6 +2775,75 @@ function preview(value) {
2510
2775
  return flat.length > 48 ? `${flat.slice(0, 47)}\u2026` : flat;
2511
2776
  }
2512
2777
 
2778
+ // src/commands/releases.ts
2779
+ function registerReleases(program) {
2780
+ const releases = program.command("releases").description("Inspect what shipped and generate a revert");
2781
+ releases.command("list").description("List releases, newest first").option("--limit <n>", "page size", "25").action(
2782
+ handle(async (ctx, _args, opts) => {
2783
+ const project = ctx.requireProject();
2784
+ const page = await ctx.management().releases.list(project, { limit: Number(opts.limit) });
2785
+ emit(
2786
+ { data: page.data, nextCursor: page.nextCursor },
2787
+ () => table(page.data, [
2788
+ { header: "#", value: (r) => String(r.number) },
2789
+ { header: "TITLE", value: (r) => r.title },
2790
+ { header: "PUBLISHED", value: (r) => r.publishedAt },
2791
+ { header: "BY", value: (r) => r.publishedBy?.displayName ?? "\u2014" },
2792
+ { header: "ITEMS", value: (r) => String(r.itemCount) },
2793
+ {
2794
+ header: "STATE",
2795
+ value: (r) => r.revertedBy ? r.revertedBy.status === "published" ? `reverted by #${r.revertedBy.releaseNumber ?? "?"}` : `revert ${r.revertedBy.status}` : r.revertsReleaseNumber ? `reverts #${r.revertsReleaseNumber}` : "live"
2796
+ }
2797
+ ])
2798
+ );
2799
+ })
2800
+ );
2801
+ releases.command("inspect").description("Show one release").argument("<release>", "release number or published change set id").action(
2802
+ handle(async (ctx, args) => {
2803
+ const project = ctx.requireProject();
2804
+ const release = await ctx.management().releases.get(project, args[0]);
2805
+ emit(release, () => {
2806
+ diag(`#${release.number} \u2014 ${release.title}`);
2807
+ diag(`published ${release.publishedAt} by ${release.publishedBy?.displayName ?? "unknown"}`);
2808
+ diag(`${release.itemCount} item(s), change set ${release.id}`);
2809
+ if (release.revertsReleaseNumber) diag(`reverts release #${release.revertsReleaseNumber}`);
2810
+ if (release.revertedBy) {
2811
+ diag(`revert ${release.revertedBy.status}: change set ${release.revertedBy.changeSetId}`);
2812
+ }
2813
+ });
2814
+ })
2815
+ );
2816
+ releases.command("revert").description("Generate a change set restoring the state before a release").argument("<release>", "release number or published change set id").option("--title <title>", "title for the generated change set").option("--force", "revert resources a later release already changed", false).action(
2817
+ handle(async (ctx, args, opts) => {
2818
+ const project = ctx.requireProject();
2819
+ const result = await ctx.management().releases.revert(project, args[0], {
2820
+ ...opts.title ? { title: opts.title } : {},
2821
+ ...opts.force ? { force: true } : {}
2822
+ });
2823
+ emit(result, () => {
2824
+ diag(`Created change set ${result.changeSet.id} \u2014 ${result.changeSet.title}`);
2825
+ table(result.reverted, [
2826
+ { header: "OP", value: (r) => r.operation },
2827
+ {
2828
+ header: "RESOURCE",
2829
+ value: (r) => r.collection && r.slug ? `${r.collection}/${r.slug}` : r.resourceId
2830
+ },
2831
+ { header: "RESTORES", value: (r) => r.restoredFromRevisionId ?? "\u2014" }
2832
+ ]);
2833
+ if (result.skipped.length > 0) {
2834
+ diag(`${result.skipped.length} resource(s) skipped:`);
2835
+ for (const s of result.skipped) {
2836
+ const label = s.collection && s.slug ? `${s.collection}/${s.slug}` : s.resourceId;
2837
+ process.stderr.write(` ${label} \u2014 ${s.reason}: ${s.message}
2838
+ `);
2839
+ }
2840
+ }
2841
+ diag(`Not published. Review, then: myna changes publish ${result.changeSet.id} --confirm-publish`);
2842
+ });
2843
+ })
2844
+ );
2845
+ }
2846
+
2513
2847
  // src/commands/previews.ts
2514
2848
  function previewTarget(ref) {
2515
2849
  if (ref.startsWith("chs_")) return { changeSetId: ref };
@@ -2574,6 +2908,10 @@ function registerAssets(program) {
2574
2908
  let asset = await ctx.management().assets.upload(project, path);
2575
2909
  if (opts.alt) asset = await ctx.management().assets.update(project, asset.id, { defaultAlt: opts.alt });
2576
2910
  uploaded.push(asset);
2911
+ const similar = await ctx.management().assets.similar(project, asset.id).catch(() => []);
2912
+ for (const match of similar) {
2913
+ diag(` looks like ${match.asset.id} (${match.asset.displayFilename}), distance ${match.distance}`);
2914
+ }
2577
2915
  }
2578
2916
  emit(
2579
2917
  uploaded.length === 1 ? uploaded[0] : uploaded,
@@ -2605,6 +2943,26 @@ function registerAssets(program) {
2605
2943
  );
2606
2944
  })
2607
2945
  );
2946
+ assets.command("similar").description("Find images that look like the same picture as this one").argument("<id>", "asset id (ast_)").option("--max-distance <n>", "how many of the fingerprint's 64 bits may differ", "5").action(
2947
+ handle(async (ctx, args, opts) => {
2948
+ const project = ctx.requireProject();
2949
+ const similar = await ctx.management().assets.similar(project, args[0], {
2950
+ maxDistance: Number(opts.maxDistance)
2951
+ });
2952
+ emit(similar, () => {
2953
+ if (similar.length === 0) {
2954
+ diag("No near-duplicates. (Images uploaded before fingerprinting shipped have none.)");
2955
+ return;
2956
+ }
2957
+ table(similar, [
2958
+ { header: "ID", value: (s) => s.asset.id },
2959
+ { header: "FILENAME", value: (s) => s.asset.displayFilename },
2960
+ { header: "DISTANCE", value: (s) => String(s.distance) },
2961
+ { header: "STATE", value: (s) => s.asset.state }
2962
+ ]);
2963
+ });
2964
+ })
2965
+ );
2608
2966
  assets.command("inspect").description("Show an asset and its usage").argument("<id>", "asset id (ast_)").action(
2609
2967
  handle(async (ctx, args) => {
2610
2968
  const project = ctx.requireProject();
@@ -3330,11 +3688,11 @@ function registerBilling(program) {
3330
3688
  }
3331
3689
 
3332
3690
  // src/commands/doctor.ts
3333
- import { existsSync as existsSync4, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
3691
+ import { existsSync as existsSync5, readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
3334
3692
  import { join as join6 } from "path";
3335
3693
 
3336
3694
  // src/version.ts
3337
- var VERSION = true ? "0.7.0" : "0.0.0-dev";
3695
+ var VERSION = true ? "0.9.0" : "0.0.0-dev";
3338
3696
 
3339
3697
  // src/commands/doctor.ts
3340
3698
  var NPM_REGISTRY = "https://registry.npmjs.org";
@@ -3521,7 +3879,7 @@ async function checkOrigin(ctx, origin) {
3521
3879
  }
3522
3880
  async function checkSchema(ctx, schemaDir) {
3523
3881
  const dir = schemaDirFor(ctx.linkedRoot, schemaDir);
3524
- if (!existsSync4(dir)) {
3882
+ if (!existsSync5(dir)) {
3525
3883
  return check("schema.drift", "Local schema", "skip", `No schema directory at ${dir}.`);
3526
3884
  }
3527
3885
  if (!ctx.project) {
@@ -3548,7 +3906,9 @@ async function checkSchema(ctx, schemaDir) {
3548
3906
  "Local schema",
3549
3907
  "warn",
3550
3908
  `${diff.ops.length} undeployed change(s) (${diff.classification}).`,
3551
- "myna schema diff, then myna schema push"
3909
+ // Drift has two directions and two fixes: push local ahead, or pull
3910
+ // the deployed schema back into the repository as a reviewable change.
3911
+ "myna schema diff, then myna schema push \u2014 or myna schema pull --open-pr if the deployed schema is the one to keep"
3552
3912
  );
3553
3913
  } catch (error) {
3554
3914
  return check(
@@ -3582,7 +3942,7 @@ function findGeneratedTypes(root, depth = 4) {
3582
3942
  }
3583
3943
  if (!/\.(ts|d\.ts)$/.test(entry)) continue;
3584
3944
  try {
3585
- if (looksGenerated(readFileSync3(full, "utf8"))) return full;
3945
+ if (looksGenerated(readFileSync4(full, "utf8"))) return full;
3586
3946
  } catch {
3587
3947
  }
3588
3948
  }
@@ -3599,16 +3959,16 @@ async function checkTypes(ctx, explicit, schemaDir) {
3599
3959
  if (!file) {
3600
3960
  return check("types.freshness", "Generated types", "skip", "No generated types file found.");
3601
3961
  }
3602
- if (!existsSync4(file)) {
3962
+ if (!existsSync5(file)) {
3603
3963
  return check("types.freshness", "Generated types", "fail", `${file} does not exist.`);
3604
3964
  }
3605
3965
  const dir = schemaDirFor(ctx.linkedRoot, schemaDir);
3606
- if (!existsSync4(dir)) {
3966
+ if (!existsSync5(dir)) {
3607
3967
  return check("types.freshness", "Generated types", "skip", `Found ${file} but no schema directory to compare against.`);
3608
3968
  }
3609
3969
  try {
3610
3970
  const expected = generateTypesModule(await loadLocalSchemas(dir));
3611
- return readFileSync3(file, "utf8") === expected ? check("types.freshness", "Generated types", "pass", `${file} matches the local schema.`) : check(
3971
+ return readFileSync4(file, "utf8") === expected ? check("types.freshness", "Generated types", "pass", `${file} matches the local schema.`) : check(
3612
3972
  "types.freshness",
3613
3973
  "Generated types",
3614
3974
  "warn",
@@ -3856,6 +4216,7 @@ function buildProgram() {
3856
4216
  registerEntries(program);
3857
4217
  registerSync(program);
3858
4218
  registerChanges(program);
4219
+ registerReleases(program);
3859
4220
  registerPreviews(program);
3860
4221
  registerAssets(program);
3861
4222
  registerAdmin(program);