@wix/himalaya-cli 0.968.0 → 0.970.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
@@ -537,7 +537,7 @@ var init_store = __esm({
537
537
 
538
538
  // ../../core/serve-storage/src/file-store.ts
539
539
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2, readdirSync, existsSync } from "node:fs";
540
- import { join as join2, resolve } from "node:path";
540
+ import { join as join2, resolve, sep } from "node:path";
541
541
  var FileReleaseStore;
542
542
  var init_file_store = __esm({
543
543
  "../../core/serve-storage/src/file-store.ts"() {
@@ -547,8 +547,37 @@ var init_file_store = __esm({
547
547
  constructor(root) {
548
548
  this.root = resolve(root);
549
549
  }
550
+ /** The app's directory — and the ONE place an app id becomes a path in this store.
551
+ *
552
+ * IT VALIDATES, because `reset()` below deletes this path recursively and `appDir` was a bare
553
+ * `join(this.root, app)`. `remove()` called `this.safe(app)` first and `reset()` did not, three
554
+ * lines apart, so `reset("../../user-code/himalaya")` escaped the root and recursively removed
555
+ * whatever the process could write. Every other method reached the same unguarded join.
556
+ *
557
+ * Guarding the CALL SITES is what produced that bug: two of eight remembered. So the guard moves
558
+ * into the derivation, where forgetting it is not possible — a method that wants the directory
559
+ * has to come through here.
560
+ *
561
+ * IT IS NOT THE PUBLIC APP-ID GRAMMAR, and that distinction cost a CI round. The first cut of
562
+ * this called `assertAppId` — the grammar the ROUTES enforce — and refused `_template`, the
563
+ * internal app `buildRelease` resets through this store (build.ts). Nothing is wrong with that
564
+ * caller: this store's key is a local build-output directory name, which is a wider namespace
565
+ * than a published app id, and a published id is validated where it is published.
566
+ *
567
+ * What remains is the invariant that actually carries the security property, and it is enough on
568
+ * its own: a path that RESOLVES outside the root is refused. `../../user-code/himalaya` and any
569
+ * absolute path resolve outside; `_template` resolves inside. The root ITSELF is refused too —
570
+ * `reset("")` would otherwise recursively delete the whole store. `safe()` runs first as the
571
+ * cheap, readable half (no separator, no `..`), the same rule this class already applies to every
572
+ * screen id and sha it turns into a filename; the resolved-path check is the belt behind it, and
573
+ * catches what a string test cannot, including a separator this one does not know about.
574
+ */
550
575
  appDir(app) {
551
- return join2(this.root, app);
576
+ const dir = resolve(this.root, this.safe(app));
577
+ if (!dir.startsWith(this.root + sep)) {
578
+ throw new Error(`unsafe app id ${JSON.stringify(app)}: ${dir} is not inside the store root`);
579
+ }
580
+ return dir;
552
581
  }
553
582
  safe(name) {
554
583
  if (name.includes("/") || name.includes("..")) throw new Error(`unsafe name: ${name}`);
@@ -561,7 +590,7 @@ var init_file_store = __esm({
561
590
  mkdirSync2(join2(dir, "bundles"), { recursive: true });
562
591
  }
563
592
  async remove(app) {
564
- rmSync(this.appDir(this.safe(app)), { recursive: true, force: true });
593
+ rmSync(this.appDir(app), { recursive: true, force: true });
565
594
  }
566
595
  async putManifest(app, manifest) {
567
596
  validateFunctionManifest(manifest);
@@ -771,7 +800,7 @@ var init_base64 = __esm({
771
800
  });
772
801
 
773
802
  // src/source-deny.ts
774
- import { sep } from "node:path";
803
+ import { sep as sep2 } from "node:path";
775
804
  function isDeniedSourcePath(relPath) {
776
805
  const segments = relPath.split(/[\\/]/).filter(Boolean);
777
806
  if (segments.length === 0) return false;
@@ -781,7 +810,7 @@ function isDeniedSourcePath(relPath) {
781
810
  return DENY_FILES.some((re) => re.test(segments[segments.length - 1]));
782
811
  }
783
812
  function isDeniedSourceDir(name, parentRel) {
784
- const base = name.split(sep).pop() ?? name;
813
+ const base = name.split(sep2).pop() ?? name;
785
814
  if (DENY_DIRS.has(base)) return true;
786
815
  return parentRel === "" && DENY_ROOT_DIRS.has(base);
787
816
  }
@@ -1289,7 +1318,7 @@ function sentinelizeIdentity(body, identity) {
1289
1318
  }
1290
1319
  if (identity.name) {
1291
1320
  const re = new RegExp(`\\b(${NAME_FIELDS.join("|")})(["']?\\s*[:=]\\s*)(["'])${escapeRe(identity.name)}\\3`, "g");
1292
- out = out.replace(re, (_m, key2, sep11, q) => `${key2}${sep11}${q}__HIMI_APP_NAME__${q}`);
1321
+ out = out.replace(re, (_m, key2, sep12, q) => `${key2}${sep12}${q}__HIMI_APP_NAME__${q}`);
1293
1322
  }
1294
1323
  return out;
1295
1324
  }
@@ -2836,7 +2865,7 @@ __export(build_lib_exports, {
2836
2865
  umbrellaRewritePlugin: () => umbrellaRewritePlugin
2837
2866
  });
2838
2867
  import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL2 } from "node:url";
2839
- import { basename as basename4, dirname as dirname8, join as join12, resolve as resolve8, sep as sep2 } from "node:path";
2868
+ import { basename as basename4, dirname as dirname8, join as join12, resolve as resolve8, sep as sep3 } from "node:path";
2840
2869
  import { readdirSync as readdirSync6, existsSync as existsSync8, readFileSync as readFileSync10, realpathSync as realpathSync2, statSync as statSync2 } from "node:fs";
2841
2870
  async function loadCompiler() {
2842
2871
  try {
@@ -2884,7 +2913,7 @@ function realpathOrSelf(p) {
2884
2913
  }
2885
2914
  }
2886
2915
  function isUnder(p, root) {
2887
- return p === root || p.startsWith(root + sep2);
2916
+ return p === root || p.startsWith(root + sep3);
2888
2917
  }
2889
2918
  function confinementRoots(contentRoot) {
2890
2919
  const roots = /* @__PURE__ */ new Set();
@@ -3174,7 +3203,7 @@ function tsExtResolvePlugin() {
3174
3203
  }
3175
3204
  function importMetaUrlPlugin({ scope } = {}) {
3176
3205
  const scopes = scope ? [.../* @__PURE__ */ new Set([realpathOrSelf(scope), resolve8(scope)])] : [];
3177
- const inScope = (p) => scopes.some((r) => p === r || p.startsWith(r + sep2));
3206
+ const inScope = (p) => scopes.some((r) => p === r || p.startsWith(r + sep3));
3178
3207
  return {
3179
3208
  name: "himalaya-import-meta-url",
3180
3209
  setup(b) {
@@ -3830,7 +3859,7 @@ var init_tier5_dir = __esm({
3830
3859
 
3831
3860
  // src/tier5-typecheck.ts
3832
3861
  import { existsSync as existsSync9, readFileSync as readFileSync11 } from "node:fs";
3833
- import { relative as relative2, resolve as resolve10, sep as sep3 } from "node:path";
3862
+ import { relative as relative2, resolve as resolve10, sep as sep4 } from "node:path";
3834
3863
  import { pathToFileURL as pathToFileURL3 } from "node:url";
3835
3864
  async function loadTypeScript() {
3836
3865
  const runtime = process.env.HIMI_AUTHORING_RUNTIME_DIR;
@@ -3869,7 +3898,7 @@ function pathsFor(options) {
3869
3898
  return declarationPaths(options.umbrellaDir);
3870
3899
  }
3871
3900
  function relativeFile(contentDir2, fileName) {
3872
- return relative2(contentDir2, fileName).split(sep3).join("/");
3901
+ return relative2(contentDir2, fileName).split(sep4).join("/");
3873
3902
  }
3874
3903
  function belongsToContentWorkers(contentDir2, diagnostic) {
3875
3904
  if (!diagnostic.file) return true;
@@ -11268,7 +11297,7 @@ __export(icon_exports, {
11268
11297
  });
11269
11298
  import { createHash as createHash5 } from "node:crypto";
11270
11299
  import { existsSync as existsSync15, mkdirSync as mkdirSync9, readFileSync as readFileSync18, statSync as statSync4, writeFileSync as writeFileSync8 } from "node:fs";
11271
- import { basename as basename5, dirname as dirname14, join as join19, resolve as resolve14, sep as sep4 } from "node:path";
11300
+ import { basename as basename5, dirname as dirname14, join as join19, resolve as resolve14, sep as sep5 } from "node:path";
11272
11301
  function readManifest(dir) {
11273
11302
  try {
11274
11303
  return JSON.parse(readFileSync18(join19(dir, MANIFEST), "utf8"));
@@ -11285,7 +11314,7 @@ function declaredIconName(dir) {
11285
11314
  function iconDest(dir, name) {
11286
11315
  const base = resolve14(dir);
11287
11316
  const abs = resolve14(base, name);
11288
- if (!abs.startsWith(base + sep4)) {
11317
+ if (!abs.startsWith(base + sep5)) {
11289
11318
  throw new Error(
11290
11319
  `himi icon: himalaya.content.json declares "icon": "${name}", which resolves outside the package (${abs}). Declare a path inside it, e.g. "${DEFAULT_CONTENT_ICON}".`
11291
11320
  );
@@ -12091,6 +12120,12 @@ var init_wix_gateway_rules = __esm({
12091
12120
  // src/ux-evidence.ts
12092
12121
  import { existsSync as existsSync21, mkdirSync as mkdirSync10, readFileSync as readFileSync23, writeFileSync as writeFileSync9 } from "node:fs";
12093
12122
  import { dirname as dirname15, isAbsolute as isAbsolute2, join as join25, relative as relative3, resolve as resolve15 } from "node:path";
12123
+ function isNativePlatform(value) {
12124
+ return typeof value === "string" && NATIVE_PLATFORMS.includes(value);
12125
+ }
12126
+ function isNativeAuditStatus(value) {
12127
+ return typeof value === "string" && NATIVE_AUDIT_STATUSES.includes(value);
12128
+ }
12094
12129
  function readText(path) {
12095
12130
  try {
12096
12131
  return existsSync21(path) ? readFileSync23(path, "utf8") : null;
@@ -12162,7 +12197,7 @@ function recordRenderWidth(content, entry) {
12162
12197
  return null;
12163
12198
  }
12164
12199
  }
12165
- function recordNativeAudit(content, platform, status, checks, reason) {
12200
+ function recordNativeAudit(content, platform, status, checks, reason, release) {
12166
12201
  const normalizedChecks = status === "unavailable" ? [] : [...new Set(checks.map((check) => check.trim()).filter(Boolean))];
12167
12202
  if (status !== "unavailable" && !normalizedChecks.length) {
12168
12203
  throw new Error("--checks needs at least one comma-separated check (for example: back,keyboard,voiceover)");
@@ -12173,13 +12208,89 @@ function recordNativeAudit(content, platform, status, checks, reason) {
12173
12208
  const parsed = parseEvidence(path);
12174
12209
  if (parsed.error) throw new Error(`${UX_EVIDENCE_PATH} ${parsed.error}`);
12175
12210
  const evidence = parsed.evidence ?? { version: 1, platforms: {} };
12176
- evidence.platforms[platform] = { status, checks: normalizedChecks, ...reason?.trim() ? { reason: reason.trim() } : {}, recordedAt: (/* @__PURE__ */ new Date()).toISOString() };
12211
+ evidence.platforms[platform] = {
12212
+ status,
12213
+ checks: normalizedChecks,
12214
+ ...reason?.trim() ? { reason: reason.trim() } : {},
12215
+ ...release?.releaseId && release.releaseHash ? { releaseId: release.releaseId, releaseHash: release.releaseHash } : {},
12216
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString()
12217
+ };
12177
12218
  writeEvidence(path, evidence);
12178
12219
  return evidence;
12179
12220
  }
12221
+ function uxEvidenceForRelease(content, release) {
12222
+ const { releaseId, releaseHash } = release;
12223
+ const path = uxEvidencePath(content);
12224
+ const base = {
12225
+ path: UX_EVIDENCE_PATH,
12226
+ platforms: [],
12227
+ failedPlatforms: [],
12228
+ otherReleases: [],
12229
+ unstamped: 0,
12230
+ inheritedScreens: [],
12231
+ malformed: []
12232
+ };
12233
+ if (!existsSync21(path)) return { ...base, state: "absent" };
12234
+ const parsed = parseEvidence(path);
12235
+ if (parsed.error) return { ...base, state: "unreadable", error: parsed.error };
12236
+ if (!parsed.evidence) return { ...base, state: "absent" };
12237
+ const recorded = [];
12238
+ const malformed = [];
12239
+ for (const [key2, value] of Object.entries(parsed.evidence.platforms)) {
12240
+ if (!value || typeof value !== "object") continue;
12241
+ const entry = value;
12242
+ if (!isNativePlatform(key2)) {
12243
+ malformed.push(`${key2} (not a platform this tool drives)`);
12244
+ continue;
12245
+ }
12246
+ if (!isNativeAuditStatus(entry.status)) {
12247
+ malformed.push(`${key2} (status ${JSON.stringify(entry.status ?? null)} is not one \`himi ux record\` writes)`);
12248
+ continue;
12249
+ }
12250
+ if (!Array.isArray(entry.checks) || entry.checks.some((check) => typeof check !== "string")) {
12251
+ malformed.push(`${key2} (no \`checks\` array \u2014 not a record \`himi ux record\` wrote)`);
12252
+ continue;
12253
+ }
12254
+ if (typeof entry.recordedAt !== "string" || !entry.recordedAt) {
12255
+ malformed.push(`${key2} (no \`recordedAt\` \u2014 not a record \`himi ux record\` wrote)`);
12256
+ continue;
12257
+ }
12258
+ if (entry.reason !== void 0 && typeof entry.reason !== "string") {
12259
+ malformed.push(`${key2} (\`reason\` is not text)`);
12260
+ continue;
12261
+ }
12262
+ recorded.push([key2, entry]);
12263
+ }
12264
+ if (recorded.length === 0) return { ...base, state: "unrecorded", malformed };
12265
+ const forRelease = recorded.filter(([, entry]) => entry.releaseId === releaseId && entry.releaseHash === releaseHash);
12266
+ if (forRelease.length > 0) {
12267
+ const platforms = forRelease.map(([platform]) => platform);
12268
+ const failedPlatforms = forRelease.filter(([, entry]) => entry.status === "failed").map(([platform]) => platform);
12269
+ return failedPlatforms.length > 0 ? { ...base, state: "failed", platforms, failedPlatforms, malformed } : { ...base, state: "recorded", platforms, malformed };
12270
+ }
12271
+ if (recorded.some(([, entry]) => entry.releaseId === releaseId)) {
12272
+ return {
12273
+ ...base,
12274
+ state: "outdated",
12275
+ platforms: recorded.filter(([, entry]) => entry.releaseId === releaseId).map(([platform]) => platform),
12276
+ malformed
12277
+ };
12278
+ }
12279
+ const otherReleases = [...new Set(recorded.map(([, entry]) => entry.releaseId).filter((id) => !!id))].sort();
12280
+ return {
12281
+ ...base,
12282
+ state: "stale",
12283
+ otherReleases,
12284
+ unstamped: recorded.filter(([, entry]) => !entry.releaseId).length,
12285
+ malformed
12286
+ };
12287
+ }
12288
+ var NATIVE_PLATFORMS, NATIVE_AUDIT_STATUSES;
12180
12289
  var init_ux_evidence = __esm({
12181
12290
  "src/ux-evidence.ts"() {
12182
12291
  init_mobile_ux_contract();
12292
+ NATIVE_PLATFORMS = ["ios", "android"];
12293
+ NATIVE_AUDIT_STATUSES = ["passed", "failed", "unavailable"];
12183
12294
  }
12184
12295
  });
12185
12296
 
@@ -13853,7 +13964,7 @@ var init_app_module = __esm({
13853
13964
  // ../../core/dev-server/src/app-assets.ts
13854
13965
  import { createHash as createHash8 } from "node:crypto";
13855
13966
  import { existsSync as existsSync27, lstatSync as lstatSync4, readFileSync as readFileSync29, readdirSync as readdirSync14, realpathSync as realpathSync3, statSync as statSync5 } from "node:fs";
13856
- import { join as join31, resolve as resolve19, sep as sep5 } from "node:path";
13967
+ import { join as join31, resolve as resolve19, sep as sep6 } from "node:path";
13857
13968
  function contentTypeFor(name) {
13858
13969
  const ext = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1).toLowerCase() : "";
13859
13970
  return CONTENT_TYPES[ext] ?? "application/octet-stream";
@@ -13874,7 +13985,7 @@ function findAssetsDir(appDir2) {
13874
13985
  const rel = typeof declared === "string" && declared ? declared : "assets";
13875
13986
  const abs = resolve19(appDir2, rel);
13876
13987
  const base = resolve19(appDir2);
13877
- if (abs !== base && !abs.startsWith(base + sep5)) return null;
13988
+ if (abs !== base && !abs.startsWith(base + sep6)) return null;
13878
13989
  return existsSync27(abs) && statSync5(abs).isDirectory() ? abs : null;
13879
13990
  }
13880
13991
  function readOtaOnlyPatterns(appDir2) {
@@ -13932,7 +14043,7 @@ function collectBrandFonts(appDir2) {
13932
14043
  try {
13933
14044
  if (!lstatSync4(path).isFile()) continue;
13934
14045
  const root = realpathSync3(appDir2);
13935
- if (!realpathSync3(path).startsWith(root + sep5)) continue;
14046
+ if (!realpathSync3(path).startsWith(root + sep6)) continue;
13936
14047
  } catch {
13937
14048
  continue;
13938
14049
  }
@@ -15153,7 +15264,7 @@ function redactBodyString(body) {
15153
15264
  } catch {
15154
15265
  }
15155
15266
  }
15156
- return body.replace(FORM_ASSIGNMENT_RE, (m, name) => isSensitiveBodyName(name) ? `${name}=${REDACTED}` : m).replace(JSON_FIELD_RE, (m, name, sep11) => isSensitiveBodyName(name) ? `"${name}"${sep11}"${REDACTED}"` : m).replace(JSON_FIELD_UNTERMINATED_RE, (m, name, sep11) => isSensitiveBodyName(name) ? `"${name}"${sep11}"${REDACTED}` : m).replace(BEARER_RE, (_m, s) => `${s} ${REDACTED}`);
15267
+ return body.replace(FORM_ASSIGNMENT_RE, (m, name) => isSensitiveBodyName(name) ? `${name}=${REDACTED}` : m).replace(JSON_FIELD_RE, (m, name, sep12) => isSensitiveBodyName(name) ? `"${name}"${sep12}"${REDACTED}"` : m).replace(JSON_FIELD_UNTERMINATED_RE, (m, name, sep12) => isSensitiveBodyName(name) ? `"${name}"${sep12}"${REDACTED}` : m).replace(BEARER_RE, (_m, s) => `${s} ${REDACTED}`);
15157
15268
  }
15158
15269
  function createWixGatewayState() {
15159
15270
  return { mode: "record", rules: [], traffic: [], clients: /* @__PURE__ */ new Set(), seq: 0, ruleSeq: 0 };
@@ -18604,7 +18715,6 @@ function shouldRevealError(args) {
18604
18715
  var EMAIL_RE, URL_HOST_RE, INTEGER_RE, NUMBER_RE;
18605
18716
  var init_validation = __esm({
18606
18717
  "../../core/ts/src/validation.ts"() {
18607
- "use strict";
18608
18718
  EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}$/;
18609
18719
  URL_HOST_RE = /^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*(:[0-9]+)?$/;
18610
18720
  INTEGER_RE = /^[+-]?[0-9]+$/;
@@ -22029,7 +22139,6 @@ function validateWorkerSubtree(raw, slotId, policy, actionCatalog) {
22029
22139
  var WORKER_SUBTREE_CEILINGS, ID_PART, ACTION_ID;
22030
22140
  var init_worker_subtree = __esm({
22031
22141
  "../../core/ts/src/worker-subtree.ts"() {
22032
- "use strict";
22033
22142
  init_worker_subtree_safe_types_generated();
22034
22143
  WORKER_SUBTREE_CEILINGS = Object.freeze({
22035
22144
  maxEncodedBytes: 65536,
@@ -22257,8 +22366,8 @@ function ensureCountUpRuntime() {
22257
22366
  const neg = fixed.startsWith("-");
22258
22367
  const body = neg ? fixed.slice(1) : fixed;
22259
22368
  const [int, frac] = body.split(".");
22260
- const sep11 = int.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
22261
- return (neg ? "-" : "") + sep11 + (frac ? "." + frac : "");
22369
+ const sep12 = int.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
22370
+ return (neg ? "-" : "") + sep12 + (frac ? "." + frac : "");
22262
22371
  };
22263
22372
  const animateEl = (el) => {
22264
22373
  const node = el;
@@ -30109,7 +30218,7 @@ var init_config2 = __esm({
30109
30218
  import { createServer as createServer2 } from "node:http";
30110
30219
  import { readFile as readFile2, stat } from "node:fs/promises";
30111
30220
  import { readFileSync as readFileSync40, existsSync as existsSync35, watch, mkdirSync as mkdirSync16, rmSync as rmSync4 } from "node:fs";
30112
- import { extname, resolve as resolve30, normalize as normalize3, sep as sep6, join as join34 } from "node:path";
30221
+ import { extname, resolve as resolve30, normalize as normalize3, sep as sep7, join as join34 } from "node:path";
30113
30222
  import { createHash as createHash15, randomUUID } from "node:crypto";
30114
30223
  import { fileURLToPath as fileURLToPath18, pathToFileURL as pathToFileURL6 } from "node:url";
30115
30224
  function deepMerge2(base, patch) {
@@ -30927,7 +31036,7 @@ function startBundleSourceWatcher(state) {
30927
31036
  try {
30928
31037
  const watcher = watch(TIER5_SRC_ROOT, { persistent: false, recursive: true }, (_event, filename) => {
30929
31038
  if (!filename || !/\.(ts|tsx)$/.test(filename)) return;
30930
- const name = filename.split(sep6)[0];
31039
+ const name = filename.split(sep7)[0];
30931
31040
  if (!name || name.startsWith(".")) return;
30932
31041
  pending.add(name);
30933
31042
  if (timer) clearTimeout(timer);
@@ -31102,7 +31211,7 @@ function startAppSourceWatcher(state) {
31102
31211
  try {
31103
31212
  const watcher = watch(appDir2, { persistent: false, recursive: true }, (_event, filename) => {
31104
31213
  if (!filename) return;
31105
- const rel = String(filename).split(sep6).join("/");
31214
+ const rel = String(filename).split(sep7).join("/");
31106
31215
  if (!classifyAppPath(rel)) return;
31107
31216
  pending.add(rel);
31108
31217
  if (timer) clearTimeout(timer);
@@ -34619,8 +34728,8 @@ async function crawlScreenInner(opts) {
34619
34728
  if (linkable.has(target)) return true;
34620
34729
  for (const base of linkable) {
34621
34730
  if (target.length > base.length + 1 && target.startsWith(base)) {
34622
- const sep11 = target[base.length];
34623
- if (sep11 === "_" || sep11 === "/") return true;
34731
+ const sep12 = target[base.length];
34732
+ if (sep12 === "_" || sep12 === "/") return true;
34624
34733
  }
34625
34734
  }
34626
34735
  return false;
@@ -38281,7 +38390,7 @@ __export(app_source_exports, {
38281
38390
  zipAppSource: () => zipAppSource
38282
38391
  });
38283
38392
  import { readdirSync as readdirSync21, lstatSync as lstatSync5, readFileSync as readFileSync44, mkdirSync as mkdirSync18, writeFileSync as writeFileSync14, existsSync as existsSync38, rmSync as rmSync6, rmdirSync, unlinkSync as unlinkSync3 } from "node:fs";
38284
- import { join as join37, resolve as resolve34, sep as sep7 } from "node:path";
38393
+ import { join as join37, resolve as resolve34, sep as sep8 } from "node:path";
38285
38394
  import { deflateRawSync as deflateRawSync2 } from "node:zlib";
38286
38395
  function walkSourceTree(dir, file, skip) {
38287
38396
  const walk2 = (relDir) => {
@@ -38301,7 +38410,7 @@ function walkSourceTree(dir, file, skip) {
38301
38410
  skip(rel);
38302
38411
  continue;
38303
38412
  }
38304
- file(rel.split(sep7).join("/"), rel);
38413
+ file(rel.split(sep8).join("/"), rel);
38305
38414
  }
38306
38415
  };
38307
38416
  walk2("");
@@ -38442,7 +38551,7 @@ function pruneEmptyDirs(root, rel, needed, removed) {
38442
38551
  let empty = true;
38443
38552
  for (const name of readdirSync21(rel ? join37(root, rel) : root)) {
38444
38553
  const childRel = rel ? join37(rel, name) : name;
38445
- const posix3 = childRel.split(sep7).join("/");
38554
+ const posix3 = childRel.split(sep8).join("/");
38446
38555
  const kind = lstatSync5(join37(root, childRel));
38447
38556
  if (kind.isSymbolicLink() || !kind.isDirectory() || isDeniedSourceDir(name, rel)) {
38448
38557
  empty = false;
@@ -38509,7 +38618,7 @@ __export(eject_exports, {
38509
38618
  ejectModule: () => ejectModule
38510
38619
  });
38511
38620
  import { existsSync as existsSync39, mkdirSync as mkdirSync19, readFileSync as readFileSync45, writeFileSync as writeFileSync15 } from "node:fs";
38512
- import { dirname as dirname25, join as join38, relative as relative5, resolve as resolve35, sep as sep8 } from "node:path";
38621
+ import { dirname as dirname25, join as join38, relative as relative5, resolve as resolve35, sep as sep9 } from "node:path";
38513
38622
  function locate(repoRootOrCwd, mod) {
38514
38623
  const inRepo = resolve35(repoRootOrCwd, `stdlib/flows/${mod}/src/index.ts`);
38515
38624
  if (existsSync39(inRepo)) return { path: inRepo, form: "source" };
@@ -38562,7 +38671,7 @@ function ejectModule(opts) {
38562
38671
  if (!existsSync39(f)) continue;
38563
38672
  const before = readFileSync45(f, "utf8");
38564
38673
  let after = before;
38565
- let rel = relative5(dirname25(f), target).split(sep8).join("/").replace(/\.tsx?$/, ".js");
38674
+ let rel = relative5(dirname25(f), target).split(sep9).join("/").replace(/\.tsx?$/, ".js");
38566
38675
  if (!rel.startsWith(".")) rel = `./${rel}`;
38567
38676
  for (const spec of specifiersFor(mod)) {
38568
38677
  const q = spec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -41352,7 +41461,7 @@ import { createServer as createServer3 } from "node:http";
41352
41461
  import { createHash as createHash22, randomBytes as randomBytes5 } from "node:crypto";
41353
41462
  import { execFile as execFile2 } from "node:child_process";
41354
41463
  import { existsSync as existsSync44, readdirSync as readdirSync22, readFileSync as readFileSync50, realpathSync as realpathSync4, statSync as statSync8 } from "node:fs";
41355
- import { join as join43, posix, relative as relative6, sep as sep9 } from "node:path";
41464
+ import { join as join43, posix, relative as relative6, sep as sep10 } from "node:path";
41356
41465
  function humanMs(ms) {
41357
41466
  return ms < 1e3 ? `${ms}ms` : `${Math.round(ms / 1e3)}s`;
41358
41467
  }
@@ -41402,7 +41511,7 @@ function collectRelativeSiblings(files, tier5SrcDir) {
41402
41511
  ];
41403
41512
  for (const abs of candidates) {
41404
41513
  if (!existsSync44(abs) || statSync8(abs).isDirectory()) continue;
41405
- const key2 = `/tier5-src/${relative6(tier5SrcDir, abs).split(sep9).join(posix.sep)}`;
41514
+ const key2 = `/tier5-src/${relative6(tier5SrcDir, abs).split(sep10).join(posix.sep)}`;
41406
41515
  if (key2 in files) break;
41407
41516
  files[key2] = readFileSync50(abs, "utf8");
41408
41517
  queue.push(key2);
@@ -48867,7 +48976,7 @@ var init_home_descriptor = __esm({
48867
48976
 
48868
48977
  // src/site-analyze/apply.ts
48869
48978
  import { existsSync as existsSync52, readFileSync as readFileSync62, writeFileSync as writeFileSync21, copyFileSync as copyFileSync3, mkdirSync as mkdirSync24, realpathSync as realpathSync5, statSync as statSync11 } from "node:fs";
48870
- import { dirname as dirname29, isAbsolute as isAbsolute4, join as join50, resolve as resolve40, sep as sep10 } from "node:path";
48979
+ import { dirname as dirname29, isAbsolute as isAbsolute4, join as join50, resolve as resolve40, sep as sep11 } from "node:path";
48871
48980
  import { spawnSync as spawnSync4 } from "node:child_process";
48872
48981
  function unpairedRedirectNote(overlay) {
48873
48982
  if (!overlay.wix?.clientId || overlay.wix.redirectUri) return void 0;
@@ -48962,7 +49071,7 @@ function buildMergedContract(options) {
48962
49071
  const contained = (root, rel) => {
48963
49072
  if (isAbsolute4(rel)) return void 0;
48964
49073
  const full = resolve40(root, rel);
48965
- return full.startsWith(resolve40(root) + sep10) ? full : void 0;
49074
+ return full.startsWith(resolve40(root) + sep11) ? full : void 0;
48966
49075
  };
48967
49076
  const stage = (fromRel, toRel) => {
48968
49077
  const from = contained(packageDir, fromRel);
@@ -48976,7 +49085,7 @@ function buildMergedContract(options) {
48976
49085
  } catch {
48977
49086
  return void 0;
48978
49087
  }
48979
- if (!real.startsWith(realRoot + sep10)) return void 0;
49088
+ if (!real.startsWith(realRoot + sep11)) return void 0;
48980
49089
  try {
48981
49090
  if (!statSync11(real).isFile()) return void 0;
48982
49091
  mkdirSync24(dirname29(to), { recursive: true });
@@ -55265,7 +55374,7 @@ var COMMANDS = [
55265
55374
  { name: "eject", summary: "take ownership of a shared flow module (auth|checkout|screens): copies its source into tier5-src/_ejected/ and repoints this package's imports at the copy. The ladder's escape hatch \u2014 for when the factory options cannot express what you need. AFTER EJECTING, CENTRAL FIXES NO LONGER REACH THIS APP, including future corrections to the fail-closed entitlement and confirm-the-session rules. The floor is unaffected: credentials still go only through the native auth actions and money only through the cashier rail.", required: "<auth|checkout|screens> [--content <dir>]" },
55266
55375
  { name: "validate", summary: "lint the content package (no push): fixed-contract TypeScript checking for tier5-src workers + schema/proto + token refs + component allowlist + $service bindings + strict-enum foot-guns + capability decls + worker boot dry-run + build/compile failures. Type issues are advisory warnings normally; --strict promotes them and hygiene warnings to errors. Exit 1 on any error. Off-repo, the CLI supplies its compiler and SDK declarations.", required: "[--content <dir>] [--strict]" },
55267
55376
  { name: "test", summary: "DERIVED behavioral crawl \u2014 no test files to write. Boots each screen's real worker, replays its onAppear, then dispatches every action site the descriptor already declares (each from a fresh boot) and checks a narrow set of unambiguous invariants: no uncaught throw, never stuck on `loading`, no `$service:` token or NaN/undefined/[object Object] reaching visible text, no binding aimed at state the worker never defines, no nav param silently dropped, no dead nav target. Catches what validate/render cannot \u2014 a WRONG REDUCER passes both with ok:true. Runs the worker in a DEVICE-PARITY sandbox (no setTimeout/setInterval, as on JSC/WebView) so a bundle that would die on device fails here. Crawls on HERMETIC DATA by default \u2014 zero network: the app's own dev/net-mocks.json first (--net <file> overrides), then the config's CMS `seed:` rows, then a canned fixture of frozen real vertical responses shipped with the CLI (exercises code paths, not API-contract truth) \u2014 so the success/mapping path runs, not just the failure UI. Two boot-only HOSTILE passes (every list empty; every request 500) then check the failure/empty day \u2014 corpus-proven, so their findings are errors. NAV EDGES are followed too: every himi.open site ($item params resolved against row 0 of its list, $service against worker state) and worker _nav directive is harvested, and each baked target is booted WITH the params the source sends \u2014 detail screens finally run their content arm; edge findings are errors tagged edge:<source>. --native runs both real native decoders over the exact baked JSON bytes; --native ios or --native android selects one. It is explicit, runs only from a Himalaya SOURCE CHECKOUT (the decoder oracles are that tree's own HimalayaCore Swift package and Gradle harness, which a published install does not ship), and needs the selected local toolchain. From a source checkout, default runs name missing native verdicts in the coverage ledger; elsewhere the ledger stays silent about them rather than offering a remedy you cannot run. --offline strips all data layers (the old reject-all axis); --no-hostile skips the hostile passes. --record (EXPLICIT, never automatic) refreshes dev/net-mocks.json from live reads first: it boots each screen (load + root onAppear ONLY \u2014 nothing the app's own launch would not fire; the tap/action crawl never sees the live transport, and only proven reads \u2014 GET, or read-shaped POST (/query, /search, /list*, \u2026) \u2014 ever reach the wire), freezes the round-trips, then crawls hermetically on the fresh fixture; record against a QA site, not production. Nodes it cannot judge headless are named, never silently passed. Exit 1 on any error. The CLI carries the authoring runtime off-repo; no per-app SDK install is required. --browser runs the whole loop (build \u2192 extract \u2192 validate \u2192 render \u2192 test) in YOUR browser instead of here, so the source never reaches a pod: it opens one screen's worker + descriptor in a tab, blocks on a loopback callback, and prints the report it posts back. One screen per tab \u2014 name it. --no-open prints the URL rather than launching a browser (SSH); --timeout <ms> bounds the wait, which matters because the page has no execution deadline of its own and an infinite loop in the worker hangs the tab.", required: "[<screenId>\u2026] [--content <dir>] [--net <file>] [--budget <ms>] [--deep [n]] [--record] [--native [ios|android]] [--no-shots] [--offline] [--no-hostile] [--all-screens] [--browser] [--no-open] [--timeout <ms>] [--json]" },
55268
- { name: "ux", summary: "audit or record mobile UX evidence. `audit` checks the public-safe contract, seeded blank acceptance rows, placeholder source copy, app-specific mocks, and iOS/Android audit records. It warns while existing templates migrate; `--strict` promotes missing evidence. `record` stores checks performed on an installed iOS/Android build: `--checks` is required for passed|failed and empty for unavailable, which drove nothing and needs a `--reason` instead.", required: "audit [--content <dir>] [--strict] [--json] | record --content <dir> --platform ios|android --status passed|failed|unavailable [--checks back,keyboard,...] [--reason <text>]" },
55377
+ { name: "ux", summary: "audit or record mobile UX evidence. `audit` checks the public-safe contract, seeded blank acceptance rows, placeholder source copy, app-specific mocks, and iOS/Android audit records. It warns while existing templates migrate; `--strict` promotes missing evidence. `record` stores checks performed on an installed iOS/Android build: `--checks` is required for passed|failed and empty for unavailable, which drove nothing and needs a `--reason` instead. `record` also STAMPS the release it audited (it resolves the id from a build, the same one a push would publish), because an unforced `himi push` of a release with screens refuses when no drive is recorded for THAT release, or when the one that is recorded says `failed` \u2014 the artifact stopped being advice nobody reads (#2955). `unavailable` still satisfies it; only `failed` refuses.", required: "audit [--content <dir>] [--strict] [--json] | record --content <dir> --platform ios|android --status passed|failed|unavailable [--checks back,keyboard,...] [--reason <text>]" },
55269
55378
  { name: "render", summary: "headless-render a screen with the web renderer: render-tree + diagnostics, optional --png <file>, --scheme dark, --tree. Local by default (content dir). Pass --remote --app --release to hit GET /v1/preview/\u2026/render on the serve plane after a push. Boots the worker so $service bindings resolve; --static raw bindings; --live real Wix fetches; --state injects worker state; --input supplies pre-boot navigation params; --width/--device for PNG viewport; --rtl for dir=rtl + $env:isRTL.", required: "<screenId> [--content <dir>] [--scheme dark] [--png <file>] [--tree] [--static] [--live] [--state <json|file>] [--input <json|file>] [--width <px> | --device <name>] [--rtl] [--remote --app --release]" },
55270
55379
  { name: "cms", summary: "inspect, provision, and seed the app's CMS (Wix Data) collections, declared as a `cms` block in config.ts. `query` is READ-ONLY \u2014 by default it fetches ONLY the physical ids `config.ts` declares (item counts + schema drift) and reports `siteScanned: false`; it never lists the site, so an UNDECLARED `himi_<app>_` collection this app owns is not reached without `--all`. `--all` SCANS the site \u2014 every collection it lists is labeled `owned`/`adopted`/`foreign`, and it describes every foreign one \u2014 id, non-system field keys + types, and the site's read role \u2014 so an app with no `cms` block at all can DISCOVER a collection to adopt (`--all` is the one case where a missing `cms` block is not 'nothing to do'). Foreign collections are described up to a cap and are never item-counted (`itemCount: null`), because a row count is one query each; `foreign.count` is how many the scan LISTED \u2014 NOT a site census, since the list endpoint is paged and is not followed \u2014 and `foreign.truncated` says how many of those went undescribed, while `unlistedCollections` reports what the listing itself left out (from Wix's own paging total). The scan is OWNER-scoped \u2014 it needs --site <msid> (or WIX_SITE_ID) plus owner auth via `wix token`; a visitor token can read a public collection's ITEMS but can never enumerate collections, so there is no tokenless discovery path. `create` reconciles declared collections additively and idempotently, and REFUSES any id outside the app's `himi_<app>_` namespace, so it is safe against a production site. It reports declared-vs-site `permissionDrift` but never writes permissions unless asked with --apply-permissions, so a field reconcile can't silently revert access control changed in the dashboard. `seed` inserts the git-tracked `seed:` rows into EMPTY owned collections (--force to add to a non-empty one; --ai writes placeholder rows plus a refinement prompt for you to review). Adopted collections (declared by `wixId`) are read-only and never created, updated, or seeded. Site from --site <msid> or WIX_SITE_ID; owner auth via `wix token`.", required: "query|create|seed [--site <msid>] [--content <dir>] [--all] [--dry-run] [--force] [--allow-destructive] [--apply-permissions] [--only <id>] [--ai] [--count <n>]" },
55271
55380
  { name: "data", summary: "read-only plan or explicitly provision/seed declared KV and Wix Data resources. Preview and production are separate scopes. KV administration uses the operator data plane from --data-url/HIMI_DATA_ADMIN_URL and HIMI_DATA_ADMIN_TOKEN; draft push never calls it.", required: "plan|create|seed [--site <msid>] [--environment preview|production] [--content <dir>] [--data-url <url>] [--dry-run] [--force]" },
@@ -55276,7 +55385,7 @@ var COMMANDS = [
55276
55385
  { name: "notify", summary: "send a REAL push notification to registered devices (RFC 0036 WS5) \u2014 APNs .p8 over HTTP/2 for iOS, FCM v1 for Android. `send` targets one device (--token / --device <registry id>) or fans out to the whole app (--all), reporting a per-device result; a token the provider says is uninstalled (APNs 410 Unregistered / FCM UNREGISTERED) is auto-disabled, while a merely wrong one (BadDeviceToken \u2014 usually a sandbox/production mismatch) is recorded and left enabled. `tokens` lists the registry. `doctor` reports which credentials resolve and which sender each platform picks. Talks to the admin-server via --admin-url/HIMALAYA_ADMIN_URL, authenticated with --admin-session/HIMALAYA_ADMIN_SESSION. NOT `himi push`, which publishes a release.", required: "send|tokens|doctor [--app <a>] [--token <t> | --device <id> | --all] [--title <s>] [--body <s>] [--link <url>] [--platform ios|android] [--badge <n>] [--channel <id>] [--silent] [--dry-run] [--json]" },
55277
55386
  { name: "site", summary: "PROVISION a Wix project, then analyze or extend it. `create` is the one door that makes a site instead of adopting one: POST /funnel/projects/v1/create (a WIX site, a HEADLESS backend, a BRANDED_APP, or a VIBE site), and for headless it also mints the OAuth app whose id IS the `WIX_CLIENT_ID` \u2014 with --redirect-uri already registered, because an unregistered redirect makes native signInDirect fail at redirect-session. `--verticals` REQUIRES `--template` and reports only what Wix confirmed: measured, installing onto a project created from the BLANK template returns 200 while the vertical's API answers 428 forever, because there is no site for it to attach to (the install asks for CREATE_SITE). A real template ships its verticals installed and populated, and installing further ones onto it then works. It MUTATES your Wix account and needs --yes; without it you get the payload and nothing is sent. `list` is the read-only site inventory (with each site's premium flag, which is what explains an installed-but-428 vertical). Auth is the account-scoped `wix token` (no --site), falling back to WIX_API_KEY + WIX_ACCOUNT_ID. `client` is the same OAuth step for a site that ALREADY exists \u2014 the one himi adopts rather than makes \u2014 so a clientId no longer means a manual trip to the dashboard: `client ensure` reconciles rather than mints, because a site can already carry a client with NO registered redirect and shipping that one fails signInDirect at runtime; it reuses a client that carries --redirect-uri, PATCHes one that matches --name but lacks it (unioning, so another app's redirect survives), and creates only when there is nothing to reuse (--new forces a fresh one). --name IS the idempotency key. `client` auth is SITE-scoped (`wix token --site <msid>`) because minting is owner-of-that-site, and --yes gates every mutation: a site that already has a usable client resolves WITHOUT it, but --write-provisioning needs --yes even then because it rewrites a manifest on disk (the dry run's planned.provisioning names the file). That query endpoint IGNORES paging (measured: query.paging, cursorPaging and a top-level paging all return the full set at offset 0), so when pagingMetadata reports more clients than came back, `ensure` REFUSES EVERY outcome \u2014 create, patch AND reuse. Reuse is refused too because pickClient breaks ties on the OLDEST createdDate, so an omitted older same-named MOBILE client with the same redirect would win a later complete read and the resolved clientId would MOVE between runs \u2014 and it gets written into provisioning.json; convergence is what makes a stray duplicate untidy rather than harmful. --name cannot narrow the query (no filter is sent; the name is applied to the rows after they arrive), so the recovery is the dashboard; --new skips the query entirely. A `patched` result is verified against what Wix STORED \u2014 the redirect list is read off the PATCH response, and re-read via GET when the response omits it, because falling back to the list himi just sent would make the guard pass vacuously on exactly the silent no-op it exists to catch. `client list` is the read-only inventory of a site's clients: act on its `shippable` column rather than `hasRedirect`, because shippable ALSO requires a MOBILE applicationType and `ensure` refuses a WEB client \u2014 a WEB one carrying your redirect looks right and is not a candidate \u2014 and it sets truncated:true rather than presenting a partial inventory as the whole one. `analyze` reads a site into a business-book folder (REST + Playwright, GET-only); `site function` list/get/put/publish/invokes that site's Velo HTTP functions and is not `himi functions` (serve-plane jobs). `--apply` bakes into a brandable shell; `--shell` takes the same names `himi dist --shell` does and defaults to branded-lite, and a shell that ships no prepare:customer is refused by name rather than half-built. It never overwrites any shell's committed apps/<shell>/provisioning.json.", required: "create <name> [--type wix|headless|branded-app|vibe] [--template <guid>] [--verticals stores,bookings,\u2026] [--folder <guid>] [--redirect-uri <uri>] [--no-oauth-app] [--write-provisioning [path]] [--site-url <url>] [--json] --yes | list | client ensure|list --site <msid> [--name <name>] [--redirect-uri <uri>] [--new] [--write-provisioning [path]] [--site-url <url>] [--json] --yes | analyze --client-id <id> --url <https> [--site <msid>] [--out <dir>] [--redirect-uri <uri>] [--apply] [--shell " + SHELL_ENUM + "] | function list|get|put|publish|invoke [--site <msid>] [--url <https>] [--method GET|POST|PUT|DELETE] [--source <file>] [--body <json>] [--publish] [--invoke]" },
55278
55387
  { name: "doctor", summary: 'off-VPN preflight in one shot: probe the PUBLIC data plane (www.wixapis.com \u2014 reachable off-VPN), the INTERNAL serve/control plane for the selected target (VPN-gated), and report auth state (dpx session on disk + stored control token). --dpx additionally runs a live, EADDRINUSE-safe `dpx whoami`. Machine-readable with --json (an unreachable internal plane carries a stable code:"needs_vpn" so an agent branches on the code, not the prose); human-readable otherwise. Never loads the authoring runtime \u2014 works off-repo.', required: "[--target <t>] [--dpx] [--json] [--data-url <url>] [--timeout <ms>]" },
55279
- { name: "push", summary: "lint + build + publish a release (default --draft: servable via ?release= but NOT live). REFUSES to publish on any error unless --force; --strict promotes warnings. Prints preview + agent-context links. The published slug is NAMESPACED per owner \u2014 `<ownerPrefix>.<name>`, composed offline from the control token's ownerPrefix claim, so the CLI and the server derive the identical string and himalaya.content.json is NOT rewritten (a legacy token with no ownerPrefix publishes the declared name unchanged). An app claimed BEFORE namespacing is stored under its FLAT slug, so the claim route answers `alreadyYours` naming that slug and the push adopts it \u2014 reported as `adoptedSlug`, since it is the one case where the published `app` differs from what the declared name composes to. `--no-claim` skips the route that knows this, so a pre-namespacing app pushed with it still fails 403. --visibility public|unlisted decides whether /v1/apps + the Himi Player picker LIST the app (also declarable as `visibility` in himalaya.content.json; omitted, a brand-new app defaults to unlisted \u2014 share by link \u2014 and an existing setting is kept). --account-id names which account owns the app when your Wix session resolves to several (the auto-claim of a brand-new slug cannot guess \u2014 without it a multi-account caller is told to name one). `--only <selector>` publishes ONLY the named items as a release derived from the app's live release (or `--from-release <id>`), running the identical build + lint gate and simply publishing less; it requires an explicit `--draft` or `--live` because a partial publish has no safe default, never claims a slug, and carries neither attestation nor a source archive (both named in the output). Selectors are comma-separated in ONE `--only`: `changed` (every item whose bytes differ from the base), `screen:<id|glob>`, `bundle:<name>`, `tokens`, `app-config` \u2014 a selected screen drags its logic bundle and a selected bundle drags the screens that reference it, or the bytes land under a key nothing reads.", required: "[--content <dir>] [--draft] [--live] [--force] [--strict] [--label] [--notes] [--visibility public|unlisted] [--no-claim] [--account-id <id>] [--only <selector>] [--from-release <id>] [--accept-regressions] [--accept-partial] [--app <slug>] (--control-token)" },
55388
+ { name: "push", summary: "lint + build + publish a release (default --draft: servable via ?release= but NOT live). REFUSES to publish on any error unless --force; --strict promotes warnings. Prints preview + agent-context links. The published slug is NAMESPACED per owner \u2014 `<ownerPrefix>.<name>`, composed offline from the control token's ownerPrefix claim, so the CLI and the server derive the identical string and himalaya.content.json is NOT rewritten (a legacy token with no ownerPrefix publishes the declared name unchanged). An app claimed BEFORE namespacing is stored under its FLAT slug, so the claim route answers `alreadyYours` naming that slug and the push adopts it \u2014 reported as `adoptedSlug`, since it is the one case where the published `app` differs from what the declared name composes to. `--no-claim` skips the route that knows this, so a pre-namespacing app pushed with it still fails 403. --visibility public|unlisted decides whether /v1/apps + the Himi Player picker LIST the app (also declarable as `visibility` in himalaya.content.json; omitted, a brand-new app defaults to unlisted \u2014 share by link \u2014 and an existing setting is kept). --account-id names which account owns the app when your Wix session resolves to several (the auto-claim of a brand-new slug cannot guess \u2014 without it a multi-account caller is told to name one). An unforced push of a release that carries SCREENS also refuses when `.himi/ux-audit.json` holds no mobile-UX drive recorded against that release id, or holds one that FAILED (#2955) \u2014 a published draft IS the owner-private preview a customer is handed; `himi ux record` clears it and `--force` overrides it, which the success envelope then says out loud. `--only <selector>` publishes ONLY the named items as a release derived from the app's live release (or `--from-release <id>`), running the identical build + lint gate and simply publishing less; it requires an explicit `--draft` or `--live` because a partial publish has no safe default, never claims a slug, and carries neither attestation nor a source archive (both named in the output). Selectors are comma-separated in ONE `--only`: `changed` (every item whose bytes differ from the base), `screen:<id|glob>`, `bundle:<name>`, `tokens`, `app-config` \u2014 a selected screen drags its logic bundle and a selected bundle drags the screens that reference it, or the bytes land under a key nothing reads.", required: "[--content <dir>] [--draft] [--live] [--force] [--strict] [--label] [--notes] [--visibility public|unlisted] [--no-claim] [--account-id <id>] [--only <selector>] [--from-release <id>] [--accept-regressions] [--accept-partial] [--app <slug>] (--control-token)" },
55280
55389
  { name: "pack", summary: 'write the app as a PORTABLE .himi package \u2014 one self-contained file holding every screen, Tier-5 bundle, token, asset and the app-config. Packages are live by default: they boot bundled content offline, then Himi Player follows its baked-in production ring. `--snapshot` makes a frozen reproducible artifact. Package-supplied origins are never trusted. --format dir writes the same tree unpacked, for static hosting. --release-dir packs a release that is already built on disk (needs --app) instead of rebuilding from source. `--dev` ALSO packs the app\'s own source archive into the container (`source.zip`, header `contents: "dev"`), so the package can be handed to someone who then keeps building \u2014 collected through the same deny-list and secret scan `himi push` uses, and refused if a credential is found. A dev package IS the codebase: it is never the default, it is always labelled in the output, the web host will not open one, and it is archive-only (`--dev --format dir` is refused, because a reader that predates the source entry drops it before hashing a directory and would call the package tampered). Restore one with `himi import`.', required: "[--content <dir>] [--out <file|dir>] [--format zip|dir] [--name <title>] [--snapshot] [--dev] [--strict] [--force] | --release-dir <dir> --app <a>" },
55281
55390
  { name: "import", summary: "turn a `himi pack --dev` package back into an EDITABLE content package \u2014 the file counterpart of `himi pull`, needing no serve plane, no account and no network. Refuses an invalid package before trusting its source (the content hash is recomputed over every entry on open, so the archive is provably the one that was packed), and refuses a RELEASE package by naming the absence \u2014 it carries built output and no source \u2014 rather than reporting a missing file. `--name` re-substitutes the app identity through the same machinery `himi init --template` and `himi pull --name` use, so the restored tree is a NEW app rather than a second copy of the same one, and a name that also appears inside a screen id (`feed` in `feed_home`) is left alone. A non-empty destination needs `--force` and a merge NAMES what it kept (`left: [...]`), the same contract `himi pull --force` has; a shape clash is refused whole before anything is written. There is no `--replace`: a file you were handed is not the plane's per-release archive.", required: "<package.himi> [--out <dir>] [--name <new>] [--force]" },
55282
55391
  { name: "pull", summary: "restore an app's SOURCE from the serve plane into an editable content package \u2014 the other half of `himi push`, which archives the content package on every publish. Owner or editor only: a viewer may download the RUNNING app (`/v1/owner/package`), not the codebase it was built from. A PAST release is restorable, not only the live one \u2014 the archive is content-addressed per release, so it has none of the live-single-slot problem that makes `himi pack` current-release-only. Two limits: pruning keeps the current release plus the newest ~20 ledger entries, and a release id identifies OTA content rather than source, so a source-only change republishes under the same id and replaces that id's archive. A release published before source archiving shipped, or pushed with `--no-source`, answers `app_source_not_found` and says to push again. `--name` re-substitutes the app identity through the same machinery `himi init --template` uses, so the restored tree is a NEW app rather than a second copy of the same one, and a name that also appears inside a screen id (`feed` in `feed_home`) is left alone. A NON-EMPTY destination is refused unless you say which you mean: `--force` merges and reports \u2014 it writes the archive over what is there, keeps files the release does not carry, and NAMES every one of them as `left: [...]` so a tree that matches no release is never silent (#2437); `--replace` restores exactly what was archived, removing those files first (`removed: [...]`) and implying `--force`. `--replace` refuses a directory holding no `himalaya.content.json`, so a mistyped `--out` cannot clear an unrelated tree, and the only paths it can remove are the ones a push would have archived \u2014 `.git/`, `node_modules/`, `dist/`, `.env*` and key material are out of its reach by construction.", required: "<app> [--release <id>] [--out <dir>] [--name <new>] [--force|--replace] [--target <t>]" },
@@ -56246,6 +56355,102 @@ function resolutionFlag(flags) {
56246
56355
  if (flags.source === true) return "source";
56247
56356
  return void 0;
56248
56357
  }
56358
+ async function recordedReleaseForContentQuietly(dir, resolution) {
56359
+ let out;
56360
+ try {
56361
+ const { buildContentForValidation: buildContentForValidation2, buildReleaseFromContent: buildReleaseFromContent2, bundleOutDir: bundleOutDir2 } = await loadAuthoring();
56362
+ const bres = await buildContentForValidation2(dir, { resolution });
56363
+ out = mkdtempSync4(join51(tmpdir5(), "himi-ux-record-"));
56364
+ const hasWorkers = bres.built.length > 0 || bres.compileErrors.length > 0;
56365
+ const outcome = await buildReleaseFromContent2(dir, out, { ...hasWorkers ? { tier5Dir: bundleOutDir2(dir) } : {} });
56366
+ const releaseId = outcome.manifest.releaseId;
56367
+ if (!releaseId) return null;
56368
+ const payload = await loadReleaseFromDir(await appId(dir), out);
56369
+ return { releaseId, releaseHash: releasePayloadHash(payload) };
56370
+ } catch {
56371
+ return null;
56372
+ } finally {
56373
+ if (out !== void 0) try {
56374
+ rmSync9(out, { recursive: true, force: true });
56375
+ } catch {
56376
+ }
56377
+ }
56378
+ }
56379
+ function releaseCarriesScreens(...manifests) {
56380
+ return manifests.some((m) => Object.keys(m?.screens ?? {}).length > 0);
56381
+ }
56382
+ function handoverUxEvidence(dir, release) {
56383
+ if (!release.carriesScreens) return null;
56384
+ const evidence = uxEvidenceForRelease(dir, { releaseId: release.releaseId, releaseHash: release.releaseHash });
56385
+ const inheritedScreens = release.inheritedScreens ?? [];
56386
+ if (inheritedScreens.length > 0) {
56387
+ return {
56388
+ ...evidence,
56389
+ state: "inherited-screens",
56390
+ inheritedScreens,
56391
+ ...evidence.state === "recorded" ? {} : { supersededState: evidence.state }
56392
+ };
56393
+ }
56394
+ return evidence;
56395
+ }
56396
+ function uxRecordCommands(contentPath) {
56397
+ return `\`himi ux audit --content ${contentPath} --strict\`, then \`himi ux record --content ${contentPath} --platform ios --status unavailable --reason "<why you could not drive it>"\``;
56398
+ }
56399
+ function uxRestoreCommands(ctx) {
56400
+ const pinned = ctx.baseReleaseId ? ` --release ${ctx.quote(ctx.baseReleaseId)}` : "";
56401
+ return {
56402
+ pull: `\`himi pull ${ctx.quote(ctx.app)}${pinned}${ctx.planeArgs ?? ""} --out ${ctx.quote("<dir>")}\``,
56403
+ record: uxRecordCommands(ctx.quote(`<dir>/${ctx.app}`))
56404
+ };
56405
+ }
56406
+ function uxHandoverHint(uxEvidence, ctx) {
56407
+ const { releaseId, contentArg, door, app } = ctx;
56408
+ const uxRemedy = uxRecordCommands(contentArg);
56409
+ if (uxEvidence.state === "inherited-screens") {
56410
+ const restore = uxRestoreCommands(ctx);
56411
+ const alsoOwed = uxEvidence.supersededState === "failed" ? ` This package's own drive FAILED on ${uxEvidence.failedPlatforms.join(", ")}, so you owe more than a record: fix what that drive found, then record it against the restored tree \u2014 re-recording a failure without changing the app writes the same failure down again.` : uxEvidence.supersededState ? ` This package also has no drive this gate accepts (${uxEvidence.supersededState}), so the record below is a step you take AFTER the restore, not instead of it.` : "";
56412
+ return `this patch derives from ${ctx.baseReleaseId ?? "a release"} which carries ${uxEvidence.inheritedScreens.length} screen(s) your package does not contain (${uxEvidence.inheritedScreens.join(", ")}), so a drive of this package cannot have seen them \u2014 and a tokens or bundle patch changes how they look or behave.${alsoOwed} Restore THAT release's source with ${restore.pull}, apply the change you were publishing to the restored tree, and only THEN record the drive there: ${restore.record} \u2014 the record is matched against the package you push, so one taken before your change describes the base and goes stale the moment you apply it. Patch from that tree. Do NOT run a full \`himi push\` from this package instead: publishing a tree that is missing those screens REMOVES them. If that release was published with \`--no-source\`, or before source archiving shipped, the pull answers \`app_source_not_found\` and the override below is the only exit. Only if you intend to hand a customer a preview of screens nobody drove, \`${door} --force\`.`;
56413
+ }
56414
+ if (uxEvidence.state === "failed") {
56415
+ return `a mobile-UX drive for ${releaseId} IS recorded, and it FAILED on ${uxEvidence.failedPlatforms.join(", ")} (see ${uxEvidence.path}). A published draft is the owner-private preview a customer is handed, so fix what the drive found and re-record it \u2014 re-running \`himi ux record\` over a failure without changing the app just writes the same failure down again. Only if you intend to hand a customer a preview whose UX you already know is broken, \`${door} --force\`.`;
56416
+ }
56417
+ const why = uxEvidence.state === "absent" ? `nothing has recorded a mobile-UX drive for this package \u2014 ${uxEvidence.path} does not exist` : uxEvidence.state === "unrecorded" ? uxEvidence.malformed.length ? `${uxEvidence.path} has no iOS or Android drive this gate can read \u2014 ${uxEvidence.malformed.join("; ")}` : `${uxEvidence.path} exists but carries no iOS or Android drive (\`himi render\` writes its rendered widths there too, so the file alone proves nothing)` : uxEvidence.state === "unreadable" ? `${ctx.quote(`${ctx.contentDir}/${uxEvidence.path}`)} ${uxEvidence.error} \u2014 delete it and record again, because re-recording over a malformed store does not repair it` : uxEvidence.state === "outdated" ? `a mobile-UX drive for ${releaseId} is recorded, but the package has CHANGED since \u2014 in-repo a release id is the git HEAD sha, so editing a screen, a token or a worker without committing keeps the id while replacing everything it identifies (a record written before this check also lands here, carrying no content hash)` : `the recorded mobile-UX drive is about ${uxEvidence.otherReleases.length ? `release ${uxEvidence.otherReleases.join(", ")}` : "no release at all"}, not ${releaseId}${uxEvidence.unstamped ? ` (${uxEvidence.unstamped} drive(s) name no release)` : ""}`;
56418
+ return `${why}. A published draft is the owner-private preview a customer is handed, so record the drive first: ${uxRemedy}, then re-run \`${door}\`. Only if you intend to hand over a preview WITHOUT that evidence, \`${door} --force\`.`;
56419
+ }
56420
+ var SECRET_FLAG_NAME = /token|secret|password|cookie|auth/i;
56421
+ var NEVER_ECHOED = /* @__PURE__ */ new Set(["force", "help", "json"]);
56422
+ var PLANE_FLAGS = ["target", "serve-url", "control-url"];
56423
+ function planeArguments(flags, quote) {
56424
+ return PLANE_FLAGS.filter((name) => typeof flags[name] === "string").map((name) => ` --${name} ${quote(String(flags[name]))}`).join("");
56425
+ }
56426
+ function echoedInvocation(command, flags, quote) {
56427
+ const parts = [command];
56428
+ for (const [name, value] of Object.entries(flags)) {
56429
+ if (value === false || NEVER_ECHOED.has(name) || SECRET_FLAG_NAME.test(name)) continue;
56430
+ parts.push(`--${name}`);
56431
+ if (typeof value === "string") parts.push(quote(value));
56432
+ }
56433
+ return parts.join(" ");
56434
+ }
56435
+ function uxHandoverNote(uxEvidence, ctx) {
56436
+ if (uxEvidence.state === "inherited-screens") {
56437
+ const restore = uxRestoreCommands(ctx);
56438
+ const drive = uxEvidence.supersededState === "failed" ? `A drive of this package is on record and it FAILED on ${uxEvidence.failedPlatforms.join(", ")} \u2014 and it could not have covered them in any case:` : uxEvidence.supersededState ? `No drive of this package is one this gate accepts (${uxEvidence.supersededState}), and a drive of this package could not have covered them in any case:` : "A drive of this package IS recorded, and it could not have covered them in any case:";
56439
+ return `published over ${uxEvidence.inheritedScreens.length} screen(s) nobody drove (${uxEvidence.inheritedScreens.join(", ")}). ${drive} the preview link below renders them from ${ctx.baseReleaseId ?? "the base release"}, and this package does not contain them. Restore that release's source with ${restore.pull}, apply the change you just published to it, drive and record there (${restore.record}), and publish again from that tree before a customer reviews it \u2014 the record is matched against the package you push, so one taken before your change describes the base.`;
56440
+ }
56441
+ if (uxEvidence.state === "failed") {
56442
+ return `published over a FAILED mobile-UX drive for ${ctx.releaseId} on ${uxEvidence.failedPlatforms.join(", ")}. The preview link below is the owner-private handover \u2014 fix what the drive found and publish again before a customer reviews it.`;
56443
+ }
56444
+ if (uxEvidence.state === "unreadable") {
56445
+ const why = uxEvidence.error ? ` \u2014 ${uxEvidence.path} ${uxEvidence.error}` : ` \u2014 ${uxEvidence.path} could not be read`;
56446
+ return `published with no mobile-UX drive this gate accepts for ${ctx.releaseId} (unreadable)${why}. The preview link below is the owner-private handover \u2014 DELETE ${ctx.quote(`${ctx.contentDir}/${uxEvidence.path}`)} and record the drive again with \`himi ux record --content ${ctx.contentArg} \u2026\`, because re-recording over a malformed store does not repair it, then publish again before a customer reviews it.`;
56447
+ }
56448
+ return `published with no mobile-UX drive this gate accepts for ${ctx.releaseId} (${uxEvidence.state}). The preview link below is the owner-private handover \u2014 record the drive with \`himi ux record --content ${ctx.contentArg} \u2026\` and publish again before a customer reviews it.`;
56449
+ }
56450
+ function uxHandoverOverride(uxEvidence, ctx) {
56451
+ if (!uxEvidence || uxEvidence.state === "recorded") return {};
56452
+ return { uxEvidence, uxEvidenceNote: uxHandoverNote(uxEvidence, ctx) };
56453
+ }
56249
56454
  async function loadAuthoring() {
56250
56455
  try {
56251
56456
  const [b, rb, crawl, vd, cov, wml, nf, rd] = await Promise.all([
@@ -56462,6 +56667,17 @@ himi v${cliVersion()} \u2014 run \`himi --version\`. If authoring commands fail
56462
56667
  }, null, 2));
56463
56668
  return 1;
56464
56669
  }
56670
+ if (opts.ux?.evidence && opts.ux.evidence.state !== "recorded" && !opts.force) {
56671
+ print({
56672
+ ok: false,
56673
+ patched: false,
56674
+ pushed: false,
56675
+ uxEvidence: opts.ux.evidence,
56676
+ releaseId: opts.ux.ctx.releaseId,
56677
+ hint: uxHandoverHint(opts.ux.evidence, opts.ux.ctx)
56678
+ });
56679
+ return 1;
56680
+ }
56465
56681
  let r;
56466
56682
  try {
56467
56683
  r = await client2().releasePatch(app, base.releaseId, plan.patch, draft, opts.meta, opts.force);
@@ -56504,6 +56720,9 @@ himi v${cliVersion()} \u2014 run \`himi --version\`. If authoring commands fail
56504
56720
  ...opts.lintNote ? { lintNote: opts.lintNote } : {},
56505
56721
  attestation: honesty.attestation,
56506
56722
  source: honesty.source,
56723
+ // Spread through the SAME helper the full push uses, so a forced partial publish records
56724
+ // the override in the same words rather than in a second phrasing that can drift.
56725
+ ...opts.ux ? uxHandoverOverride(opts.ux.evidence, opts.ux.ctx) : {},
56507
56726
  ...out.visibility ? { visibility: out.visibility } : {},
56508
56727
  ...opts.extra ?? {},
56509
56728
  previewWeb: sbase && release ? previewWebUrl(sbase, app, release, void 0, void 0, previewToken) : null,
@@ -57134,17 +57353,27 @@ himi v${cliVersion()} \u2014 run \`himi --version\`. If authoring commands fail
57134
57353
  const platform = str5(flags.platform);
57135
57354
  const status = str5(flags.status);
57136
57355
  const checks = str5(flags.checks)?.split(",").map((item) => item.trim()).filter(Boolean) ?? [];
57137
- if (platform !== "ios" && platform !== "android") {
57138
- io.error(JSON.stringify({ error: "ux record needs --platform ios|android" }));
57356
+ if (!isNativePlatform(platform)) {
57357
+ io.error(JSON.stringify({ error: `ux record needs --platform ${NATIVE_PLATFORMS.join("|")}` }));
57139
57358
  return 2;
57140
57359
  }
57141
- if (status !== "passed" && status !== "failed" && status !== "unavailable") {
57142
- io.error(JSON.stringify({ error: "ux record needs --status passed|failed|unavailable" }));
57360
+ if (!isNativeAuditStatus(status)) {
57361
+ io.error(JSON.stringify({ error: `ux record needs --status ${NATIVE_AUDIT_STATUSES.join("|")}` }));
57143
57362
  return 2;
57144
57363
  }
57364
+ const uxRelease = await recordedReleaseForContentQuietly(dir, resolutionFlag(flags));
57145
57365
  try {
57146
- const evidence = recordNativeAudit(dir, platform, status, checks, str5(flags.reason));
57147
- print({ ok: true, content: dir, evidence });
57366
+ const evidence = recordNativeAudit(dir, platform, status, checks, str5(flags.reason), uxRelease ?? void 0);
57367
+ print({
57368
+ ok: true,
57369
+ content: dir,
57370
+ releaseId: uxRelease?.releaseId ?? null,
57371
+ releaseHash: uxRelease?.releaseHash ?? null,
57372
+ ...uxRelease ? {} : {
57373
+ releaseIdNote: "this package could not be built, so the record names no release \u2014 `himi push` will not accept it as evidence for the release it publishes. Fix the build and re-run this command."
57374
+ },
57375
+ evidence
57376
+ });
57148
57377
  return 0;
57149
57378
  } catch (error) {
57150
57379
  io.error(JSON.stringify({ error: error.message }));
@@ -58163,6 +58392,9 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
58163
58392
  const kvDeclared = declaredData?.kv?.length ?? 0;
58164
58393
  const manifest = outcome.manifest;
58165
58394
  const payload = await loadReleaseFromDir(declared, out);
58395
+ const { shellQuote: shellQuote2 } = await Promise.resolve().then(() => (init_shell_quote(), shell_quote_exports));
58396
+ const contentRel = relative8(process.cwd(), dir) || ".";
58397
+ const contentArg = shellQuote2(contentRel);
58166
58398
  if (onlyRaw !== void 0) {
58167
58399
  const onlyDraft = flags.draft === true;
58168
58400
  const { parseOnlySelectors: parseOnlySelectors2, planFromPayload: planFromPayload2, notCarriedDiff: notCarriedDiff2 } = await Promise.resolve().then(() => (init_release_patch(), release_patch_exports));
@@ -58195,6 +58427,28 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
58195
58427
  io.error(JSON.stringify(planned.error, null, 2));
58196
58428
  return 1;
58197
58429
  }
58430
+ const patchUxEvidence = handoverUxEvidence(dir, {
58431
+ releaseId: manifest.releaseId,
58432
+ releaseHash: releasePayloadHash(payload),
58433
+ carriesScreens: releaseCarriesScreens(baseForPlan.manifest, manifest),
58434
+ // The screens this publish will serve whose BYTES this package has never held: the
58435
+ // derived release materializes them from the base. Local-only screens are not here —
58436
+ // those the author does have, and a drive of this package covers them.
58437
+ inheritedScreens: Object.keys(baseForPlan.manifest.screens ?? {}).filter((id) => !(id in (manifest.screens ?? {}))).sort()
58438
+ });
58439
+ const patchUxContext = {
58440
+ releaseId: manifest.releaseId,
58441
+ contentArg,
58442
+ contentDir: contentRel,
58443
+ app,
58444
+ door: echoedInvocation("himi push", flags, shellQuote2),
58445
+ // The base this plan DERIVES from, not the live release: under `--from-release` those
58446
+ // differ, and a restore of the wrong one hands the author a different set of screen
58447
+ // bytes to drive.
58448
+ baseReleaseId: baseForPlan.manifest.releaseId,
58449
+ quote: shellQuote2,
58450
+ planeArgs: planeArguments(flags, shellQuote2)
58451
+ };
58198
58452
  let localSourceHash;
58199
58453
  try {
58200
58454
  const { collectAppSource: collectAppSource2, zipAppSource: zipAppSource2 } = await Promise.resolve().then(() => (init_app_source(), app_source_exports));
@@ -58223,6 +58477,9 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
58223
58477
  },
58224
58478
  ...localSourceHash ? { localSourceHash } : {},
58225
58479
  notCarried: notCarriedDiff2(manifest, baseForPlan.manifest),
58480
+ // Reaches here ONLY on the forced path — the refusal above returned otherwise — so
58481
+ // this is the override's record, and the case it matters most for is a `failed` drive.
58482
+ ux: { evidence: patchUxEvidence, ctx: patchUxContext },
58226
58483
  lint: report.counts ?? null,
58227
58484
  extra: { selector: parsed.selectors.map((s) => "pattern" in s ? `${s.kind}:${s.pattern}` : s.kind) }
58228
58485
  });
@@ -58271,8 +58528,6 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
58271
58528
  const evidence = pushEvidence(dir, manifest.releaseId, (candidate) => verifyAttestation(candidate, dir, payload.screens, declared, payload, { requireLatest: false }).ok);
58272
58529
  const staleEvidence = evidence.staleRuns > 0 ? `${evidence.staleRuns} retained attestation(s) carry this release id, but none describes the content being published` : null;
58273
58530
  const attestationErrors = [...evidence.historyErrors, ...staleEvidence ? [staleEvidence] : []];
58274
- const { shellQuote: shellQuote2 } = await Promise.resolve().then(() => (init_shell_quote(), shell_quote_exports));
58275
- const contentArg = shellQuote2(relative8(process.cwd(), dir) || ".");
58276
58531
  if (attestationErrors.length > 0 && !force) {
58277
58532
  const remedy = `\`himi test --content ${contentArg}\` (or \`himi render <screenId> --content ${contentArg}\`)`;
58278
58533
  const corrupted = evidence.historyErrors.length > 0;
@@ -58294,6 +58549,35 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
58294
58549
  });
58295
58550
  return 1;
58296
58551
  }
58552
+ const uxEvidence = handoverUxEvidence(dir, {
58553
+ releaseId: manifest.releaseId,
58554
+ releaseHash: releasePayloadHash(payload),
58555
+ // On a FULL push the package is the release, so its own screens are the whole answer —
58556
+ // and nothing is inherited, which is why the limitation above is a `--only` concern only.
58557
+ carriesScreens: releaseCarriesScreens(manifest),
58558
+ inheritedScreens: []
58559
+ });
58560
+ if (uxEvidence && uxEvidence.state !== "recorded" && !force) {
58561
+ print({
58562
+ ok: false,
58563
+ pushed: false,
58564
+ uxEvidence,
58565
+ releaseId: manifest.releaseId,
58566
+ // The SAME reconstruction as the patch door: this one dropped `--content` too, and
58567
+ // fixing one door and not the other is the drift the shared helpers exist to stop.
58568
+ hint: uxHandoverHint(uxEvidence, {
58569
+ releaseId: manifest.releaseId,
58570
+ contentArg,
58571
+ contentDir: contentRel,
58572
+ app,
58573
+ door: echoedInvocation("himi push", flags, shellQuote2),
58574
+ quote: shellQuote2
58575
+ // No `baseReleaseId`: a full push derives from nothing, so it can never produce
58576
+ // `inherited-screens` and has no base to restore.
58577
+ })
58578
+ });
58579
+ return 1;
58580
+ }
58297
58581
  const forcedPastErrors = force && (hasErrors || attestation?.report.ok === false || attestationErrors.length > 0);
58298
58582
  const assetRecords = Object.entries(manifest.assets ?? {});
58299
58583
  const assetSummary = assetRecords.length ? {
@@ -58436,6 +58720,18 @@ default (no --glyph): ${DEFAULT_GLYPH2} \u2014 a mark that claims nothing, so th
58436
58720
  ...!attestation && staleEvidence ? {
58437
58721
  attestationNote: `${staleEvidence} \u2014 this release was published with no headless evidence of its own, and the serve plane may still show the attestation from an earlier push of this release id. Re-attest with \`himi test --content ${contentArg}\` and push again to attach one.`
58438
58722
  } : {},
58723
+ // #2955 — the SAME honesty for the UX gate: `--force` published this preview without a
58724
+ // recorded mobile-UX drive for it, and the link below goes to a customer. Say it in the
58725
+ // success envelope, because a refusal nobody reads and an override nobody records are
58726
+ // the same silence one step apart.
58727
+ ...uxHandoverOverride(uxEvidence, {
58728
+ releaseId: manifest.releaseId,
58729
+ contentArg,
58730
+ contentDir: contentRel,
58731
+ app,
58732
+ door: echoedInvocation("himi push", flags, shellQuote2),
58733
+ quote: shellQuote2
58734
+ }),
58439
58735
  ...loopbackWarning ? { warning: loopbackWarning } : {},
58440
58736
  ...report.issues ? { issues: report.issues } : {},
58441
58737
  previewWeb: base ? previewWebUrl(base, app, release, void 0, void 0, previewToken) : null,
@@ -61149,6 +61445,9 @@ export {
61149
61445
  switchFlagErrors,
61150
61446
  templatePreviewAuthError,
61151
61447
  tokenCoversApp,
61448
+ uxHandoverHint,
61449
+ uxHandoverNote,
61450
+ uxHandoverOverride,
61152
61451
  wixPlayerTestflightTarget,
61153
61452
  writePngFile
61154
61453
  };
@@ -137,6 +137,16 @@ The plane has no headless browser (`look.unavailable: no_browser_on_plane`). Own
137
137
 
138
138
  `--apply` writes `appearance.homeLayout` + `navigation.navKind` plus identity/colors. It never silently overwrites committed Amit1 `apps/himi-branded-lite/provisioning.json`.
139
139
 
140
+ When the analysis recognized at least one vertical, `--apply` also patches the shell's baked home
141
+ (`dev/index.ts`) to what the site actually runs. The Explore grid keeps only the site's
142
+ destinations (adding a card the template baked none for, and leaving off a vertical the template
143
+ ships no screen for); home modules that no installed vertical can fill are dropped; and a site
144
+ without `store` loses the home's Shop calls to action, including the one nested in the
145
+ `retail-store` store-first layout. The store screens themselves are untouched — only the home
146
+ stops linking to them. The apply notes name every removed call to action and every installed
147
+ vertical left off Explore, so an intentional omission is distinguishable from a failed patch. An
148
+ analysis with no recognized vertical leaves the home exactly as shipped.
149
+
140
150
  It also writes **`cmsCollections`** — the customer's own CMS collections, always present and possibly `[]`. The overlay used to carry no such key, and an absent key inherits the shell's, so an applied app advertised and **queried** the shell's demo collection: another tenant's data model under the customer's brand. `[]` is therefore a value, not an absence — it clears the shell's list and the CMS screen shows its own empty state.
141
151
 
142
152
  Only a collection a shipped app can read **as a visitor** is carried, because `readCms` lists with an **owner** token and the app has no such session. An `ADMIN` read is dropped (no session the app can hold will ever satisfy it), and so is a **member-gated** one — not because the app cannot read it, but because the CMS worker caches fetched rows in the unencrypted `cms_items` store, which outlives the member's session and would be served to the next visitor on the device ([cms-collections.md](cms-collections.md)). A Wix app's own collection (`ownerAppId`) is dropped too; its vertical already renders it. So are the four other ways a collection misses the app, because they need different remedies and are indistinguishable in the artifact: **no owner token** (nothing was looked at), the listing **failing** (any status but 404 — re-authenticate, widen the Wix Data grant, or retry a 429; a 404 means the app is not on the site, which is simply "no collections"), the listing **cap** of 24 counted against the server's `pagingMetadata.total` (the rest were never read), and an **id that cannot be carried unchanged** (surrounded by whitespace, carrying a control character, or over 256 chars — normalizing it would name a different collection in a manifest that still loads). A site that simply has none says nothing — that is the ordinary state, and `--apply` leads its note with warnings.
@@ -167,11 +167,14 @@ book says which happened on the `cms collections:` line (`(skipped: no owner tok
167
167
  The `clientId`-is-the-binding rule above is about the **app's** runtime site binding, not this.
168
168
 
169
169
  `--apply` writes `provisioning.json` + token **values**, and patches the branded-lite
170
- home `onAppear` params (`ownerDefaultLayout`, `brandAccent`, `verticals`) in
171
- `dev/index.ts`. It does **not** rewrite `config.ts` tabBar — you still set 2–4
172
- Hebrew/RTL tabs to the site's jobs. If home still renders the English Shop/Book/Explore
173
- hub after `--apply`, the onAppear patch missed (no `dev/index.ts`, or a custom home
174
- screen) — set `ownerDefaultLayout` by hand.
170
+ home in `dev/index.ts`: its `onAppear` params (`ownerDefaultLayout`, `brandAccent`,
171
+ `verticals`) and, when the analysis recognized any vertical, the Explore grid, the home
172
+ modules and — on a site without `store` — the Shop calls to action, naming what it
173
+ removed in its notes (`himi docs wix-business-context`). It does **not** rewrite
174
+ `config.ts` tabBar — you still set 2–4 Hebrew/RTL tabs to the site's jobs. If home still
175
+ renders the English Shop/Book/Explore hub after `--apply`, the home patch missed (no
176
+ `dev/index.ts`, a custom home screen, or an analysis with no recognized vertical) — set
177
+ `ownerDefaultLayout` and the home content by hand.
175
178
 
176
179
  **Look:** merge `--wst-*` into `tokens.json` **values only** (`himi docs tokens`), and only into
177
180
  the **light** arm — a scraped website colour is never a dark-mode colour, so let the app's own
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.968.0",
3
+ "version": "0.970.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": [
@@ -64,5 +64,5 @@
64
64
  "artifactId": "himalaya-cli"
65
65
  }
66
66
  },
67
- "falconPackageHash": "e41cecd4c127159c9493293586554a07f307be67bc66163cb13683c0"
67
+ "falconPackageHash": "09b972a7f7d83ae250b4039472411e77995eaddf0767c4c09bc73558"
68
68
  }