@wix/himalaya-cli 0.1246.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
@@ -13465,7 +13465,6 @@ function resolveIdiom(tokens2, ctx = {}) {
13465
13465
  var IDIOM_CONTEXTS, UNSAFE_KEYS;
13466
13466
  var init_idiom = __esm({
13467
13467
  "../../core/ts/src/idiom.ts"() {
13468
- "use strict";
13469
13468
  IDIOM_CONTEXTS = {
13470
13469
  ios: { native: ["ios"] },
13471
13470
  android: { native: ["material"] },
@@ -132926,6 +132925,72 @@ var init_studio = __esm({
132926
132925
  }
132927
132926
  });
132928
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
+
132929
132994
  // src/release-patch.ts
132930
132995
  var release_patch_exports = {};
132931
132996
  __export(release_patch_exports, {
@@ -163742,11 +163807,11 @@ async function contentVisibility(dir) {
163742
163807
  }
163743
163808
  return void 0;
163744
163809
  }
163745
- function visibilityFlag(flags, io2) {
163810
+ function visibilityFlag(flags, io2, failure = (fields) => fields) {
163746
163811
  if (flags.visibility === void 0) return {};
163747
163812
  const v2 = str9(flags.visibility);
163748
163813
  if (v2 !== "public" && v2 !== "unlisted") {
163749
- 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 ?? ""}")` })));
163750
163815
  return null;
163751
163816
  }
163752
163817
  return { value: v2 };
@@ -164296,6 +164361,9 @@ async function runCommand(argv, io2, deps) {
164296
164361
  const { _: pos, flags } = parseFlags(argv);
164297
164362
  const flagErrors = switchFlagErrors(argv);
164298
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;
164299
164367
  let updateNotice;
164300
164368
  const print = (o2) => io2.log(JSON.stringify(
164301
164369
  o2 && typeof o2 === "object" && !Array.isArray(o2) ? tailLast(updateNotice ? { ...o2, upgrade: upgradeEnvelope(updateNotice) } : o2) : o2,
@@ -164316,7 +164384,7 @@ himi v${cliVersion()} \u2014 run \`himi --version\`. If authoring commands fail
164316
164384
  return command === "help" || flags.help ? 0 : 2;
164317
164385
  }
164318
164386
  if (flagErrors.length > 0 && flags.help !== true) {
164319
- 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." })));
164320
164388
  return 2;
164321
164389
  }
164322
164390
  if (flags["skip-version-check"] !== true) {
@@ -164343,11 +164411,11 @@ himi v${cliVersion()} \u2014 run \`himi --version\`. If authoring commands fail
164343
164411
  const base = serveBase();
164344
164412
  if (!isLoopbackBase(base)) return null;
164345
164413
  if (await servePlaneAnswers(() => client2().health("serve"))) return null;
164346
- io2.error(JSON.stringify(foreignPlaneError(targetName(), base)));
164414
+ io2.error(JSON.stringify(pushFailure2("no_serve_plane", foreignPlaneError(targetName(), base))));
164347
164415
  return 1;
164348
164416
  };
164349
164417
  const printServeFailure = (r2, extra) => {
164350
- 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 }));
164351
164419
  return 1;
164352
164420
  };
164353
164421
  const serveFailure = async (r2, extra) => {
@@ -164360,33 +164428,33 @@ himi v${cliVersion()} \u2014 run \`himi --version\`. If authoring commands fail
164360
164428
  const target = targetName();
164361
164429
  const baseFound = await fetchReleaseManifest(client2, app, opts.fromRelease);
164362
164430
  if ("error" in baseFound) {
164363
- io2.error(JSON.stringify(baseFound));
164431
+ io2.error(JSON.stringify(pushFailure2("release_read_failed", baseFound)));
164364
164432
  return 1;
164365
164433
  }
164366
164434
  const base = baseFound.manifest;
164367
164435
  if (!base) {
164368
- io2.error(JSON.stringify({
164436
+ io2.error(JSON.stringify(pushFailure2(opts.explicitBase ? "base_not_found" : "no_live_release", {
164369
164437
  ok: false,
164370
164438
  error: opts.explicitBase ? "base_not_found" : "no_live_release",
164371
164439
  app,
164372
164440
  ...opts.explicitBase ? { releaseId: opts.fromRelease } : {},
164373
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.`,
164374
164442
  hint: opts.explicitBase ? `list what exists: \`himi context --app ${app} --target ${target}\`` : "publish the whole app first: `himi push --draft` then `himi promote`."
164375
- }));
164443
+ })));
164376
164444
  return 1;
164377
164445
  }
164378
164446
  let regressions = [];
164379
164447
  {
164380
164448
  const liveFound = await fetchReleaseManifest(client2, app);
164381
164449
  if ("error" in liveFound) {
164382
- io2.error(JSON.stringify(liveFound));
164450
+ io2.error(JSON.stringify(pushFailure2("release_read_failed", liveFound)));
164383
164451
  return 1;
164384
164452
  }
164385
164453
  const live3 = liveFound.manifest;
164386
164454
  if (live3 && live3.releaseId !== base.releaseId) {
164387
164455
  regressions = regressionDiff2(base, live3, plan.touched);
164388
164456
  if (regressions.length > 0 && !opts.acceptRegressions) {
164389
- io2.error(JSON.stringify({
164457
+ io2.error(JSON.stringify(pushFailure2("would_regress", {
164390
164458
  ok: false,
164391
164459
  patched: false,
164392
164460
  error: "would_regress",
@@ -164398,14 +164466,14 @@ himi v${cliVersion()} \u2014 run \`himi --version\`. If authoring commands fail
164398
164466
  moves: plan.touched,
164399
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.`,
164400
164468
  proof: "core/serve-control/tests/routes-release-branch-patch.test.ts:126"
164401
- }, null, 2));
164469
+ }), null, 2));
164402
164470
  return 1;
164403
164471
  }
164404
164472
  }
164405
164473
  }
164406
164474
  const notCarried = opts.notCarried ?? [];
164407
164475
  if (notCarried.length > 0 && !opts.acceptPartial) {
164408
- io2.error(JSON.stringify({
164476
+ io2.error(JSON.stringify(pushFailure2("not_carried_by_patch", {
164409
164477
  ok: false,
164410
164478
  patched: false,
164411
164479
  error: "not_carried_by_patch",
@@ -164415,25 +164483,25 @@ himi v${cliVersion()} \u2014 run \`himi --version\`. If authoring commands fail
164415
164483
  notCarried,
164416
164484
  carriedByPatch: ["screens", "bundles", "tokens", "appConfig"],
164417
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."
164418
- }, null, 2));
164486
+ }), null, 2));
164419
164487
  return 1;
164420
164488
  }
164421
164489
  if (opts.ux?.evidence && opts.ux.evidence.state !== "recorded" && !opts.force) {
164422
- print({
164490
+ print(pushFailure2(uxEvidenceCode2(opts.ux.evidence.state), {
164423
164491
  ok: false,
164424
164492
  patched: false,
164425
164493
  pushed: false,
164426
164494
  uxEvidence: opts.ux.evidence,
164427
164495
  releaseId: opts.ux.ctx.releaseId,
164428
164496
  hint: uxHandoverHint(opts.ux.evidence, opts.ux.ctx)
164429
- });
164497
+ }));
164430
164498
  return 1;
164431
164499
  }
164432
164500
  let r2;
164433
164501
  try {
164434
164502
  r2 = await client2().releasePatch(app, base.releaseId, plan.patch, draft, opts.meta, opts.force);
164435
164503
  } catch (e2) {
164436
- 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 })));
164437
164505
  return 1;
164438
164506
  }
164439
164507
  if (!r2.ok) {
@@ -164499,17 +164567,17 @@ himi v${cliVersion()} \u2014 run \`himi --version\`. If authoring commands fail
164499
164567
  const wantDraft = flags.draft === true;
164500
164568
  const wantLive = flags.live === true;
164501
164569
  if (wantDraft && wantLive) {
164502
- 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." })));
164503
164571
  return null;
164504
164572
  }
164505
164573
  if (!wantDraft && !wantLive) {
164506
- io2.error(JSON.stringify({
164574
+ io2.error(JSON.stringify(pushFailure2("no_blast_radius", {
164507
164575
  ok: false,
164508
164576
  error: "no_blast_radius",
164509
164577
  message: "a patch must say where it lands: --draft (nothing changes on any device) or --live (every device, immediately).",
164510
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.",
164511
164579
  usage
164512
- }));
164580
+ })));
164513
164581
  return null;
164514
164582
  }
164515
164583
  return wantDraft;
@@ -166688,15 +166756,15 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166688
166756
  if (flags.only !== void 0) {
166689
166757
  onlyRaw = str9(flags.only);
166690
166758
  if (!onlyRaw) {
166691
- 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 })));
166692
166760
  return 2;
166693
166761
  }
166694
166762
  if (argv.filter((a2) => a2 === "--only" || a2.startsWith("--only=")).length > 1) {
166695
- io2.error(JSON.stringify({
166763
+ io2.error(JSON.stringify(pushFailure2("duplicate_only", {
166696
166764
  error: "duplicate_only",
166697
166765
  usage: ONLY_USAGE,
166698
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"
166699
- }));
166767
+ })));
166700
166768
  return 2;
166701
166769
  }
166702
166770
  for (const [flag, why] of [
@@ -166704,12 +166772,12 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166704
166772
  ["visibility", "the patch route does not stamp visibility; set it with `himi visibility` instead"]
166705
166773
  ]) {
166706
166774
  if (flags[flag] !== void 0) {
166707
- 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 })));
166708
166776
  return 2;
166709
166777
  }
166710
166778
  }
166711
166779
  if (flags["from-release"] !== void 0 && !str9(flags["from-release"])) {
166712
- 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 })));
166713
166781
  return 2;
166714
166782
  }
166715
166783
  if (patchBlastRadius(ONLY_USAGE) === null) return 2;
@@ -166728,14 +166796,14 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166728
166796
  const { loadTargetsFile: loadTargetsFile2 } = await Promise.resolve().then(() => (init_config(), config_exports));
166729
166797
  const configured = loadTargetsFile2();
166730
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 }));
166731
- io2.error(JSON.stringify({
166799
+ io2.error(JSON.stringify(pushFailure2("control_token_expired", {
166732
166800
  ok: false,
166733
166801
  pushed: false,
166734
166802
  error: "control_token_expired",
166735
166803
  target: pushTarget,
166736
166804
  ...usable.length ? { usableTargets: usable } : {},
166737
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.` : "")
166738
- }));
166806
+ })));
166739
166807
  return 1;
166740
166808
  }
166741
166809
  let app = resolveAppSlug(declared, (pushToken ? decodeControlTokenClaims(pushToken) : null) ?? {});
@@ -166763,7 +166831,7 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166763
166831
  ...await navIssues2(dir, strict)
166764
166832
  ]);
166765
166833
  if (hasErrors && !force) {
166766
- 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" }));
166767
166835
  return 1;
166768
166836
  }
166769
166837
  const declaredData = await loadDataResourcesQuietly(dir);
@@ -166782,28 +166850,28 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166782
166850
  bundles: Object.keys(manifest.bundles ?? {})
166783
166851
  });
166784
166852
  if (!parsed.ok) {
166785
- io2.error(JSON.stringify(parsed.error, null, 2));
166853
+ io2.error(JSON.stringify(pushFailure2("invalid_selector", parsed.error), null, 2));
166786
166854
  return 2;
166787
166855
  }
166788
166856
  const fromRelease = str9(flags["from-release"]);
166789
166857
  const baseForPlan = await fetchReleaseManifest(client2, app, fromRelease);
166790
166858
  if ("error" in baseForPlan) {
166791
- io2.error(JSON.stringify(baseForPlan));
166859
+ io2.error(JSON.stringify(pushFailure2("release_read_failed", baseForPlan)));
166792
166860
  return 1;
166793
166861
  }
166794
166862
  if (!baseForPlan.manifest) {
166795
- io2.error(JSON.stringify({
166863
+ io2.error(JSON.stringify(pushFailure2(fromRelease ? "base_not_found" : "no_live_release", {
166796
166864
  ok: false,
166797
166865
  error: fromRelease ? "base_not_found" : "no_live_release",
166798
166866
  app,
166799
166867
  message: fromRelease ? `no release ${fromRelease} for ${app}.` : `no published release for "${app}" to patch \u2014 run \`himi push\` first.`,
166800
166868
  hint: "if this app is registered under a different slug, pass --app <slug>."
166801
- }));
166869
+ })));
166802
166870
  return 1;
166803
166871
  }
166804
166872
  const planned = planFromPayload2(payload, baseForPlan.manifest, parsed.selectors);
166805
166873
  if (!planned.ok) {
166806
- io2.error(JSON.stringify(planned.error, null, 2));
166874
+ io2.error(JSON.stringify(pushFailure2("invalid_patch", planned.error), null, 2));
166807
166875
  return 1;
166808
166876
  }
166809
166877
  const patchUxEvidence = handoverUxEvidence(dir, {
@@ -166873,21 +166941,21 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166873
166941
  const collected = collectAppSource2(dir);
166874
166942
  const findings = secretFindings2(collected.entries);
166875
166943
  if (findings.length > 0) {
166876
- io2.error(JSON.stringify({
166944
+ io2.error(JSON.stringify(pushFailure2("app_source_contains_secrets", {
166877
166945
  ok: false,
166878
166946
  pushed: false,
166879
166947
  error: `refusing to archive the source: ${findings[0].path} looks like it contains ${findings[0].reason}`,
166880
166948
  file: findings[0].path,
166881
166949
  found: findings.map((f2) => f2.path),
166882
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"
166883
- }));
166951
+ })));
166884
166952
  return 1;
166885
166953
  }
166886
166954
  const zip = zipAppSource2(collected.entries);
166887
166955
  const encoded2 = zip.toString("base64");
166888
166956
  if (encoded2.length > MAX_APP_SOURCE_BYTES2) {
166889
166957
  const largest = largestEntries2(collected.entries);
166890
- io2.error(JSON.stringify({
166958
+ io2.error(JSON.stringify(pushFailure2("app_source_too_large", {
166891
166959
  ok: false,
166892
166960
  pushed: false,
166893
166961
  error: "app_source_too_large",
@@ -166897,7 +166965,7 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166897
166965
  files: collected.entries.length,
166898
166966
  largest,
166899
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.`
166900
- }));
166968
+ })));
166901
166969
  return 1;
166902
166970
  }
166903
166971
  appSourceB64 = encoded2;
@@ -166914,22 +166982,22 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166914
166982
  if (attestationErrors.length > 0 && !force) {
166915
166983
  const remedy = `\`himi test --content ${contentArg2}\` (or \`himi render <screenId> --content ${contentArg2}\`)`;
166916
166984
  const corrupted = evidence.historyErrors.length > 0;
166917
- print({
166985
+ print(pushFailure2(corrupted ? "attestation_history_corrupt" : "attestation_stale", {
166918
166986
  ok: false,
166919
166987
  pushed: false,
166920
166988
  errors: attestationErrors,
166921
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\`.`
166922
- });
166990
+ }));
166923
166991
  return 1;
166924
166992
  }
166925
166993
  const attestation = evidence.selected ? summaryOf(evidence.selected) : void 0;
166926
166994
  if (attestation?.report.ok === false && !force) {
166927
- print({
166995
+ print(pushFailure2("headless_run_failed", {
166928
166996
  ok: false,
166929
166997
  pushed: false,
166930
166998
  attestation,
166931
166999
  hint: "the latest verified headless run failed \u2014 fix it and rerun the tool, or re-run `himi push --force` to publish anyway"
166932
- });
167000
+ }));
166933
167001
  return 1;
166934
167002
  }
166935
167003
  const uxEvidence = handoverUxEvidence(dir, {
@@ -166941,7 +167009,7 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166941
167009
  inheritedScreens: []
166942
167010
  });
166943
167011
  if (uxEvidence && uxEvidence.state !== "recorded" && !force) {
166944
- print({
167012
+ print(pushFailure2(uxEvidenceCode2(uxEvidence.state), {
166945
167013
  ok: false,
166946
167014
  pushed: false,
166947
167015
  uxEvidence,
@@ -166959,7 +167027,7 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166959
167027
  // `inherited-screens` and has no base to restore.
166960
167028
  nondeterministicWorkers: drifting
166961
167029
  })
166962
- });
167030
+ }));
166963
167031
  return 1;
166964
167032
  }
166965
167033
  const forcedPastErrors = force && (hasErrors || attestation?.report.ok === false || attestationErrors.length > 0);
@@ -166970,7 +167038,7 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166970
167038
  bakedBytes: assetRecords.filter(([, a2]) => a2.baked).reduce((n2, [, a2]) => n2 + a2.bytes, 0),
166971
167039
  otaOnly: assetRecords.filter(([, a2]) => !a2.baked).map(([name]) => name)
166972
167040
  } : null;
166973
- const visArg = visibilityFlag(flags, io2);
167041
+ const visArg = visibilityFlag(flags, io2, (fields) => pushFailure2("invalid_visibility", fields));
166974
167042
  if (!visArg) return 2;
166975
167043
  const visibility = visArg.value ?? await contentVisibility(dir);
166976
167044
  const meta = {
@@ -166989,7 +167057,7 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166989
167057
  const publishBodyBytesFor = (slug) => publishReleaseBodyBytes(publishBodyFor(slug), meta, draft);
166990
167058
  const refuseOversizedBody = (slug, bodyBytes) => {
166991
167059
  const withoutSource = appSourceB64 ? bodyBytes - appSourceB64.length : bodyBytes;
166992
- io2.error(JSON.stringify({
167060
+ io2.error(JSON.stringify(pushFailure2("publish_body_too_large", {
166993
167061
  ok: false,
166994
167062
  app: slug,
166995
167063
  pushed: false,
@@ -166998,7 +167066,7 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
166998
167066
  limit: MAX_PUBLISH_BODY_BYTES,
166999
167067
  ...appSourceB64 ? { appSourceBytes: appSourceB64.length } : {},
167000
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"
167001
- }));
167069
+ })));
167002
167070
  return 1;
167003
167071
  };
167004
167072
  const composedBodyBytes = publishBodyBytesFor(app);
@@ -167009,14 +167077,14 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
167009
167077
  if (ownerIssue && pushToken && !tokenCoversApp(pushToken, app)) {
167010
167078
  const claim = await autoClaimSlug({ ownerIssue, controlToken: pushToken, app, accountId: str9(flags["account-id"]) });
167011
167079
  if (claim.fatal) {
167012
- io2.error(JSON.stringify({
167080
+ io2.error(JSON.stringify(pushFailure2("claim_failed", {
167013
167081
  ok: false,
167014
167082
  app,
167015
167083
  pushed: false,
167016
167084
  status: claim.fatal.status,
167017
167085
  ...claim.fatal.body,
167018
167086
  ...claimHint(claim.fatal.status, claim.fatal.error)
167019
- }));
167087
+ })));
167020
167088
  return 1;
167021
167089
  }
167022
167090
  if (claim.refusal) io2.error(JSON.stringify({ warning: claim.refusal.error, hint: claim.refusal.hint }));
@@ -167037,16 +167105,16 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
167037
167105
  r2 = await client2().publishRelease(publishPayload, meta, draft);
167038
167106
  } catch (e2) {
167039
167107
  const target = targetName();
167040
- io2.error(JSON.stringify({
167108
+ io2.error(JSON.stringify(pushFailure2("needs_vpn", {
167041
167109
  ...serveUnreachableError(target, serveBase(), e2.message),
167042
167110
  version: cliVersion()
167043
- }));
167111
+ })));
167044
167112
  return 1;
167045
167113
  }
167046
167114
  if (!r2.ok) {
167047
167115
  const authHint = pushAuthHint(r2.status, pushToken, pushTarget, !!ownerIssue);
167048
167116
  const hint = authHint ?? releaseIdExistsHint(r2.status, r2.json);
167049
- 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 } : {} }));
167050
167118
  return 1;
167051
167119
  }
167052
167120
  const release = r2.json?.releaseId ?? manifest.releaseId;
@@ -170283,15 +170351,15 @@ Code: ${userCode}`);
170283
170351
  }
170284
170352
  } catch (err) {
170285
170353
  if (err instanceof AuthoringUnavailable) {
170286
- io2.error(JSON.stringify({
170354
+ io2.error(JSON.stringify(pushFailure2("authoring_unavailable", {
170287
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.`,
170288
170356
  version: cliVersion(),
170289
170357
  upgrade: "npm i -g @wix/himalaya-cli@latest",
170290
170358
  portal: DEVELOPER_PORTAL_URL
170291
- }));
170359
+ })));
170292
170360
  return 1;
170293
170361
  }
170294
- io2.error(JSON.stringify({ error: err.message }));
170362
+ io2.error(JSON.stringify(pushFailure2("push_failed", { error: err.message })));
170295
170363
  const code = err.exitCode;
170296
170364
  return code === 2 ? 2 : 1;
170297
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.1246.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": "5e90fa27e7fc17c28b74ef6630a0742b66132390f44c0270ca325392"
70
+ "falconPackageHash": "ba75b3fdbeb557376aad2c794b64762b5e246a1c72a94d970720534c"
71
71
  }