@wix/himalaya-cli 0.1245.0 → 0.1247.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/cli.mjs CHANGED
@@ -132925,6 +132925,72 @@ var init_studio = __esm({
132925
132925
  }
132926
132926
  });
132927
132927
 
132928
+ // src/push-errors.ts
132929
+ var push_errors_exports = {};
132930
+ __export(push_errors_exports, {
132931
+ PUSH_FAILURE_CODES: () => PUSH_FAILURE_CODES,
132932
+ pushFailure: () => pushFailure,
132933
+ uxEvidenceCode: () => uxEvidenceCode
132934
+ });
132935
+ function pushFailure(code, fields) {
132936
+ return { ...fields, ok: false, code };
132937
+ }
132938
+ function uxEvidenceCode(state) {
132939
+ return UX_CODES[state];
132940
+ }
132941
+ var PUSH_FAILURE_CODES, UX_CODES;
132942
+ var init_push_errors = __esm({
132943
+ "src/push-errors.ts"() {
132944
+ PUSH_FAILURE_CODES = [
132945
+ "attestation_history_corrupt",
132946
+ "attestation_stale",
132947
+ "headless_run_failed",
132948
+ "ux_evidence_absent",
132949
+ "ux_evidence_unrecorded",
132950
+ "ux_evidence_stale",
132951
+ "ux_evidence_outdated",
132952
+ "ux_evidence_failed",
132953
+ "ux_evidence_inherited_screens",
132954
+ "ux_evidence_unreadable",
132955
+ "lint_errors",
132956
+ "control_token_expired",
132957
+ "app_source_contains_secrets",
132958
+ "app_source_too_large",
132959
+ "publish_body_too_large",
132960
+ "needs_vpn",
132961
+ "serve_unreachable",
132962
+ "no_serve_plane",
132963
+ "upload_http_error",
132964
+ "claim_failed",
132965
+ "invalid_flag_value",
132966
+ "invalid_selector",
132967
+ "duplicate_only",
132968
+ "incompatible_push_flags",
132969
+ "from_release_needs_a_value",
132970
+ "conflicting_blast_radius",
132971
+ "no_blast_radius",
132972
+ "invalid_visibility",
132973
+ "release_read_failed",
132974
+ "base_not_found",
132975
+ "no_live_release",
132976
+ "invalid_patch",
132977
+ "would_regress",
132978
+ "not_carried_by_patch",
132979
+ "authoring_unavailable",
132980
+ "push_failed"
132981
+ ];
132982
+ UX_CODES = {
132983
+ absent: "ux_evidence_absent",
132984
+ unrecorded: "ux_evidence_unrecorded",
132985
+ stale: "ux_evidence_stale",
132986
+ outdated: "ux_evidence_outdated",
132987
+ failed: "ux_evidence_failed",
132988
+ "inherited-screens": "ux_evidence_inherited_screens",
132989
+ unreadable: "ux_evidence_unreadable"
132990
+ };
132991
+ }
132992
+ });
132993
+
132928
132994
  // src/release-patch.ts
132929
132995
  var release_patch_exports = {};
132930
132996
  __export(release_patch_exports, {
@@ -163741,11 +163807,11 @@ async function contentVisibility(dir) {
163741
163807
  }
163742
163808
  return void 0;
163743
163809
  }
163744
- function visibilityFlag(flags, io2) {
163810
+ function visibilityFlag(flags, io2, failure = (fields) => fields) {
163745
163811
  if (flags.visibility === void 0) return {};
163746
163812
  const v2 = str9(flags.visibility);
163747
163813
  if (v2 !== "public" && v2 !== "unlisted") {
163748
- io2.error(JSON.stringify({ error: `--visibility must be public or unlisted (got "${v2 ?? ""}")` }));
163814
+ io2.error(JSON.stringify(failure({ error: `--visibility must be public or unlisted (got "${v2 ?? ""}")` })));
163749
163815
  return null;
163750
163816
  }
163751
163817
  return { value: v2 };
@@ -164295,6 +164361,9 @@ async function runCommand(argv, io2, deps) {
164295
164361
  const { _: pos, flags } = parseFlags(argv);
164296
164362
  const flagErrors = switchFlagErrors(argv);
164297
164363
  const command = pos[0];
164364
+ const pushErrors = command === "push" ? await Promise.resolve().then(() => (init_push_errors(), push_errors_exports)) : void 0;
164365
+ const uxEvidenceCode2 = (state) => pushErrors?.uxEvidenceCode(state) ?? "ux_evidence_absent";
164366
+ const pushFailure2 = command === "push" ? pushErrors.pushFailure : (_code, fields) => fields;
164298
164367
  let updateNotice;
164299
164368
  const print = (o2) => io2.log(JSON.stringify(
164300
164369
  o2 && typeof o2 === "object" && !Array.isArray(o2) ? tailLast(updateNotice ? { ...o2, upgrade: upgradeEnvelope(updateNotice) } : o2) : o2,
@@ -164315,7 +164384,7 @@ himi v${cliVersion()} \u2014 run \`himi --version\`. If authoring commands fail
164315
164384
  return command === "help" || flags.help ? 0 : 2;
164316
164385
  }
164317
164386
  if (flagErrors.length > 0 && flags.help !== true) {
164318
- io2.error(JSON.stringify({ ok: false, error: "invalid_flag_value", problems: flagErrors, hint: "run `himi help` for the flag grammar." }));
164387
+ io2.error(JSON.stringify(pushFailure2("invalid_flag_value", { ok: false, error: "invalid_flag_value", problems: flagErrors, hint: "run `himi help` for the flag grammar." })));
164319
164388
  return 2;
164320
164389
  }
164321
164390
  if (flags["skip-version-check"] !== true) {
@@ -164342,11 +164411,11 @@ himi v${cliVersion()} \u2014 run \`himi --version\`. If authoring commands fail
164342
164411
  const base = serveBase();
164343
164412
  if (!isLoopbackBase(base)) return null;
164344
164413
  if (await servePlaneAnswers(() => client2().health("serve"))) return null;
164345
- io2.error(JSON.stringify(foreignPlaneError(targetName(), base)));
164414
+ io2.error(JSON.stringify(pushFailure2("no_serve_plane", foreignPlaneError(targetName(), base))));
164346
164415
  return 1;
164347
164416
  };
164348
164417
  const printServeFailure = (r2, extra) => {
164349
- print({ ok: false, ...extra, status: r2.status, body: r2.json ?? r2.text });
164418
+ print(pushFailure2("upload_http_error", { ok: false, ...extra, status: r2.status, body: r2.json ?? r2.text }));
164350
164419
  return 1;
164351
164420
  };
164352
164421
  const serveFailure = async (r2, extra) => {
@@ -164359,33 +164428,33 @@ himi v${cliVersion()} \u2014 run \`himi --version\`. If authoring commands fail
164359
164428
  const target = targetName();
164360
164429
  const baseFound = await fetchReleaseManifest(client2, app, opts.fromRelease);
164361
164430
  if ("error" in baseFound) {
164362
- io2.error(JSON.stringify(baseFound));
164431
+ io2.error(JSON.stringify(pushFailure2("release_read_failed", baseFound)));
164363
164432
  return 1;
164364
164433
  }
164365
164434
  const base = baseFound.manifest;
164366
164435
  if (!base) {
164367
- io2.error(JSON.stringify({
164436
+ io2.error(JSON.stringify(pushFailure2(opts.explicitBase ? "base_not_found" : "no_live_release", {
164368
164437
  ok: false,
164369
164438
  error: opts.explicitBase ? "base_not_found" : "no_live_release",
164370
164439
  app,
164371
164440
  ...opts.explicitBase ? { releaseId: opts.fromRelease } : {},
164372
164441
  message: opts.explicitBase ? `no release ${opts.fromRelease} for ${app}. Retention keeps the current release plus roughly the newest 20 ledger entries.` : `${app} has no live release to derive from. A patch changes part of an existing release; there is nothing here to be part of.`,
164373
164442
  hint: opts.explicitBase ? `list what exists: \`himi context --app ${app} --target ${target}\`` : "publish the whole app first: `himi push --draft` then `himi promote`."
164374
- }));
164443
+ })));
164375
164444
  return 1;
164376
164445
  }
164377
164446
  let regressions = [];
164378
164447
  {
164379
164448
  const liveFound = await fetchReleaseManifest(client2, app);
164380
164449
  if ("error" in liveFound) {
164381
- io2.error(JSON.stringify(liveFound));
164450
+ io2.error(JSON.stringify(pushFailure2("release_read_failed", liveFound)));
164382
164451
  return 1;
164383
164452
  }
164384
164453
  const live3 = liveFound.manifest;
164385
164454
  if (live3 && live3.releaseId !== base.releaseId) {
164386
164455
  regressions = regressionDiff2(base, live3, plan.touched);
164387
164456
  if (regressions.length > 0 && !opts.acceptRegressions) {
164388
- io2.error(JSON.stringify({
164457
+ io2.error(JSON.stringify(pushFailure2("would_regress", {
164389
164458
  ok: false,
164390
164459
  patched: false,
164391
164460
  error: "would_regress",
@@ -164397,14 +164466,14 @@ himi v${cliVersion()} \u2014 run \`himi --version\`. If authoring commands fail
164397
164466
  moves: plan.touched,
164398
164467
  hint: opts.explicitBase ? `patch the live release instead (drop --from-release), widen the patch to cover them, or re-run with --accept-regressions if reverting those ${regressions.length} item(s) is what you mean.` : `${app} was published again while this patch was being prepared, so the base it was planned against is no longer live. Re-run to plan against the new release, or pass --accept-regressions to derive from the older one anyway.`,
164399
164468
  proof: "core/serve-control/tests/routes-release-branch-patch.test.ts:126"
164400
- }, null, 2));
164469
+ }), null, 2));
164401
164470
  return 1;
164402
164471
  }
164403
164472
  }
164404
164473
  }
164405
164474
  const notCarried = opts.notCarried ?? [];
164406
164475
  if (notCarried.length > 0 && !opts.acceptPartial) {
164407
- io2.error(JSON.stringify({
164476
+ io2.error(JSON.stringify(pushFailure2("not_carried_by_patch", {
164408
164477
  ok: false,
164409
164478
  patched: false,
164410
164479
  error: "not_carried_by_patch",
@@ -164414,25 +164483,25 @@ himi v${cliVersion()} \u2014 run \`himi --version\`. If authoring commands fail
164414
164483
  notCarried,
164415
164484
  carriedByPatch: ["screens", "bundles", "tokens", "appConfig"],
164416
164485
  hint: "run a full `himi push` \u2014 it publishes assets, the icon, the surface envelope and minBinaryVersion. Re-run with --accept-partial only if you know those devices are already covered."
164417
- }, null, 2));
164486
+ }), null, 2));
164418
164487
  return 1;
164419
164488
  }
164420
164489
  if (opts.ux?.evidence && opts.ux.evidence.state !== "recorded" && !opts.force) {
164421
- print({
164490
+ print(pushFailure2(uxEvidenceCode2(opts.ux.evidence.state), {
164422
164491
  ok: false,
164423
164492
  patched: false,
164424
164493
  pushed: false,
164425
164494
  uxEvidence: opts.ux.evidence,
164426
164495
  releaseId: opts.ux.ctx.releaseId,
164427
164496
  hint: uxHandoverHint(opts.ux.evidence, opts.ux.ctx)
164428
- });
164497
+ }));
164429
164498
  return 1;
164430
164499
  }
164431
164500
  let r2;
164432
164501
  try {
164433
164502
  r2 = await client2().releasePatch(app, base.releaseId, plan.patch, draft, opts.meta, opts.force);
164434
164503
  } catch (e2) {
164435
- io2.error(JSON.stringify({ ok: false, patched: false, error: "serve_unreachable", app, message: e2.message }));
164504
+ io2.error(JSON.stringify(pushFailure2("serve_unreachable", { ok: false, patched: false, error: "serve_unreachable", app, message: e2.message })));
164436
164505
  return 1;
164437
164506
  }
164438
164507
  if (!r2.ok) {
@@ -164498,17 +164567,17 @@ himi v${cliVersion()} \u2014 run \`himi --version\`. If authoring commands fail
164498
164567
  const wantDraft = flags.draft === true;
164499
164568
  const wantLive = flags.live === true;
164500
164569
  if (wantDraft && wantLive) {
164501
- io2.error(JSON.stringify({ ok: false, error: "conflicting_blast_radius", message: "--draft and --live are mutually exclusive; pass exactly one." }));
164570
+ io2.error(JSON.stringify(pushFailure2("conflicting_blast_radius", { ok: false, error: "conflicting_blast_radius", message: "--draft and --live are mutually exclusive; pass exactly one." })));
164502
164571
  return null;
164503
164572
  }
164504
164573
  if (!wantDraft && !wantLive) {
164505
- io2.error(JSON.stringify({
164574
+ io2.error(JSON.stringify(pushFailure2("no_blast_radius", {
164506
164575
  ok: false,
164507
164576
  error: "no_blast_radius",
164508
164577
  message: "a patch must say where it lands: --draft (nothing changes on any device) or --live (every device, immediately).",
164509
164578
  hint: "`himi push` defaults to --draft because it publishes a whole release you then promote. A patch has no default, because the two outcomes are not comparable.",
164510
164579
  usage
164511
- }));
164580
+ })));
164512
164581
  return null;
164513
164582
  }
164514
164583
  return wantDraft;
@@ -166687,15 +166756,15 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166687
166756
  if (flags.only !== void 0) {
166688
166757
  onlyRaw = str9(flags.only);
166689
166758
  if (!onlyRaw) {
166690
- io2.error(JSON.stringify({ error: "--only needs a selector", usage: ONLY_USAGE }));
166759
+ io2.error(JSON.stringify(pushFailure2("invalid_selector", { error: "--only needs a selector", usage: ONLY_USAGE })));
166691
166760
  return 2;
166692
166761
  }
166693
166762
  if (argv.filter((a2) => a2 === "--only" || a2.startsWith("--only=")).length > 1) {
166694
- io2.error(JSON.stringify({
166763
+ io2.error(JSON.stringify(pushFailure2("duplicate_only", {
166695
166764
  error: "duplicate_only",
166696
166765
  usage: ONLY_USAGE,
166697
166766
  message: "--only was passed more than once, and only the last would have taken effect. Comma-separate the selectors instead: --only screen:a,screen:b"
166698
- }));
166767
+ })));
166699
166768
  return 2;
166700
166769
  }
166701
166770
  for (const [flag, why] of [
@@ -166703,12 +166772,12 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166703
166772
  ["visibility", "the patch route does not stamp visibility; set it with `himi visibility` instead"]
166704
166773
  ]) {
166705
166774
  if (flags[flag] !== void 0) {
166706
- io2.error(JSON.stringify({ error: `--${flag} is not meaningful with --only`, message: why, usage: ONLY_USAGE }));
166775
+ io2.error(JSON.stringify(pushFailure2("incompatible_push_flags", { error: `--${flag} is not meaningful with --only`, message: why, usage: ONLY_USAGE })));
166707
166776
  return 2;
166708
166777
  }
166709
166778
  }
166710
166779
  if (flags["from-release"] !== void 0 && !str9(flags["from-release"])) {
166711
- io2.error(JSON.stringify({ ok: false, error: "from_release_needs_a_value", message: "--from-release takes a release id to branch from. Omit the flag entirely to patch the live release.", usage: ONLY_USAGE }));
166780
+ io2.error(JSON.stringify(pushFailure2("from_release_needs_a_value", { ok: false, error: "from_release_needs_a_value", message: "--from-release takes a release id to branch from. Omit the flag entirely to patch the live release.", usage: ONLY_USAGE })));
166712
166781
  return 2;
166713
166782
  }
166714
166783
  if (patchBlastRadius(ONLY_USAGE) === null) return 2;
@@ -166727,14 +166796,14 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166727
166796
  const { loadTargetsFile: loadTargetsFile2 } = await Promise.resolve().then(() => (init_config(), config_exports));
166728
166797
  const configured = loadTargetsFile2();
166729
166798
  const usable = Object.keys(configured.targets ?? {}).filter((n2) => n2 !== pushTarget).filter((n2) => inspectControlToken(configured.targets?.[n2]?.controlToken).valid).sort().map((n2) => ({ target: n2, control: configured.targets?.[n2]?.control }));
166730
- io2.error(JSON.stringify({
166799
+ io2.error(JSON.stringify(pushFailure2("control_token_expired", {
166731
166800
  ok: false,
166732
166801
  pushed: false,
166733
166802
  error: "control_token_expired",
166734
166803
  target: pushTarget,
166735
166804
  ...usable.length ? { usableTargets: usable } : {},
166736
166805
  hint: `${pushAuthHint(401, pushToken, pushTarget, !!ownerIssue)}` + (usable.length ? ` \u2014 you are also logged in for ${usable.map((u2) => `${u2.target}${u2.control ? ` (${u2.control})` : ""}`).join(", ")}, but check the plane before switching: a different target publishes somewhere else and claims the slug there.` : "")
166737
- }));
166806
+ })));
166738
166807
  return 1;
166739
166808
  }
166740
166809
  let app = resolveAppSlug(declared, (pushToken ? decodeControlTokenClaims(pushToken) : null) ?? {});
@@ -166762,7 +166831,7 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166762
166831
  ...await navIssues2(dir, strict)
166763
166832
  ]);
166764
166833
  if (hasErrors && !force) {
166765
- print({ ...report3, pushed: false, hint: "lint found errors \u2014 fix them, or re-run `himi push --force` to publish anyway" });
166834
+ print(pushFailure2("lint_errors", { ...report3, pushed: false, hint: "lint found errors \u2014 fix them, or re-run `himi push --force` to publish anyway" }));
166766
166835
  return 1;
166767
166836
  }
166768
166837
  const declaredData = await loadDataResourcesQuietly(dir);
@@ -166781,28 +166850,28 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166781
166850
  bundles: Object.keys(manifest.bundles ?? {})
166782
166851
  });
166783
166852
  if (!parsed.ok) {
166784
- io2.error(JSON.stringify(parsed.error, null, 2));
166853
+ io2.error(JSON.stringify(pushFailure2("invalid_selector", parsed.error), null, 2));
166785
166854
  return 2;
166786
166855
  }
166787
166856
  const fromRelease = str9(flags["from-release"]);
166788
166857
  const baseForPlan = await fetchReleaseManifest(client2, app, fromRelease);
166789
166858
  if ("error" in baseForPlan) {
166790
- io2.error(JSON.stringify(baseForPlan));
166859
+ io2.error(JSON.stringify(pushFailure2("release_read_failed", baseForPlan)));
166791
166860
  return 1;
166792
166861
  }
166793
166862
  if (!baseForPlan.manifest) {
166794
- io2.error(JSON.stringify({
166863
+ io2.error(JSON.stringify(pushFailure2(fromRelease ? "base_not_found" : "no_live_release", {
166795
166864
  ok: false,
166796
166865
  error: fromRelease ? "base_not_found" : "no_live_release",
166797
166866
  app,
166798
166867
  message: fromRelease ? `no release ${fromRelease} for ${app}.` : `no published release for "${app}" to patch \u2014 run \`himi push\` first.`,
166799
166868
  hint: "if this app is registered under a different slug, pass --app <slug>."
166800
- }));
166869
+ })));
166801
166870
  return 1;
166802
166871
  }
166803
166872
  const planned = planFromPayload2(payload, baseForPlan.manifest, parsed.selectors);
166804
166873
  if (!planned.ok) {
166805
- io2.error(JSON.stringify(planned.error, null, 2));
166874
+ io2.error(JSON.stringify(pushFailure2("invalid_patch", planned.error), null, 2));
166806
166875
  return 1;
166807
166876
  }
166808
166877
  const patchUxEvidence = handoverUxEvidence(dir, {
@@ -166872,21 +166941,21 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166872
166941
  const collected = collectAppSource2(dir);
166873
166942
  const findings = secretFindings2(collected.entries);
166874
166943
  if (findings.length > 0) {
166875
- io2.error(JSON.stringify({
166944
+ io2.error(JSON.stringify(pushFailure2("app_source_contains_secrets", {
166876
166945
  ok: false,
166877
166946
  pushed: false,
166878
166947
  error: `refusing to archive the source: ${findings[0].path} looks like it contains ${findings[0].reason}`,
166879
166948
  file: findings[0].path,
166880
166949
  found: findings.map((f2) => f2.path),
166881
166950
  hint: "move it out of the content package (or into .himi/, .env, or a *.pem/*.p8 file, which are never archived), or push with --no-source"
166882
- }));
166951
+ })));
166883
166952
  return 1;
166884
166953
  }
166885
166954
  const zip = zipAppSource2(collected.entries);
166886
166955
  const encoded2 = zip.toString("base64");
166887
166956
  if (encoded2.length > MAX_APP_SOURCE_BYTES2) {
166888
166957
  const largest = largestEntries2(collected.entries);
166889
- io2.error(JSON.stringify({
166958
+ io2.error(JSON.stringify(pushFailure2("app_source_too_large", {
166890
166959
  ok: false,
166891
166960
  pushed: false,
166892
166961
  error: "app_source_too_large",
@@ -166896,7 +166965,7 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166896
166965
  files: collected.entries.length,
166897
166966
  largest,
166898
166967
  hint: `trim the content package \u2014 the largest entries are ${largest.map((e2) => `${e2.path} (${Math.round(e2.compressed / 1024)} KB compressed)`).join(", ")}. Sizes are COMPRESSED, because the cap is measured on the deflated archive \u2014 a big but compressible source file is rarely what costs you it. Build output, dependencies, native shells and tooling output (.himi/, and shots/ + evidence/ at the package root) are already excluded. \`--no-source\` publishes without the archive, but then \`himi pull\` cannot restore this release.`
166899
- }));
166968
+ })));
166900
166969
  return 1;
166901
166970
  }
166902
166971
  appSourceB64 = encoded2;
@@ -166913,22 +166982,22 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166913
166982
  if (attestationErrors.length > 0 && !force) {
166914
166983
  const remedy = `\`himi test --content ${contentArg2}\` (or \`himi render <screenId> --content ${contentArg2}\`)`;
166915
166984
  const corrupted = evidence.historyErrors.length > 0;
166916
- print({
166985
+ print(pushFailure2(corrupted ? "attestation_history_corrupt" : "attestation_stale", {
166917
166986
  ok: false,
166918
166987
  pushed: false,
166919
166988
  errors: attestationErrors,
166920
166989
  hint: corrupted ? `attestation history in .himi/attestations/ is corrupt, so the retained runs cannot be trusted \u2014 a fresh run does not remove the bad entry. Delete .himi/attestations/, re-attest with ${remedy}, then re-run \`himi push\`. Only if you intend to publish WITHOUT that evidence, \`himi push --force\`.` : `no retained attestation for this release passed live verification${drifting.length ? `. ${releaseDriftCause(drifting, "Re-attesting", "an attestation")}, then re-attest` : " \u2014 runs recorded at this commit before your latest edits describe a tree that no longer exists. Re-attest"} the CURRENT release with ${remedy}, then re-run \`himi push\`. Only if you intend to publish WITHOUT that evidence, \`himi push --force\`.`
166921
- });
166990
+ }));
166922
166991
  return 1;
166923
166992
  }
166924
166993
  const attestation = evidence.selected ? summaryOf(evidence.selected) : void 0;
166925
166994
  if (attestation?.report.ok === false && !force) {
166926
- print({
166995
+ print(pushFailure2("headless_run_failed", {
166927
166996
  ok: false,
166928
166997
  pushed: false,
166929
166998
  attestation,
166930
166999
  hint: "the latest verified headless run failed \u2014 fix it and rerun the tool, or re-run `himi push --force` to publish anyway"
166931
- });
167000
+ }));
166932
167001
  return 1;
166933
167002
  }
166934
167003
  const uxEvidence = handoverUxEvidence(dir, {
@@ -166940,7 +167009,7 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166940
167009
  inheritedScreens: []
166941
167010
  });
166942
167011
  if (uxEvidence && uxEvidence.state !== "recorded" && !force) {
166943
- print({
167012
+ print(pushFailure2(uxEvidenceCode2(uxEvidence.state), {
166944
167013
  ok: false,
166945
167014
  pushed: false,
166946
167015
  uxEvidence,
@@ -166958,7 +167027,7 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166958
167027
  // `inherited-screens` and has no base to restore.
166959
167028
  nondeterministicWorkers: drifting
166960
167029
  })
166961
- });
167030
+ }));
166962
167031
  return 1;
166963
167032
  }
166964
167033
  const forcedPastErrors = force && (hasErrors || attestation?.report.ok === false || attestationErrors.length > 0);
@@ -166969,7 +167038,7 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166969
167038
  bakedBytes: assetRecords.filter(([, a2]) => a2.baked).reduce((n2, [, a2]) => n2 + a2.bytes, 0),
166970
167039
  otaOnly: assetRecords.filter(([, a2]) => !a2.baked).map(([name]) => name)
166971
167040
  } : null;
166972
- const visArg = visibilityFlag(flags, io2);
167041
+ const visArg = visibilityFlag(flags, io2, (fields) => pushFailure2("invalid_visibility", fields));
166973
167042
  if (!visArg) return 2;
166974
167043
  const visibility = visArg.value ?? await contentVisibility(dir);
166975
167044
  const meta = {
@@ -166988,7 +167057,7 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166988
167057
  const publishBodyBytesFor = (slug) => publishReleaseBodyBytes(publishBodyFor(slug), meta, draft);
166989
167058
  const refuseOversizedBody = (slug, bodyBytes) => {
166990
167059
  const withoutSource = appSourceB64 ? bodyBytes - appSourceB64.length : bodyBytes;
166991
- io2.error(JSON.stringify({
167060
+ io2.error(JSON.stringify(pushFailure2("publish_body_too_large", {
166992
167061
  ok: false,
166993
167062
  app: slug,
166994
167063
  pushed: false,
@@ -166997,7 +167066,7 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166997
167066
  limit: MAX_PUBLISH_BODY_BYTES,
166998
167067
  ...appSourceB64 ? { appSourceBytes: appSourceB64.length } : {},
166999
167068
  hint: appSourceB64 && withoutSource <= MAX_PUBLISH_BODY_BYTES ? "push with --no-source to publish without the source archive (this release then cannot be restored with `himi pull`), or trim the app's assets and bundles" : "trim the app's assets and bundles \u2014 this release is over the limit even without the source archive, so --no-source would not help"
167000
- }));
167069
+ })));
167001
167070
  return 1;
167002
167071
  };
167003
167072
  const composedBodyBytes = publishBodyBytesFor(app);
@@ -167008,14 +167077,14 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
167008
167077
  if (ownerIssue && pushToken && !tokenCoversApp(pushToken, app)) {
167009
167078
  const claim = await autoClaimSlug({ ownerIssue, controlToken: pushToken, app, accountId: str9(flags["account-id"]) });
167010
167079
  if (claim.fatal) {
167011
- io2.error(JSON.stringify({
167080
+ io2.error(JSON.stringify(pushFailure2("claim_failed", {
167012
167081
  ok: false,
167013
167082
  app,
167014
167083
  pushed: false,
167015
167084
  status: claim.fatal.status,
167016
167085
  ...claim.fatal.body,
167017
167086
  ...claimHint(claim.fatal.status, claim.fatal.error)
167018
- }));
167087
+ })));
167019
167088
  return 1;
167020
167089
  }
167021
167090
  if (claim.refusal) io2.error(JSON.stringify({ warning: claim.refusal.error, hint: claim.refusal.hint }));
@@ -167036,16 +167105,16 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
167036
167105
  r2 = await client2().publishRelease(publishPayload, meta, draft);
167037
167106
  } catch (e2) {
167038
167107
  const target = targetName();
167039
- io2.error(JSON.stringify({
167108
+ io2.error(JSON.stringify(pushFailure2("needs_vpn", {
167040
167109
  ...serveUnreachableError(target, serveBase(), e2.message),
167041
167110
  version: cliVersion()
167042
- }));
167111
+ })));
167043
167112
  return 1;
167044
167113
  }
167045
167114
  if (!r2.ok) {
167046
167115
  const authHint = pushAuthHint(r2.status, pushToken, pushTarget, !!ownerIssue);
167047
167116
  const hint = authHint ?? releaseIdExistsHint(r2.status, r2.json);
167048
- print({ ok: false, status: r2.status, body: r2.json ?? r2.text, ...hint ? { hint } : {} });
167117
+ print(pushFailure2("upload_http_error", { ok: false, status: r2.status, body: r2.json ?? r2.text, ...hint ? { hint } : {} }));
167049
167118
  return 1;
167050
167119
  }
167051
167120
  const release = r2.json?.releaseId ?? manifest.releaseId;
@@ -170282,15 +170351,15 @@ Code: ${userCode}`);
170282
170351
  }
170283
170352
  } catch (err) {
170284
170353
  if (err instanceof AuthoringUnavailable) {
170285
- io2.error(JSON.stringify({
170354
+ io2.error(JSON.stringify(pushFailure2("authoring_unavailable", {
170286
170355
  error: `the himi authoring runtime failed to load (himi v${cliVersion()}). Off-repo this almost always means an out-of-date CLI, NOT a platform limitation \u2014 upgrade: \`npm i -g @wix/himalaya-cli@latest\`. If you meant to run in-repo, run from a monorepo checkout.`,
170287
170356
  version: cliVersion(),
170288
170357
  upgrade: "npm i -g @wix/himalaya-cli@latest",
170289
170358
  portal: DEVELOPER_PORTAL_URL
170290
- }));
170359
+ })));
170291
170360
  return 1;
170292
170361
  }
170293
- io2.error(JSON.stringify({ error: err.message }));
170362
+ io2.error(JSON.stringify(pushFailure2("push_failed", { error: err.message })));
170294
170363
  const code = err.exitCode;
170295
170364
  return code === 2 ? 2 : 1;
170296
170365
  }
@@ -3,6 +3,10 @@
3
3
  Author a Himi app from your own machine, push it to the production serve as a **draft**,
4
4
  preview it, read the worker's logs, then promote it live. Nothing is live until you promote.
5
5
 
6
+ For machine consumers, `himi push` failures carry a stable top-level `code`.
7
+ See [JSON failure codes](himi-json-output.md) (`himi docs himi-json-output`);
8
+ branch on that code rather than human prose or envelope shape.
9
+
6
10
  ## 1. Get the CLI
7
11
 
8
12
  ```sh
@@ -0,0 +1,84 @@
1
+ # himi JSON failure contract
2
+
3
+ Every `ok: false` JSON envelope must carry a top-level `code`: a stable, typed
4
+ snake_case identifier for consumers to branch on. Codes are never renamed or
5
+ repurposed when human wording changes. New codes may be added; consumers must
6
+ handle an unknown code as a failure, without parsing prose.
7
+
8
+ **Rollout:** this contract is enforced for **`himi push`**, including `--only`,
9
+ first. Other subcommands still have legacy failures without codes and are a
10
+ follow-up migration. This is an additive change: existing `error`, `errors`,
11
+ `message`, `hint`, status, body, and gate-specific fields stay as they were.
12
+ Some legacy envelopes have no singular `error` or `hint`; consumers must not
13
+ require those fields. Existing error-only push refusals now also carry `ok: false`.
14
+ Output channels and process exit codes are unchanged. Read JSON failures on both
15
+ stdout and stderr; progress/warnings are not failure envelopes.
16
+
17
+ `code` is the machine contract. `error`/`errors`/`message` describe the failure for
18
+ people and `hint`, when present, suggests recovery. Neither their wording nor the
19
+ presence of one of those legacy shapes identifies the failure class. The complete
20
+ push code vocabulary is listed below.
21
+
22
+ For example, an attestation that no longer describes the content being published
23
+ keeps its existing fields and adds the code:
24
+
25
+ ```json
26
+ {
27
+ "ok": false,
28
+ "pushed": false,
29
+ "errors": ["2 retained attestation(s) were recorded at this commit, but none describes the content being published"],
30
+ "hint": "…",
31
+ "code": "attestation_stale"
32
+ }
33
+ ```
34
+
35
+ ## Push codes
36
+
37
+ | Code | Meaning |
38
+ | --- | --- |
39
+ | `attestation_history_corrupt` | Retained attestation history failed integrity checks. Takes precedence if stale runs are also present. |
40
+ | `attestation_stale` | Retained runs at this commit do not describe the current release. |
41
+ | `headless_run_failed` | A verified headless run failed. |
42
+ | `ux_evidence_absent` | No UX evidence artifact exists. |
43
+ | `ux_evidence_unrecorded` | No usable native UX drive was recorded. |
44
+ | `ux_evidence_stale` | UX evidence belongs to a different release/commit. |
45
+ | `ux_evidence_outdated` | UX evidence predates edits at this commit. |
46
+ | `ux_evidence_failed` | A matching UX drive failed. |
47
+ | `ux_evidence_inherited_screens` | A partial push inherits screens not covered by local UX evidence. |
48
+ | `ux_evidence_unreadable` | The UX evidence artifact cannot be read. |
49
+ | `lint_errors` | Build/lint validation found errors. |
50
+ | `control_token_expired` | The selected control token has expired. |
51
+ | `app_source_contains_secrets` | Source archiving detected a possible credential. |
52
+ | `app_source_too_large` | The encoded source archive exceeds its limit. |
53
+ | `publish_body_too_large` | The complete publish request exceeds its limit. |
54
+ | `needs_vpn` | Full-push request to the publishing service could not complete (existing reachability code). This is not proof that VPN is the cause; read the target-aware error. |
55
+ | `serve_unreachable` | A partial-push upload could not reach the publishing service. |
56
+ | `no_serve_plane` | A loopback service answering a partial push is not a himi publishing service. |
57
+ | `upload_http_error` | The upload returned a non-success HTTP status. `status` and the original `body` remain available; server errors never replace this CLI code. |
58
+ | `claim_failed` | Auto-claim returned a fatal refusal. Existing server fields and `status` remain available. |
59
+ | `invalid_flag_value` | A flag violates the CLI flag grammar. |
60
+ | `invalid_selector` | `--only` is missing a selector or its selector syntax is invalid. |
61
+ | `duplicate_only` | `--only` was repeated. |
62
+ | `incompatible_push_flags` | A flag cannot be combined with `--only`. |
63
+ | `from_release_needs_a_value` | `--from-release` has no value. |
64
+ | `conflicting_blast_radius` | A partial push supplied both `--draft` and `--live`. |
65
+ | `no_blast_radius` | A partial push supplied neither `--draft` nor `--live`. |
66
+ | `invalid_visibility` | The visibility value is invalid. |
67
+ | `release_read_failed` | A partial push could not read the base/live release. |
68
+ | `base_not_found` | The explicitly selected base has no manifest. |
69
+ | `no_live_release` | There is no live release to patch. |
70
+ | `invalid_patch` | The selection cannot produce a valid patch. The existing `error` and plan details are preserved. |
71
+ | `would_regress` | A partial push would revert unselected live changes. |
72
+ | `not_carried_by_patch` | The package changed dependencies that a partial push cannot carry. |
73
+ | `authoring_unavailable` | The bundled authoring runtime failed to load. |
74
+ | `push_failed` | An otherwise unclassified exception stopped push (for example unreadable content). The original error is preserved. |
75
+
76
+ The `ux_evidence_*` codes map to the existing typed `uxEvidence.state`; a successful
77
+ `recorded` state has no refusal code. Successful pushes and warnings do not gain
78
+ failure codes. A forced push still follows the existing override rules.
79
+
80
+ ## Follow-ups
81
+
82
+ - Migrate failure envelopes in subcommands other than `push`, keeping this contract.
83
+ - Review/reword human messages separately; no message rewrite is part of this change.
84
+ - Review exit-code conventions separately; no exit code changes are part of this change.
package/guides/index.json CHANGED
@@ -53,6 +53,12 @@
53
53
  "category": "CLI & Loop",
54
54
  "file": "headless-loop.md"
55
55
  },
56
+ {
57
+ "id": "himi-json-output",
58
+ "title": "JSON failures — stable error codes",
59
+ "category": "CLI & Loop",
60
+ "file": "himi-json-output.md"
61
+ },
56
62
  {
57
63
  "id": "himi-mcp",
58
64
  "title": "himi in your coding agent (MCP)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.1245.0",
3
+ "version": "0.1247.0",
4
4
  "type": "module",
5
5
  "description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
6
6
  "keywords": [
@@ -67,5 +67,5 @@
67
67
  "artifactId": "himalaya-cli"
68
68
  }
69
69
  },
70
- "falconPackageHash": "09f05672ac223575259bf3c6b41d9dbdb7ad92bd5de82076c956864c"
70
+ "falconPackageHash": "ba75b3fdbeb557376aad2c794b64762b5e246a1c72a94d970720534c"
71
71
  }