kintone-migrator 0.34.5 → 0.34.6

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/index.mjs CHANGED
@@ -10,6 +10,7 @@ import { realpathSync } from "node:fs";
10
10
  import * as v from "valibot";
11
11
  import * as p from "@clack/prompts";
12
12
  import pc from "picocolors";
13
+ import { createHash } from "node:crypto";
13
14
  //#region src/lib/error.ts
14
15
  var AnyError = class extends Error {
15
16
  constructor(message, cause) {
@@ -2739,6 +2740,7 @@ function printThreeWayDiffResult(result, printTwoWay) {
2739
2740
  printTwoWay(result.diff);
2740
2741
  return;
2741
2742
  }
2743
+ for (const note of result.notes ?? []) p.log.warn(note);
2742
2744
  if (result.isEmpty) {
2743
2745
  p.log.info("No changes detected (local, remote, and base are in sync).");
2744
2746
  return;
@@ -3443,7 +3445,7 @@ function resolveActionMerge(merge, resolution) {
3443
3445
  * record-keyed merge (view / report / action / plugin). The caller supplies a
3444
3446
  * `label` extractor (e.g. the view's name) for display.
3445
3447
  */
3446
- function buildRecordThreeWayDiff(merge, label, extras = []) {
3448
+ function buildRecordThreeWayDiff(merge, label, extras = [], notes = []) {
3447
3449
  const localChanges = [];
3448
3450
  const remoteDrift = [];
3449
3451
  const conflicts = [];
@@ -3471,13 +3473,15 @@ function buildRecordThreeWayDiff(merge, label, extras = []) {
3471
3473
  break;
3472
3474
  default: break;
3473
3475
  }
3476
+ const isEmpty = localChanges.length === 0 && remoteDrift.length === 0 && conflicts.length === 0 && extras.length === 0;
3474
3477
  return {
3475
3478
  mode: "three-way",
3476
3479
  localChanges,
3477
3480
  remoteDrift,
3478
3481
  conflicts,
3479
3482
  extras,
3480
- isEmpty: localChanges.length === 0 && remoteDrift.length === 0 && conflicts.length === 0 && extras.length === 0
3483
+ ...notes.length > 0 ? { notes } : {},
3484
+ isEmpty
3481
3485
  };
3482
3486
  }
3483
3487
  /**
@@ -3606,7 +3610,7 @@ async function saveSnapshotState(storage, codec, serialize, snapshot) {
3606
3610
  * Loads the base snapshot, the base app revision, the local config, and the
3607
3611
  * remote config in parallel for a 3-way sync. Generic over the snapshot
3608
3612
  * (`TSnapshot`) and remote (`TRemote`) shapes so every config domain reuses one
3609
- * loader (generalization of schema step1's `loadThreeWayInputs`).
3613
+ * loader.
3610
3614
  */
3611
3615
  async function loadThreeWayInputs$1(args) {
3612
3616
  const [state, appRevision, local, remote] = await Promise.all([
@@ -3980,9 +3984,13 @@ function buildDriftMessage(pullCommand) {
3980
3984
  * The CLI distinguishes this snapshot drift from API optimistic-lock (TOCTOU)
3981
3985
  * conflicts by the `ConfigDrift` code, not by the message string, so the
3982
3986
  * message stays a free-form, domain-specific hint.
3987
+ *
3988
+ * `extraHint` appends a domain-specific caveat (e.g. customization's "this
3989
+ * drift may be inferred") on its own line; omitted, the message is unchanged.
3983
3990
  */
3984
- function buildDriftConflict(pullCommand) {
3985
- return new ConflictError(ConflictErrorCode.ConfigDrift, buildDriftMessage(pullCommand));
3991
+ function buildDriftConflict(pullCommand, extraHint) {
3992
+ const message = buildDriftMessage(pullCommand);
3993
+ return new ConflictError(ConflictErrorCode.ConfigDrift, extraHint === void 0 ? message : `${message}\n${extraHint}`);
3986
3994
  }
3987
3995
  //#endregion
3988
3996
  //#region src/core/application/action/pushAction.ts
@@ -10398,63 +10406,130 @@ function createCliCaptureContainers(input) {
10398
10406
  }
10399
10407
  //#endregion
10400
10408
  //#region src/core/domain/customization/services/configSerializer.ts
10401
- function serializeResource(resource) {
10402
- if (resource.type === "FILE") return {
10409
+ function serializeResource(resource, platform, category, decorate) {
10410
+ const serialized = resource.type === "FILE" ? {
10403
10411
  type: "FILE",
10404
10412
  path: resource.path
10405
- };
10406
- return {
10413
+ } : {
10407
10414
  type: "URL",
10408
10415
  url: resource.url
10409
10416
  };
10417
+ const extra = decorate?.(platform, category, resource);
10418
+ return extra === void 0 ? serialized : {
10419
+ ...extra,
10420
+ ...serialized
10421
+ };
10410
10422
  }
10411
- function serializeResourceList(resources) {
10412
- return resources.map(serializeResource);
10423
+ function serializeResourceList(resources, platform, category, decorate) {
10424
+ return resources.map((resource) => serializeResource(resource, platform, category, decorate));
10413
10425
  }
10414
10426
  function hasPlatformResources(platform) {
10415
10427
  return platform.js.length > 0 || platform.css.length > 0;
10416
10428
  }
10417
- function serializePlatform(platform) {
10429
+ function serializePlatform(platform, platformName, decorate) {
10418
10430
  return {
10419
- ...platform.js.length > 0 ? { js: serializeResourceList(platform.js) } : {},
10420
- ...platform.css.length > 0 ? { css: serializeResourceList(platform.css) } : {}
10431
+ ...platform.js.length > 0 ? { js: serializeResourceList(platform.js, platformName, "js", decorate) } : {},
10432
+ ...platform.css.length > 0 ? { css: serializeResourceList(platform.css, platformName, "css", decorate) } : {}
10421
10433
  };
10422
10434
  }
10423
- const CustomizationConfigSerializer = { serialize: (config) => {
10435
+ const CustomizationConfigSerializer = { serialize: (config, decorate) => {
10424
10436
  return {
10425
10437
  ...config.scope !== void 0 ? { scope: config.scope } : {},
10426
- ...hasPlatformResources(config.desktop) ? { desktop: serializePlatform(config.desktop) } : {},
10427
- ...hasPlatformResources(config.mobile) ? { mobile: serializePlatform(config.mobile) } : {}
10438
+ ...hasPlatformResources(config.desktop) ? { desktop: serializePlatform(config.desktop, "desktop", decorate) } : {},
10439
+ ...hasPlatformResources(config.mobile) ? { mobile: serializePlatform(config.mobile, "mobile", decorate) } : {}
10428
10440
  };
10429
10441
  } };
10430
10442
  //#endregion
10431
10443
  //#region src/core/domain/customization/services/customizationMerge.ts
10444
+ /**
10445
+ * Per-side placeholder for "no content digest for this key". Distinct per side
10446
+ * so an untracked key never compares equal across sides by accident.
10447
+ */
10448
+ const UNTRACKED = {
10449
+ base: "untracked:base",
10450
+ local: "untracked:local",
10451
+ remote: "untracked:remote"
10452
+ };
10453
+ /**
10454
+ * The bucket space every walk over a customization config must agree on.
10455
+ *
10456
+ * Shared rather than redeclared per module: the merge keys, the snapshot's
10457
+ * digest keys and the locally/remotely computed digest keys are only comparable
10458
+ * while they enumerate the same buckets, and a bucket missing from one walk
10459
+ * would not be a type error — it would silently leave digests unrecorded and
10460
+ * degrade the classification to an estimate.
10461
+ */
10432
10462
  const PLATFORMS = ["desktop", "mobile"];
10433
10463
  const CATEGORIES = ["js", "css"];
10464
+ /**
10465
+ * The merge key holding the customization scope rather than a resource.
10466
+ *
10467
+ * Exported so every consumer branches on the same literal: the CLI's conflict
10468
+ * prompt tells scope conflicts and file conflicts apart by this key alone, and a
10469
+ * private copy drifting from this one would describe overwriting a file that
10470
+ * does not exist.
10471
+ */
10434
10472
  const SCOPE_KEY = "config:scope";
10435
10473
  /**
10436
10474
  * Bucket-qualified resource identity: `platform:category:name`. Shared with the
10437
10475
  * force/firstTime pull path (`preservePaths`) so path preservation matches the
10438
10476
  * merge on the same identity and cross-bucket same-basename files stay distinct.
10477
+ *
10478
+ * The bucket parameters are the bucket types rather than `string` so a typo or a
10479
+ * stale literal at a call site is a compile error instead of a key nobody ever
10480
+ * looks up.
10439
10481
  */
10440
10482
  function resourceKey(platform, category, name) {
10441
10483
  return `${platform}:${category}:${name}`;
10442
10484
  }
10485
+ /**
10486
+ * The {@link resourceKey} of a remote FILE, from the name kintone reports.
10487
+ *
10488
+ * The remote name is normalized exactly like a local declared path, because the
10489
+ * remote config the merge is built from carries `file.name` as its `path`: a
10490
+ * name containing `/` becomes its trailing segment there, and a lookup keyed by
10491
+ * the raw name would miss the very entry it describes.
10492
+ */
10493
+ function remoteFileResourceKey(platform, category, fileName) {
10494
+ return resourceKey(platform, category, resourceName({
10495
+ type: "FILE",
10496
+ path: fileName
10497
+ }));
10498
+ }
10499
+ /**
10500
+ * Walks every (platform, category) bucket, tagging each resource with the bucket
10501
+ * it belongs to. Generic over the resource so the local config, the raw remote
10502
+ * shape and a merged config are all walked by the same code.
10503
+ *
10504
+ * Key-producing walks over a typed config go through here: a hand-written
10505
+ * enumeration that misses a bucket is not a type error, and the symptom —
10506
+ * digests silently left unrecorded, downloads silently skipped — surfaces far
10507
+ * from the cause. Walks over raw `unknown` data (the state parser) cannot be
10508
+ * typed as {@link BucketedResources} and instead iterate {@link PLATFORMS} and
10509
+ * {@link CATEGORIES} directly, which keeps them on the same bucket space.
10510
+ */
10511
+ function* walkCustomizationBuckets(resources) {
10512
+ for (const platform of PLATFORMS) for (const category of CATEGORIES) for (const resource of resources[platform][category]) yield {
10513
+ platform,
10514
+ category,
10515
+ resource
10516
+ };
10517
+ }
10443
10518
  function platformList(config, platform, category) {
10444
10519
  const p = config[platform];
10445
10520
  return category === "js" ? p.js : p.css;
10446
10521
  }
10447
10522
  /**
10448
- * Builds the per-side map keyed by `platform:category:name`. `contentTag`
10449
- * returns the content marker for a same-name FILE so divergent content surfaces
10450
- * as a conflict. `side` distinguishes local vs remote tags for modified files.
10523
+ * Builds the per-side map keyed by `platform:category:name`. A FILE's tag is its
10524
+ * content digest for this side, falling back to the side's untracked sentinel so
10525
+ * an unknown content never compares equal to another side's unknown content.
10451
10526
  */
10452
- function toMap(config, contentTag) {
10527
+ function toMap(config, tags, side) {
10453
10528
  const map = /* @__PURE__ */ new Map();
10454
- for (const platform of PLATFORMS) for (const category of CATEGORIES) for (const resource of platformList(config, platform, category)) {
10455
- const name = resourceName(resource);
10456
- const tag = resource.type === "FILE" ? `FILE:${contentTag(name)}` : "URL";
10457
- map.set(resourceKey(platform, category, name), {
10529
+ for (const { platform, category, resource } of walkCustomizationBuckets(config)) {
10530
+ const key = resourceKey(platform, category, resourceName(resource));
10531
+ const tag = resource.type === "FILE" ? `FILE:${tags.get(key) ?? UNTRACKED[side]}` : "URL";
10532
+ map.set(key, {
10458
10533
  resource,
10459
10534
  tag
10460
10535
  });
@@ -10463,6 +10538,12 @@ function toMap(config, contentTag) {
10463
10538
  map.set(SCOPE_KEY, { scope });
10464
10539
  return map;
10465
10540
  }
10541
+ /** The `resourceKey` set of every FILE resource in a config. */
10542
+ function fileResourceKeys(config) {
10543
+ const keys = /* @__PURE__ */ new Set();
10544
+ for (const { platform, category, resource } of walkCustomizationBuckets(config)) if (resource.type === "FILE") keys.add(resourceKey(platform, category, resourceName(resource)));
10545
+ return keys;
10546
+ }
10466
10547
  function isScopeValue(v) {
10467
10548
  return "scope" in v;
10468
10549
  }
@@ -10471,16 +10552,69 @@ function eq(a, b) {
10471
10552
  return a.tag === b.tag;
10472
10553
  }
10473
10554
  /**
10474
- * Computes the file-name-keyed 3-way merge of base/local/remote customization.
10555
+ * Computes the resource-key-keyed 3-way merge of base/local/remote customization.
10475
10556
  *
10476
- * `modifiedFileNames` is the set of same-name FILE basenames whose local
10477
- * content differs from the remote content (reused from the diffDetector flow).
10478
- * Such files get distinct local/remote tags so they classify as conflicts; all
10479
- * other matched files share the `"same"` tag and classify as unchanged.
10557
+ * `tags` carries each side's FILE content tokens (digests, or the base tags
10558
+ * produced by {@link resolveBaseFileDigests}). A key whose base tag equals the
10559
+ * local tag but not the remote tag is `remoteOnly`, and vice versa — so editing
10560
+ * a file locally is a local change, not a conflict.
10480
10561
  */
10481
- function computeCustomizationThreeWayMerge(base, local, remote, modifiedFileNames) {
10482
- const tagFor = (side) => (name) => modifiedFileNames.has(name) ? side : "same";
10483
- return classifyThreeWay(toMap(base, () => "same"), toMap(local, tagFor("local")), toMap(remote, tagFor("remote")), eq);
10562
+ function computeCustomizationThreeWayMerge(base, local, remote, tags) {
10563
+ return classifyThreeWay(toMap(base, tags.base, "base"), toMap(local, tags.local, "local"), toMap(remote, tags.remote, "remote"), eq);
10564
+ }
10565
+ /**
10566
+ * Determines the base-side comparison tag of every FILE key in the base
10567
+ * snapshot, filling in the keys whose digest was never recorded.
10568
+ *
10569
+ * Rules, highest priority first:
10570
+ *
10571
+ * 1. a recorded digest wins — the classification is exact;
10572
+ * 2. otherwise, for a legacy state (no digest at all) whose app revision has not
10573
+ * moved, the remote digest is assumed to still be the base content, so a
10574
+ * purely local edit is `localOnly` instead of a spurious conflict;
10575
+ * 3. otherwise, when the key exists on exactly one side, the base takes that
10576
+ * side's tag, so the classification is decided by existence alone;
10577
+ * 4. otherwise, when both sides carry the same tag, the base takes it
10578
+ * (`unchanged`);
10579
+ * 5. otherwise the base gets its own sentinel — both sides diverged
10580
+ * (`conflict`), or the key exists on neither side (`bothSame`).
10581
+ *
10582
+ * Rules 3-5 decide from the two sides alone, which is all a snapshot without
10583
+ * digests allows.
10584
+ */
10585
+ function resolveBaseFileDigests(baseConfig, baseDigests, localDigests, remoteDigests, options) {
10586
+ const tags = /* @__PURE__ */ new Map();
10587
+ const optimistic = /* @__PURE__ */ new Set();
10588
+ const conservative = /* @__PURE__ */ new Set();
10589
+ for (const key of fileResourceKeys(baseConfig)) {
10590
+ const recorded = baseDigests.get(key);
10591
+ if (recorded !== void 0) {
10592
+ tags.set(key, recorded);
10593
+ continue;
10594
+ }
10595
+ const inLocal = options.localKeys.has(key);
10596
+ const inRemote = options.remoteKeys.has(key);
10597
+ const classifiable = inLocal || inRemote;
10598
+ const remoteDigest = remoteDigests.get(key);
10599
+ if (options.legacyState && options.remoteUnchanged && remoteDigest !== void 0) {
10600
+ tags.set(key, remoteDigest);
10601
+ if (classifiable) optimistic.add(key);
10602
+ continue;
10603
+ }
10604
+ const localTag = localDigests.get(key) ?? UNTRACKED.local;
10605
+ const remoteTag = remoteDigest ?? UNTRACKED.remote;
10606
+ if (inLocal !== inRemote) tags.set(key, inLocal ? localTag : remoteTag);
10607
+ else if (inLocal && localTag === remoteTag) tags.set(key, localTag);
10608
+ else tags.set(key, UNTRACKED.base);
10609
+ if (classifiable) conservative.add(key);
10610
+ }
10611
+ return {
10612
+ tags,
10613
+ estimatedKeys: {
10614
+ optimistic,
10615
+ conservative
10616
+ }
10617
+ };
10484
10618
  }
10485
10619
  /**
10486
10620
  * Applies a resolved 3-way merge, returning the merged customization config.
@@ -10597,8 +10731,6 @@ function sanitizeFileName(name) {
10597
10731
  * Unlike {@link deduplicateName} from `@/lib/deduplicateName`, this function is
10598
10732
  * file-name-aware: it inserts the counter before the extension (e.g. `file_1.js`)
10599
10733
  * rather than appending it at the end (which would produce `file.js_1`).
10600
- * This extension-aware behavior cannot be achieved by the generic lib utility,
10601
- * so a local implementation is necessary.
10602
10734
  */
10603
10735
  function deduplicateFileName(baseName, usedNames) {
10604
10736
  if (!usedNames.has(baseName)) {
@@ -10633,7 +10765,7 @@ function planResources(resources, platformName, platformDir, resourceType, relat
10633
10765
  });
10634
10766
  else {
10635
10767
  const fileName = deduplicateFileName(sanitizeFileName(resource.file.name), usedNames);
10636
- const declaredPath = preservePaths?.get(resourceKey(platformName, resourceType, resource.file.name));
10768
+ const declaredPath = preservePaths?.get(remoteFileResourceKey(platformName, resourceType, resource.file.name));
10637
10769
  if (declaredPath !== void 0) {
10638
10770
  planned.push({
10639
10771
  type: "FILE",
@@ -10642,7 +10774,7 @@ function planResources(resources, platformName, platformDir, resourceType, relat
10642
10774
  filesToDownload.push({
10643
10775
  fileName,
10644
10776
  fileKey: resource.file.fileKey,
10645
- absolutePath: join(contentBase, declaredPath)
10777
+ absolutePath: resolve(contentBase, declaredPath)
10646
10778
  });
10647
10779
  } else {
10648
10780
  const absolutePath = join(dir, fileName);
@@ -11702,6 +11834,215 @@ var apply_default$9 = define({
11702
11834
  }
11703
11835
  });
11704
11836
  //#endregion
11837
+ //#region src/lib/contentDigest.ts
11838
+ const PREFIX = "sha256:";
11839
+ const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
11840
+ /**
11841
+ * Computes the digest of raw bytes.
11842
+ *
11843
+ * The bytes are hashed as-is: no newline / BOM / trailing-newline normalization.
11844
+ * kintone stores the uploaded byte sequence verbatim, so normalizing here would
11845
+ * report "no changes" for content that actually changes once uploaded. A
11846
+ * zero-byte input is a normal input and yields the sha256 of the empty string,
11847
+ * so "no digest recorded" stays distinguishable from "the content is empty".
11848
+ */
11849
+ function computeContentDigest(data) {
11850
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
11851
+ return `${PREFIX}${createHash("sha256").update(bytes).digest("hex")}`;
11852
+ }
11853
+ /**
11854
+ * Narrows an untrusted value (e.g. a parsed state file) to a {@link ContentDigest}.
11855
+ *
11856
+ * The full `sha256:<64 lowercase hex chars>` shape is enforced, not just the
11857
+ * prefix: state files are hand-editable, and a truncated or otherwise malformed
11858
+ * digest would be adopted as a comparison token and silently report the remote
11859
+ * as changed. Every value {@link computeContentDigest} produces matches.
11860
+ */
11861
+ function isContentDigest(value) {
11862
+ return typeof value === "string" && DIGEST_PATTERN.test(value);
11863
+ }
11864
+ //#endregion
11865
+ //#region src/core/application/customization/customizationDigests.ts
11866
+ /**
11867
+ * Digests the given local FILE resources.
11868
+ *
11869
+ * Paths resolve with `resolve(basePath, path)` — the same resolution push
11870
+ * uploads from and pull writes downloads to — so the digest always describes the
11871
+ * bytes that actually travel to kintone, including when an absolute path is
11872
+ * declared.
11873
+ *
11874
+ * `keys` restricts the read to the resource keys the caller needs; keys whose
11875
+ * body cannot be read are simply absent from the result (see
11876
+ * {@link readLocalDigest}).
11877
+ */
11878
+ async function computeFileDigests(config, basePath, reader, keys) {
11879
+ return digestLocalFiles(config, basePath, reader, keys);
11880
+ }
11881
+ /**
11882
+ * Digests every local FILE resource of a config about to be persisted as the new
11883
+ * base snapshot. Same path resolution and same failure handling as
11884
+ * {@link computeFileDigests}; unreadable files stay untracked in the snapshot.
11885
+ */
11886
+ async function computeSnapshotFileDigests(config, basePath, reader) {
11887
+ return digestLocalFiles(config, basePath, reader, void 0);
11888
+ }
11889
+ async function digestLocalFiles(config, basePath, reader, keys) {
11890
+ const targets = [];
11891
+ for (const { platform, category, resource } of walkCustomizationBuckets(config)) {
11892
+ if (resource.type !== "FILE") continue;
11893
+ const key = resourceKey(platform, category, resourceName(resource));
11894
+ if (keys !== void 0 && !keys.has(key)) continue;
11895
+ targets.push({
11896
+ key,
11897
+ absolutePath: resolve(basePath, resource.path)
11898
+ });
11899
+ }
11900
+ const results = await Promise.all(targets.map(async ({ key, absolutePath }) => ({
11901
+ key,
11902
+ digest: await readLocalDigest(reader, absolutePath)
11903
+ })));
11904
+ const digests = /* @__PURE__ */ new Map();
11905
+ for (const { key, digest } of results) if (digest !== void 0) digests.set(key, digest);
11906
+ return digests;
11907
+ }
11908
+ /**
11909
+ * Reads and digests one local file, or reports "unknown" when it cannot be read.
11910
+ *
11911
+ * The swallow is deliberate (the one try/catch of this flow): a config may
11912
+ * declare a build artifact that has not been generated yet, and neither `diff`
11913
+ * nor `pull` nor drift detection should fail because of it. The key stays
11914
+ * untracked and the merge falls back to its conservative classification.
11915
+ */
11916
+ async function readLocalDigest(reader, absolutePath) {
11917
+ try {
11918
+ return computeContentDigest(await reader.read(absolutePath));
11919
+ } catch {
11920
+ return;
11921
+ }
11922
+ }
11923
+ /**
11924
+ * Downloads and digests the given remote FILE resources.
11925
+ *
11926
+ * The resource key and the fileKey are paired in a single walk of the raw remote
11927
+ * shape: an index keyed by the remote file name could not be looked up when a
11928
+ * name contains `/`, because the merge key uses the trailing segment only. A
11929
+ * download failure propagates — a transient network error must not silently
11930
+ * degrade into "the remote has no content for this key" and change the
11931
+ * classification.
11932
+ */
11933
+ async function computeRemoteFileDigests(remote, downloader, keys) {
11934
+ const targets = [];
11935
+ for (const { platform, category, resource } of walkCustomizationBuckets(remote)) {
11936
+ if (resource.type !== "FILE") continue;
11937
+ const key = remoteFileResourceKey(platform, category, resource.file.name);
11938
+ if (!keys.has(key)) continue;
11939
+ targets.push({
11940
+ key,
11941
+ fileKey: resource.file.fileKey
11942
+ });
11943
+ }
11944
+ const results = await Promise.all(targets.map(async ({ key, fileKey }) => [key, computeContentDigest(await downloader.download(fileKey))]));
11945
+ return new Map(results);
11946
+ }
11947
+ //#endregion
11948
+ //#region src/core/application/customization/customizationThreeWayMerge.ts
11949
+ /**
11950
+ * Assembles the customization 3-way merge shared by `diff`, `push` and `pull`.
11951
+ *
11952
+ * Only the contents that can change a classification are fetched: a key present
11953
+ * on one side alone is decided by existence, so local reads are limited to
11954
+ * `local ∩ (base ∪ remote)` and downloads to `remote ∩ (base ∪ local)`. Base
11955
+ * tags come from the snapshot's recorded digests, with
11956
+ * {@link resolveBaseFileDigests} filling in whatever was never recorded.
11957
+ */
11958
+ async function buildCustomizationThreeWayMerge({ container, state, baseRevision, local, remote, basePath, localDigests: precomputedLocalDigests }) {
11959
+ const baseKeys = fileResourceKeys(state.config);
11960
+ const localKeys = fileResourceKeys(local);
11961
+ const remoteKeys = fileResourceKeys(remote.config);
11962
+ const localNeeded = intersect(localKeys, union(baseKeys, remoteKeys));
11963
+ const remoteNeeded = intersect(remoteKeys, union(baseKeys, localKeys));
11964
+ const [localDigests, remoteDigests] = await Promise.all([precomputedLocalDigests !== void 0 ? Promise.resolve(subset(precomputedLocalDigests, localNeeded)) : computeFileDigests(local, basePath, container.fileContentReader, localNeeded), computeRemoteFileDigests(remote.raw, container.fileDownloader, remoteNeeded)]);
11965
+ const { tags: baseTags, estimatedKeys } = resolveBaseFileDigests(state.config, state.fileDigests, localDigests, remoteDigests, {
11966
+ localKeys,
11967
+ remoteKeys,
11968
+ legacyState: state.fileDigests.size === 0,
11969
+ remoteUnchanged: baseRevision !== void 0 && baseRevision === remote.revision
11970
+ });
11971
+ return {
11972
+ merge: computeCustomizationThreeWayMerge(state.config, local, remote.config, {
11973
+ base: baseTags,
11974
+ local: localDigests,
11975
+ remote: remoteDigests
11976
+ }),
11977
+ estimatedKeys,
11978
+ remoteDigests,
11979
+ unreadableLocalKeys: difference(localNeeded, localDigests)
11980
+ };
11981
+ }
11982
+ function difference(keys, digests) {
11983
+ return new Set([...keys].filter((key) => !digests.has(key)));
11984
+ }
11985
+ function union(a, b) {
11986
+ return new Set([...a, ...b]);
11987
+ }
11988
+ function intersect(a, b) {
11989
+ const result = /* @__PURE__ */ new Set();
11990
+ for (const key of a) if (b.has(key)) result.add(key);
11991
+ return result;
11992
+ }
11993
+ function subset(digests, keys) {
11994
+ return new Map([...digests].filter(([key]) => keys.has(key)));
11995
+ }
11996
+ //#endregion
11997
+ //#region src/core/domain/customization/services/customizationStateParser.ts
11998
+ /**
11999
+ * Collects the per-FILE `digest` entries into the merge's key space. The
12000
+ * structure itself is already validated by {@link CustomizationConfigParser},
12001
+ * so this pass only reads the extra key and rejects malformed values. A missing
12002
+ * `digest` means "untracked", not an error: a state file carrying no digest at
12003
+ * all parses into an empty map.
12004
+ */
12005
+ function parseFileDigests(parsed) {
12006
+ const digests = /* @__PURE__ */ new Map();
12007
+ if (!isRecord(parsed)) return digests;
12008
+ for (const platform of PLATFORMS) {
12009
+ const platformValue = parsed[platform];
12010
+ if (!isRecord(platformValue)) continue;
12011
+ for (const category of CATEGORIES) {
12012
+ const list = platformValue[category];
12013
+ if (!Array.isArray(list)) continue;
12014
+ for (const raw of list) {
12015
+ const entry = digestEntry(raw, platform, category);
12016
+ if (entry !== void 0) digests.set(entry[0], entry[1]);
12017
+ }
12018
+ }
12019
+ }
12020
+ return digests;
12021
+ }
12022
+ /** The `[resourceKey, digest]` pair of one serialized resource, if it has one. */
12023
+ function digestEntry(raw, platform, category) {
12024
+ if (!isRecord(raw) || raw.type !== "FILE" || typeof raw.path !== "string") return;
12025
+ const digest = raw.digest;
12026
+ if (digest === void 0 || digest === null) return;
12027
+ if (!isContentDigest(digest)) throw new BusinessRuleError(CustomizationErrorCode.CzInvalidConfigStructure, `FILE resource "${raw.path}" has an invalid "digest": ${String(digest)}. Must be a "sha256:<64 lowercase hex chars>" string`);
12028
+ return [resourceKey(platform, category, resourceName({
12029
+ type: "FILE",
12030
+ path: raw.path
12031
+ })), digest];
12032
+ }
12033
+ /**
12034
+ * Parses pre-parsed (codec-decoded) data into a {@link CustomizationState}.
12035
+ *
12036
+ * The inverse of {@link CustomizationStateSerializer}: the captured config is
12037
+ * parsed via the same {@link CustomizationConfigParser} as the local YAML, so
12038
+ * the base snapshot is validated identically, then the per-FILE `digest` keys
12039
+ * are collected into the snapshot's digest map.
12040
+ */
12041
+ const CustomizationStateParser = { parse: (parsed) => ({
12042
+ config: CustomizationConfigParser.parse(parsed),
12043
+ fileDigests: parseFileDigests(parsed)
12044
+ }) };
12045
+ //#endregion
11705
12046
  //#region src/core/application/customization/customizationRemote.ts
11706
12047
  /**
11707
12048
  * Converts the kintone remote customization (file metadata) into a
@@ -11730,59 +12071,6 @@ function toResource(resource) {
11730
12071
  path: resource.file.name
11731
12072
  };
11732
12073
  }
11733
- /**
11734
- * Computes the set of same-name FILE basenames whose local content differs from
11735
- * the remote content, across all platforms/categories — the same signal the
11736
- * 2-way `detectCustomizationDiff` uses for the `modified` entries. Reused here so
11737
- * the 3-way merge marks divergent same-name files as conflicts.
11738
- */
11739
- async function computeModifiedFileNames(local, remote, basePath, container) {
11740
- const pairs = [
11741
- ...matchedPairs(local.desktop.js, remote.desktop.js),
11742
- ...matchedPairs(local.desktop.css, remote.desktop.css),
11743
- ...matchedPairs(local.mobile.js, remote.mobile.js),
11744
- ...matchedPairs(local.mobile.css, remote.mobile.css)
11745
- ];
11746
- const modified = /* @__PURE__ */ new Set();
11747
- if (pairs.length === 0) return modified;
11748
- const results = await Promise.all(pairs.map(async ({ localResource, remoteFileKey }) => {
11749
- const localPath = join(basePath, localResource.path);
11750
- const [localContent, remoteContent] = await Promise.all([container.fileContentReader.read(localPath), container.fileDownloader.download(remoteFileKey)]);
11751
- const isEqual = Buffer.from(localContent).equals(Buffer.from(remoteContent));
11752
- return {
11753
- name: resourceName(localResource),
11754
- isEqual
11755
- };
11756
- }));
11757
- for (const { name, isEqual } of results) if (!isEqual) modified.add(name);
11758
- return modified;
11759
- }
11760
- /** Finds matched local-FILE + remote-FILE pairs by basename. */
11761
- function matchedPairs(localResources, remoteResources) {
11762
- const remoteFileMap = /* @__PURE__ */ new Map();
11763
- for (const r of remoteResources) if (r.type === "FILE") remoteFileMap.set(remoteResourceName(r), r.file.fileKey);
11764
- const pairs = [];
11765
- for (const local of localResources) {
11766
- if (local.type !== "FILE") continue;
11767
- const remoteFileKey = remoteFileMap.get(resourceName(local));
11768
- if (remoteFileKey !== void 0) pairs.push({
11769
- localResource: local,
11770
- remoteFileKey
11771
- });
11772
- }
11773
- return pairs;
11774
- }
11775
- //#endregion
11776
- //#region src/core/domain/customization/services/customizationStateParser.ts
11777
- /**
11778
- * Parses pre-parsed (codec-decoded) data into a {@link CustomizationState}.
11779
- *
11780
- * The inverse of {@link CustomizationStateSerializer}: the captured config is
11781
- * parsed via the same {@link CustomizationConfigParser} as the local YAML, so
11782
- * the base snapshot is validated identically. The domain layer does not depend
11783
- * on YAML; the application layer handles codec decoding before calling this.
11784
- */
11785
- const CustomizationStateParser = { parse: (parsed) => ({ config: CustomizationConfigParser.parse(parsed) }) };
11786
12074
  //#endregion
11787
12075
  //#region src/core/application/customization/loadCustomizationThreeWayInputs.ts
11788
12076
  /**
@@ -11796,7 +12084,7 @@ async function loadCustomizationThreeWayInputs(container) {
11796
12084
  codec: container.configCodec,
11797
12085
  stateStorage: container.customizationStateStorage,
11798
12086
  appRevisionStorage: container.appRevisionStorage,
11799
- parseState: (parsed) => CustomizationStateParser.parse(parsed).config,
12087
+ parseState: (parsed) => CustomizationStateParser.parse(parsed),
11800
12088
  stateLabel: "Customization state",
11801
12089
  loadLocal: async () => {
11802
12090
  const result = await container.customizationStorage.get();
@@ -11820,17 +12108,58 @@ async function loadCustomizationThreeWayInputs(container) {
11820
12108
  }
11821
12109
  //#endregion
11822
12110
  //#region src/core/application/customization/detectCustomizationThreeWayDiff.ts
12111
+ /** How many resource keys a note lists inline before summarizing the rest. */
12112
+ const MAX_LISTED_KEYS = 10;
12113
+ /**
12114
+ * Renders the affected resource keys so the note identifies its subjects.
12115
+ *
12116
+ * The counts alone cannot be acted on: neither the diff lines nor `customize
12117
+ * push` mark which entries were inferred, so "N file(s)" leaves the user with no
12118
+ * way to tell them apart from the exactly-compared ones.
12119
+ */
12120
+ function formatKeys(keys) {
12121
+ const listed = keys.slice(0, MAX_LISTED_KEYS).join(", ");
12122
+ const rest = keys.length - MAX_LISTED_KEYS;
12123
+ return rest > 0 ? `${listed}, and ${rest} more` : listed;
12124
+ }
12125
+ function optimisticNote(keys) {
12126
+ return [`Base snapshot has no content digest for ${keys.length} file(s); they were classified by assuming the remote still matches the base (app revision unchanged): ${formatKeys(keys)}.`, "A normal (non-force) `customize push` or `customize pull` records the baseline. If the remote may have been edited outside this tool, run `customize pull --force` first to take the remote side — that replaces every local customization file and your customization config file with the remote copy, discarding the local edits listed above, so save them elsewhere first."].join("\n");
12127
+ }
12128
+ function conservativeNote(keys) {
12129
+ return [`Base snapshot has no content digest for ${keys.length} file(s); their classification is inferred: ${formatKeys(keys)}.`, "Run `customize pull` once (resolve the entries, or use `--ours` to keep every local copy) to record the baseline for files that exist on the remote or can be read from disk. Subsequent runs use exact content comparison for those files. `--theirs` records the baseline too, but it replaces the local files on disk with the remote copies wherever the remote side has a body, so save the local edits elsewhere first."].join("\n");
12130
+ }
12131
+ function unreadableNote(keys) {
12132
+ return [`${keys.length} file(s) declared in the customization config file could not be read from disk, so their content counted as unknown and they are listed as changed: ${formatKeys(keys)}.`, "Build or restore those files (or fix the declared paths) and run `customize diff` again to see the real classification."].join("\n");
12133
+ }
12134
+ /**
12135
+ * Builds the caveats for keys the classification could not compare exactly:
12136
+ * those whose base content was inferred rather than read from the snapshot, and
12137
+ * those whose local body was unreadable. Only keys that actually surfaced as a
12138
+ * diff line are reported: such a key classified as `unchanged` tells the user
12139
+ * nothing, and over-reporting would train them to ignore the note.
12140
+ */
12141
+ function buildNotes(estimatedKeys, unreadableLocalKeys, changedKeys) {
12142
+ const changedIn = (keys) => [...keys].filter((key) => changedKeys.has(key));
12143
+ const notes = [];
12144
+ const optimistic = changedIn(estimatedKeys.optimistic);
12145
+ if (optimistic.length > 0) notes.push(optimisticNote(optimistic));
12146
+ const conservative = changedIn(estimatedKeys.conservative);
12147
+ if (conservative.length > 0) notes.push(conservativeNote(conservative));
12148
+ const unreadable = changedIn(unreadableLocalKeys);
12149
+ if (unreadable.length > 0) notes.push(unreadableNote(unreadable));
12150
+ return notes;
12151
+ }
11823
12152
  /**
11824
12153
  * Detects customization differences with 3-way awareness.
11825
12154
  *
11826
12155
  * When a state (base snapshot) exists, classifies changes into local-only,
11827
- * remote drift, and conflicts via the file-name-keyed merge. Same-name files
11828
- * whose content diverges are conflicts. When no state exists, falls back to the
11829
- * existing 2-way `detectCustomizationDiff` (the CLI renders it with its 2-way
11830
- * printer).
12156
+ * remote drift, and conflicts via the resource-key-keyed merge, comparing FILE
12157
+ * contents by digest against the base snapshot. When no state exists, falls back
12158
+ * to the existing 2-way `detectCustomizationDiff` (the CLI renders it with its
12159
+ * 2-way printer).
11831
12160
  */
11832
12161
  async function detectCustomizationThreeWayDiff({ container, input }) {
11833
- const { state, local, remote } = await loadCustomizationThreeWayInputs(container);
12162
+ const { state, baseRevision, local, remote } = await loadCustomizationThreeWayInputs(container);
11834
12163
  if (state === void 0 || local === void 0) return {
11835
12164
  mode: "two-way",
11836
12165
  diff: await detectCustomizationDiff({
@@ -11838,8 +12167,15 @@ async function detectCustomizationThreeWayDiff({ container, input }) {
11838
12167
  input: { basePath: input.basePath }
11839
12168
  })
11840
12169
  };
11841
- const modifiedFileNames = await computeModifiedFileNames(local, remote.raw, input.basePath, container);
11842
- return buildRecordThreeWayDiff(computeCustomizationThreeWayMerge(state, local, remote.config, modifiedFileNames), (entry) => entry.key);
12170
+ const { merge, estimatedKeys, unreadableLocalKeys } = await buildCustomizationThreeWayMerge({
12171
+ container,
12172
+ state,
12173
+ baseRevision,
12174
+ local,
12175
+ remote,
12176
+ basePath: input.basePath
12177
+ });
12178
+ return buildRecordThreeWayDiff(merge, (entry) => entry.key, [], buildNotes(estimatedKeys, unreadableLocalKeys, new Set(merge.entries.filter((entry) => entry.change.kind === "localOnly" || entry.change.kind === "remoteOnly" || entry.change.kind === "conflict").map((entry) => entry.key))));
11843
12179
  }
11844
12180
  //#endregion
11845
12181
  //#region src/cli/commands/customize/diff.ts
@@ -11894,20 +12230,37 @@ var diff_default$10 = define({
11894
12230
  * stringification (via the codec port in the application layer).
11895
12231
  *
11896
12232
  * The snapshot is serialized through the exact same path as `capture`
11897
- * ({@link CustomizationConfigSerializer}) so the state file is round-trip
11898
- * compatible with the captured config. The app revision is NOT written here; it
11899
- * is persisted separately via `AppRevisionStorage`.
12233
+ * ({@link CustomizationConfigSerializer}), with the recorded content digest
12234
+ * injected into each FILE entry via the serializer's decorate hook. URL
12235
+ * resources and FILE resources with no recorded digest are written unchanged,
12236
+ * so the state file stays round-trip compatible with the captured config. The
12237
+ * app revision is NOT written here; it is persisted separately via
12238
+ * `AppRevisionStorage`.
11900
12239
  */
11901
- const CustomizationStateSerializer = { serialize: (state) => CustomizationConfigSerializer.serialize(state.config) };
12240
+ const CustomizationStateSerializer = { serialize: (state) => CustomizationConfigSerializer.serialize(state.config, (platform, category, resource) => {
12241
+ if (resource.type !== "FILE") return;
12242
+ const digest = state.fileDigests.get(resourceKey(platform, category, resourceName(resource)));
12243
+ return digest === void 0 ? void 0 : { digest };
12244
+ }) };
11902
12245
  //#endregion
11903
12246
  //#region src/core/application/customization/customizationStateIo.ts
11904
12247
  /**
11905
- * Persists the new base: the customization snapshot (state file) and the app
11906
- * revision (`state/<appName>/revision.yaml`) side by side. The two writes are
11907
- * kept together so they always advance as a pair (snapshot → revision).
12248
+ * Persists the new base: the customization snapshot (config + FILE content
12249
+ * digests) and the app revision (`state/<appName>/revision.yaml`) side by side.
12250
+ * The two writes are kept together so they always advance as a pair
12251
+ * (snapshot → revision).
12252
+ *
12253
+ * `fileDigests` is computed by the caller rather than here: this function runs
12254
+ * after `updateCustomization` (it needs the resulting revision), while push must
12255
+ * digest its files *before* uploading them, and pull's merge path records the
12256
+ * remote's digests rather than what is on disk. Keeping the parameter required
12257
+ * still makes a digest-less snapshot impossible to write by accident.
11908
12258
  */
11909
- async function saveCustomizationSnapshotAndRevision(container, config, revision) {
11910
- await saveSnapshotState(container.customizationStateStorage, container.configCodec, CustomizationStateSerializer.serialize, { config });
12259
+ async function saveCustomizationSnapshotAndRevision(container, config, fileDigests, revision) {
12260
+ await saveSnapshotState(container.customizationStateStorage, container.configCodec, CustomizationStateSerializer.serialize, {
12261
+ config,
12262
+ fileDigests
12263
+ });
11911
12264
  await saveAppRevision(container.appRevisionStorage, container.configCodec, revision);
11912
12265
  }
11913
12266
  //#endregion
@@ -11924,7 +12277,7 @@ async function saveCustomizationSnapshotAndRevision(container, config, revision)
11924
12277
  * resolution, so an aborted resolution leaves the working copy untouched.
11925
12278
  */
11926
12279
  async function pullCustomization({ container, input }) {
11927
- const { state, local, remote } = await loadCustomizationThreeWayInputs(container);
12280
+ const { state, baseRevision, local, remote } = await loadCustomizationThreeWayInputs(container);
11928
12281
  if (input.force || state === void 0 || local === void 0) {
11929
12282
  const preservePaths = local !== void 0 ? buildPreservePaths(local) : void 0;
11930
12283
  const captured = await captureCustomization({
@@ -11936,17 +12289,28 @@ async function pullCustomization({ container, input }) {
11936
12289
  }
11937
12290
  });
11938
12291
  await container.customizationStorage.update(captured.configText);
11939
- await saveCustomizationSnapshotAndRevision(container, captured.config, remote.revision);
12292
+ const fileDigests = await computeSnapshotFileDigests(captured.config, input.basePath, container.fileContentReader);
12293
+ await saveCustomizationSnapshotAndRevision(container, captured.config, fileDigests, remote.revision);
11940
12294
  return { mode: input.force ? "force" : "firstTime" };
11941
12295
  }
11942
- const modifiedFileNames = await computeModifiedFileNames(local, remote.raw, input.basePath, container);
12296
+ const { merge, remoteDigests, estimatedKeys, unreadableLocalKeys } = await buildCustomizationThreeWayMerge({
12297
+ container,
12298
+ state,
12299
+ baseRevision,
12300
+ local,
12301
+ remote,
12302
+ basePath: input.basePath
12303
+ });
11943
12304
  return {
11944
12305
  mode: "merged",
11945
- merge: computeCustomizationThreeWayMerge(state, local, remote.config, modifiedFileNames),
12306
+ merge,
11946
12307
  local,
11947
12308
  remote: remote.raw,
11948
12309
  remoteConfig: remote.config,
11949
- remoteRevision: remote.revision
12310
+ remoteRevision: remote.revision,
12311
+ remoteDigests,
12312
+ estimatedKeys,
12313
+ unreadableLocalKeys
11950
12314
  };
11951
12315
  }
11952
12316
  /**
@@ -11962,18 +12326,15 @@ async function pullCustomization({ container, input }) {
11962
12326
  async function applyPulledCustomizationMerge({ container, input }) {
11963
12327
  const merged = wrapBusinessRuleError(() => resolveCustomizationMerge(input.merge, input.resolution, input.local, input.remoteConfig));
11964
12328
  const fromRemote = /* @__PURE__ */ new Set();
11965
- for (const entry of input.merge.entries) {
11966
- const name = nameFromKey(entry.key);
11967
- if (name === void 0) continue;
11968
- if (entry.change.kind === "remoteOnly") fromRemote.add(name);
11969
- else if (entry.change.kind === "conflict" && input.resolution.get(entry.key) === "remote") fromRemote.add(name);
11970
- }
12329
+ for (const entry of input.merge.entries) if (entry.change.kind === "remoteOnly") fromRemote.add(entry.key);
12330
+ else if (entry.change.kind === "conflict" && input.resolution.get(entry.key) === "remote") fromRemote.add(entry.key);
11971
12331
  const remoteFileKeys = remoteFileKeyMap(input.remote);
11972
12332
  const downloads = [];
11973
- for (const resource of allFileResources(merged)) {
11974
- const name = resourceName(resource);
11975
- if (!fromRemote.has(name)) continue;
11976
- const fileKey = remoteFileKeys.get(name);
12333
+ for (const { platform, category, resource } of walkCustomizationBuckets(merged)) {
12334
+ if (resource.type !== "FILE") continue;
12335
+ const key = resourceKey(platform, category, resourceName(resource));
12336
+ if (!fromRemote.has(key)) continue;
12337
+ const fileKey = remoteFileKeys.get(key);
11977
12338
  if (fileKey === void 0) continue;
11978
12339
  const absolutePath = resolve(input.basePath, resource.path);
11979
12340
  downloads.push((async () => {
@@ -11984,7 +12345,28 @@ async function applyPulledCustomizationMerge({ container, input }) {
11984
12345
  await Promise.all(downloads);
11985
12346
  const configText = stringifyConfig(container.configCodec, CustomizationConfigSerializer.serialize(merged));
11986
12347
  await container.customizationStorage.update(configText);
11987
- await saveCustomizationSnapshotAndRevision(container, merged, input.remoteRevision);
12348
+ await saveCustomizationSnapshotAndRevision(container, merged, await buildMergedBaseDigests(merged, input.basePath, input.remoteDigests, container), input.remoteRevision);
12349
+ }
12350
+ /**
12351
+ * Base digests for a merge that was just applied.
12352
+ *
12353
+ * A key the remote still holds records the *remote's* digest, even when the
12354
+ * local side won the merge: the base means "what local and remote last agreed
12355
+ * on", and content that stayed local was never uploaded. Recording the on-disk
12356
+ * bytes instead would make the very next diff report the local edit as remote
12357
+ * drift and block the push. Keys the remote does not have (locally added files,
12358
+ * or remote-only files freshly downloaded) take the on-disk digest.
12359
+ */
12360
+ async function buildMergedBaseDigests(merged, basePath, remoteDigests, container) {
12361
+ const mergedKeys = fileResourceKeys(merged);
12362
+ const fromDisk = new Set([...mergedKeys].filter((key) => !remoteDigests.has(key)));
12363
+ const diskDigests = await computeFileDigests(merged, basePath, container.fileContentReader, fromDisk);
12364
+ const digests = /* @__PURE__ */ new Map();
12365
+ for (const key of mergedKeys) {
12366
+ const digest = remoteDigests.get(key) ?? diskDigests.get(key);
12367
+ if (digest !== void 0) digests.set(key, digest);
12368
+ }
12369
+ return digests;
11988
12370
  }
11989
12371
  /**
11990
12372
  * Builds the path-preservation map for `captureCustomization`: bucket-qualified
@@ -11996,41 +12378,19 @@ async function applyPulledCustomizationMerge({ container, input }) {
11996
12378
  */
11997
12379
  function buildPreservePaths(config) {
11998
12380
  const map = /* @__PURE__ */ new Map();
11999
- const add = (platform, category, resources) => {
12000
- for (const resource of resources) if (resource.type === "FILE") map.set(resourceKey(platform, category, resourceName(resource)), resource.path);
12001
- };
12002
- add("desktop", "js", config.desktop.js);
12003
- add("desktop", "css", config.desktop.css);
12004
- add("mobile", "js", config.mobile.js);
12005
- add("mobile", "css", config.mobile.css);
12381
+ for (const { platform, category, resource } of walkCustomizationBuckets(config)) if (resource.type === "FILE") map.set(resourceKey(platform, category, resourceName(resource)), resource.path);
12006
12382
  return map;
12007
12383
  }
12008
- function allFileResources(config) {
12009
- return [
12010
- ...config.desktop.js,
12011
- ...config.desktop.css,
12012
- ...config.mobile.js,
12013
- ...config.mobile.css
12014
- ].filter((r) => r.type === "FILE");
12015
- }
12016
12384
  /**
12017
- * Extracts the file/resource name from a merge key. Resource keys are
12018
- * `platform:category:name`; the `config:scope` key has no resource name.
12385
+ * Remote FILE `fileKey`s under the merge identity, so a body is looked up by the
12386
+ * same key the merge decided to take from the remote. A flat basename map would
12387
+ * make cross-bucket same-basename files (e.g. `desktop/js/main.js` and
12388
+ * `mobile/js/main.js`) resolve to whichever bucket was registered last,
12389
+ * overwriting one bucket's body with the other's.
12019
12390
  */
12020
- function nameFromKey(key) {
12021
- const parts = key.split(":");
12022
- if (parts.length < 3) return void 0;
12023
- return parts.slice(2).join(":");
12024
- }
12025
12391
  function remoteFileKeyMap(remote) {
12026
12392
  const map = /* @__PURE__ */ new Map();
12027
- const add = (resources) => {
12028
- for (const r of resources) if (r.type === "FILE") map.set(remoteResourceName(r), r.file.fileKey);
12029
- };
12030
- add(remote.desktop.js);
12031
- add(remote.desktop.css);
12032
- add(remote.mobile.js);
12033
- add(remote.mobile.css);
12393
+ for (const { platform, category, resource } of walkCustomizationBuckets(remote)) if (resource.type === "FILE") map.set(remoteFileResourceKey(platform, category, resource.file.name), resource.file.fileKey);
12034
12394
  return map;
12035
12395
  }
12036
12396
  //#endregion
@@ -12054,6 +12414,42 @@ function derivePullPaths(customizeFilePath) {
12054
12414
  filePrefix: deriveFilePrefix(customizeFilePath)
12055
12415
  };
12056
12416
  }
12417
+ /**
12418
+ * What each choice does. `pull` never writes to kintone, so the destruction is
12419
+ * one-directional: only the copy on disk can be lost here, and the wording says
12420
+ * which choice loses it.
12421
+ *
12422
+ * The scope is a single value in the customization config file, so no file moves
12423
+ * either way. A resource whose remote side is a URL — or gone — carries no body
12424
+ * to write over the local copy, hence "wherever the remote side has a body"
12425
+ * rather than a flat promise that the file on disk is replaced.
12426
+ */
12427
+ const SCOPE_EFFECT = "remote takes the remote scope; local keeps the local one, and the remote stays untouched until the next `customize push`";
12428
+ const RESOURCE_EFFECT = "remote replaces the local file on disk wherever the remote side has a body; local keeps the copy on disk, and the remote stays untouched until the next `customize push`";
12429
+ /**
12430
+ * Why the divergence may not be real, when it may not be.
12431
+ *
12432
+ * An unreadable local file counts as unknown content, so it conflicts with any
12433
+ * remote change even where a baseline was recorded — a separate case from a
12434
+ * missing baseline, and the only one where picking `local` leaves the merge
12435
+ * pointing at a file that is not on disk.
12436
+ */
12437
+ function conflictCaveat(context, key) {
12438
+ if (context.unreadableLocalKeys.has(key)) return "the local file could not be read, so this may not be a real conflict";
12439
+ if (context.estimatedKeys.conservative.has(key)) return "no recorded baseline for this file, so this may not be a real conflict";
12440
+ }
12441
+ /**
12442
+ * The conflict prompt. Exported so its wording is pinned by a test: this prompt
12443
+ * is the only place where picking the wrong side discards content, and both the
12444
+ * caveat and the effect are chosen here.
12445
+ */
12446
+ function buildConflictPrompt(context, key) {
12447
+ const isScope = key === SCOPE_KEY;
12448
+ const effect = isScope ? SCOPE_EFFECT : RESOURCE_EFFECT;
12449
+ const caveat = conflictCaveat(context, key);
12450
+ const detail = caveat === void 0 ? effect : `${caveat}; ${effect}`;
12451
+ return `Conflict on ${isScope ? "scope" : `entry "${key}"`}. Keep which side? (${detail})`;
12452
+ }
12057
12453
  async function resolveConflicts$1(merged, options) {
12058
12454
  const resolution = /* @__PURE__ */ new Map();
12059
12455
  for (const conflict of merged.merge.conflicts) {
@@ -12066,7 +12462,7 @@ async function resolveConflicts$1(merged, options) {
12066
12462
  continue;
12067
12463
  }
12068
12464
  const selected = await p.select({
12069
- message: `Conflict on file "${conflict.key}". Keep which side?`,
12465
+ message: buildConflictPrompt(merged, conflict.key),
12070
12466
  options: [{
12071
12467
  value: "local",
12072
12468
  label: "local (ours)"
@@ -12120,7 +12516,8 @@ async function runPull$1(containerConfig, options) {
12120
12516
  local: result.local,
12121
12517
  remote: result.remote,
12122
12518
  remoteConfig: result.remoteConfig,
12123
- remoteRevision: result.remoteRevision
12519
+ remoteRevision: result.remoteRevision,
12520
+ remoteDigests: result.remoteDigests
12124
12521
  }
12125
12522
  });
12126
12523
  p.log.success("Local customization merged and written.");
@@ -12142,7 +12539,8 @@ async function runPull$1(containerConfig, options) {
12142
12539
  local: result.local,
12143
12540
  remote: result.remote,
12144
12541
  remoteConfig: result.remoteConfig,
12145
- remoteRevision: result.remoteRevision
12542
+ remoteRevision: result.remoteRevision,
12543
+ remoteDigests: result.remoteDigests
12146
12544
  }
12147
12545
  });
12148
12546
  p.log.success("Conflicts resolved. Local customization written.");
@@ -12196,6 +12594,9 @@ var pull_default$10 = define({
12196
12594
  //#region src/core/application/customization/pushCustomization.ts
12197
12595
  /** Pull command name surfaced in the drift hint message. */
12198
12596
  const CUSTOMIZE_PULL_COMMAND = "customize pull";
12597
+ function inferredDriftHint(count) {
12598
+ return `Drift possibly inferred: no recorded content baseline for ${count} of the drifted file(s). Run \`customize diff\` for the affected file(s); the pull above also records the baseline.`;
12599
+ }
12199
12600
  async function resolvePlatform(platform, basePath, fileUploader) {
12200
12601
  const resolveList = async (resources) => Promise.all(resources.map(async (resource) => {
12201
12602
  if (resource.type === "URL") return resource;
@@ -12216,10 +12617,11 @@ async function resolvePlatform(platform, basePath, fileUploader) {
12216
12617
  /**
12217
12618
  * Applies the local customization config to the remote with drift detection.
12218
12619
  *
12219
- * - Loads base/local/remote and computes the file-name-keyed 3-way merge. drift
12220
- * (remoteOnly or conflict entries) && !force → {@link buildDriftConflict}
12221
- * tagged with `ConfigDrift`. Same-name files whose content
12222
- * diverges count as conflicts and block a non-forced push.
12620
+ * - Loads base/local/remote and computes the resource-key-keyed 3-way merge.
12621
+ * drift (remoteOnly or conflict entries) && !force → {@link buildDriftConflict}
12622
+ * tagged with `ConfigDrift`. A file edited only locally is a local change, not
12623
+ * drift, so it pushes without `--force`; a file whose remote body moved away
12624
+ * from the base blocks the push.
12223
12625
  * - Replaces the full js/css lists with the local config's resolved (uploaded)
12224
12626
  * resources via `updateAppCustomize`, so renames are expressed as old-name
12225
12627
  * removal + new-name add (full-list replace — NOT an inexpressible
@@ -12230,12 +12632,25 @@ async function resolvePlatform(platform, basePath, fileUploader) {
12230
12632
  * post-apply revision as the new base (snapshot → revision).
12231
12633
  */
12232
12634
  async function pushCustomization({ container, input }) {
12233
- const { state, local, remote } = await loadCustomizationThreeWayInputs(container);
12635
+ const { state, baseRevision, local, remote } = await loadCustomizationThreeWayInputs(container);
12234
12636
  if (local === void 0) throw new ValidationError(ValidationErrorCode.InvalidInput, "Customization config file not found");
12235
12637
  const firstTime = state === void 0;
12236
- if (!firstTime && !input.force) {
12237
- const modifiedFileNames = await computeModifiedFileNames(local, remote.raw, input.basePath, container);
12238
- if (computeCustomizationThreeWayMerge(state, local, remote.config, modifiedFileNames).entries.some((e) => e.change.kind === "remoteOnly" || e.change.kind === "conflict")) throw buildDriftConflict(CUSTOMIZE_PULL_COMMAND);
12638
+ const localDigests = await computeSnapshotFileDigests(local, input.basePath, container.fileContentReader);
12639
+ if (state !== void 0 && !input.force) {
12640
+ const { merge, estimatedKeys } = await buildCustomizationThreeWayMerge({
12641
+ container,
12642
+ state,
12643
+ baseRevision,
12644
+ local,
12645
+ remote,
12646
+ basePath: input.basePath,
12647
+ localDigests
12648
+ });
12649
+ const driftKeys = merge.entries.filter((e) => e.change.kind === "remoteOnly" || e.change.kind === "conflict").map((e) => e.key);
12650
+ if (driftKeys.length > 0) {
12651
+ const inferred = driftKeys.filter((key) => estimatedKeys.conservative.has(key)).length;
12652
+ throw buildDriftConflict(CUSTOMIZE_PULL_COMMAND, inferred > 0 ? inferredDriftHint(inferred) : void 0);
12653
+ }
12239
12654
  }
12240
12655
  const [desktop, mobile] = await Promise.all([resolvePlatform(local.desktop, input.basePath, container.fileUploader), resolvePlatform(local.mobile, input.basePath, container.fileUploader)]);
12241
12656
  ResourceMerger.assertResourceCount("desktop.js", desktop.js);
@@ -12259,7 +12674,7 @@ async function pushCustomization({ container, input }) {
12259
12674
  scope: local.scope,
12260
12675
  desktop: local.desktop,
12261
12676
  mobile: local.mobile
12262
- }, newRevision);
12677
+ }, localDigests, newRevision);
12263
12678
  return {
12264
12679
  mode: firstTime ? "firstTime" : "push",
12265
12680
  revision: newRevision
@@ -15665,7 +16080,8 @@ function buildTasks(args) {
15665
16080
  local: result.local,
15666
16081
  remote: result.remote,
15667
16082
  remoteConfig: result.remoteConfig,
15668
- remoteRevision: result.remoteRevision
16083
+ remoteRevision: result.remoteRevision,
16084
+ remoteDigests: result.remoteDigests
15669
16085
  }
15670
16086
  });
15671
16087
  return "merged";