@myna-sh/cli 0.8.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)}`)
@@ -649,6 +659,19 @@ var ManagementClient = class {
649
659
  }),
650
660
  get: (project, asset, signal) => this.get(`/projects/${enc(project)}/assets/${enc(asset)}`, void 0, signal),
651
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),
652
675
  update: (project, asset, body) => this.mutate("PATCH", `/projects/${enc(project)}/assets/${enc(asset)}`, body),
653
676
  replace: (project, asset, body) => this.mutate("POST", `/projects/${enc(project)}/assets/${enc(asset)}/replace`, body),
654
677
  delete: (project, asset) => this.mutate("DELETE", `/projects/${enc(project)}/assets/${enc(asset)}`),
@@ -1194,7 +1217,33 @@ function startDevice(apiUrl) {
1194
1217
  function pollDevice(apiUrl, deviceCode) {
1195
1218
  return apiPost(apiUrl, `/auth/device/${deviceCode}/token`, { deviceCode });
1196
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
+ }
1197
1245
  function registerAuth(program) {
1246
+ registerAccess(program);
1198
1247
  const login = program.command("login").description("Authenticate via the browser device-authorization flow").action(
1199
1248
  handle(async (ctx) => {
1200
1249
  const start = await startDevice(ctx.apiUrl);
@@ -1483,7 +1532,7 @@ function registerWorkspace(program) {
1483
1532
  }
1484
1533
 
1485
1534
  // src/commands/schema.ts
1486
- 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";
1487
1536
  import { join as join4 } from "path";
1488
1537
 
1489
1538
  // src/schema-loader.ts
@@ -1717,6 +1766,89 @@ function varName(name) {
1717
1766
  return /^[A-Za-z_$]/.test(camel) ? camel : `_${camel}`;
1718
1767
  }
1719
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
+
1720
1852
  // src/commands/schema.ts
1721
1853
  async function deployedSchemas(ctx, project) {
1722
1854
  const mgmt = ctx.management();
@@ -1762,21 +1894,59 @@ function registerSchema(program) {
1762
1894
  });
1763
1895
  })
1764
1896
  );
1765
- 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(
1766
1898
  handle(async (ctx, _args, opts) => {
1767
1899
  const project = ctx.requireProject();
1768
1900
  const dir = schemaDirFor(ctx.linkedRoot, opts.schemaDir);
1769
1901
  const schemas = await deployedSchemas(ctx, project);
1770
- mkdirSync3(dir, { recursive: true });
1771
- const written = [];
1772
- for (const s of schemas) {
1773
- const file = join4(dir, `${s.name}.ts`);
1774
- writeFileSync3(file, schemaToDsl(s));
1775
- written.push(file);
1776
- }
1777
- emit({ pulled: schemas.length, files: written }, () => {
1778
- 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}
1779
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}.`);
1780
1950
  });
1781
1951
  })
1782
1952
  );
@@ -1894,6 +2064,29 @@ function registerSchema(program) {
1894
2064
  })
1895
2065
  );
1896
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
+ }
1897
2090
  function renderDiff(diff) {
1898
2091
  if (diff.ops.length === 0) {
1899
2092
  diag("No changes.");
@@ -2482,6 +2675,9 @@ function registerChanges(program) {
2482
2675
  { header: "BEFORE", value: (c) => preview(c.before) },
2483
2676
  { header: "AFTER", value: (c) => preview(c.after) }
2484
2677
  ]);
2678
+ for (const change of item.changes) {
2679
+ if (change.segments) diag(` ${change.path}: ${inlineWords(change.segments)}`);
2680
+ }
2485
2681
  }
2486
2682
  if (diff.items.length === 0) diag("No items.");
2487
2683
  });
@@ -2563,6 +2759,15 @@ function registerChanges(program) {
2563
2759
  })
2564
2760
  );
2565
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
+ }
2566
2771
  function preview(value) {
2567
2772
  if (value === void 0 || value === null) return "\u2014";
2568
2773
  const text = typeof value === "string" ? value : JSON.stringify(value);
@@ -2703,6 +2908,10 @@ function registerAssets(program) {
2703
2908
  let asset = await ctx.management().assets.upload(project, path);
2704
2909
  if (opts.alt) asset = await ctx.management().assets.update(project, asset.id, { defaultAlt: opts.alt });
2705
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
+ }
2706
2915
  }
2707
2916
  emit(
2708
2917
  uploaded.length === 1 ? uploaded[0] : uploaded,
@@ -2734,6 +2943,26 @@ function registerAssets(program) {
2734
2943
  );
2735
2944
  })
2736
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
+ );
2737
2966
  assets.command("inspect").description("Show an asset and its usage").argument("<id>", "asset id (ast_)").action(
2738
2967
  handle(async (ctx, args) => {
2739
2968
  const project = ctx.requireProject();
@@ -3459,11 +3688,11 @@ function registerBilling(program) {
3459
3688
  }
3460
3689
 
3461
3690
  // src/commands/doctor.ts
3462
- 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";
3463
3692
  import { join as join6 } from "path";
3464
3693
 
3465
3694
  // src/version.ts
3466
- var VERSION = true ? "0.8.0" : "0.0.0-dev";
3695
+ var VERSION = true ? "0.9.0" : "0.0.0-dev";
3467
3696
 
3468
3697
  // src/commands/doctor.ts
3469
3698
  var NPM_REGISTRY = "https://registry.npmjs.org";
@@ -3650,7 +3879,7 @@ async function checkOrigin(ctx, origin) {
3650
3879
  }
3651
3880
  async function checkSchema(ctx, schemaDir) {
3652
3881
  const dir = schemaDirFor(ctx.linkedRoot, schemaDir);
3653
- if (!existsSync4(dir)) {
3882
+ if (!existsSync5(dir)) {
3654
3883
  return check("schema.drift", "Local schema", "skip", `No schema directory at ${dir}.`);
3655
3884
  }
3656
3885
  if (!ctx.project) {
@@ -3677,7 +3906,9 @@ async function checkSchema(ctx, schemaDir) {
3677
3906
  "Local schema",
3678
3907
  "warn",
3679
3908
  `${diff.ops.length} undeployed change(s) (${diff.classification}).`,
3680
- "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"
3681
3912
  );
3682
3913
  } catch (error) {
3683
3914
  return check(
@@ -3711,7 +3942,7 @@ function findGeneratedTypes(root, depth = 4) {
3711
3942
  }
3712
3943
  if (!/\.(ts|d\.ts)$/.test(entry)) continue;
3713
3944
  try {
3714
- if (looksGenerated(readFileSync3(full, "utf8"))) return full;
3945
+ if (looksGenerated(readFileSync4(full, "utf8"))) return full;
3715
3946
  } catch {
3716
3947
  }
3717
3948
  }
@@ -3728,16 +3959,16 @@ async function checkTypes(ctx, explicit, schemaDir) {
3728
3959
  if (!file) {
3729
3960
  return check("types.freshness", "Generated types", "skip", "No generated types file found.");
3730
3961
  }
3731
- if (!existsSync4(file)) {
3962
+ if (!existsSync5(file)) {
3732
3963
  return check("types.freshness", "Generated types", "fail", `${file} does not exist.`);
3733
3964
  }
3734
3965
  const dir = schemaDirFor(ctx.linkedRoot, schemaDir);
3735
- if (!existsSync4(dir)) {
3966
+ if (!existsSync5(dir)) {
3736
3967
  return check("types.freshness", "Generated types", "skip", `Found ${file} but no schema directory to compare against.`);
3737
3968
  }
3738
3969
  try {
3739
3970
  const expected = generateTypesModule(await loadLocalSchemas(dir));
3740
- 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(
3741
3972
  "types.freshness",
3742
3973
  "Generated types",
3743
3974
  "warn",