kintone-migrator 0.34.4 → 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 +729 -248
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
-
|
|
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,36 +10406,284 @@ function createCliCaptureContainers(input) {
|
|
|
10398
10406
|
}
|
|
10399
10407
|
//#endregion
|
|
10400
10408
|
//#region src/core/domain/customization/services/configSerializer.ts
|
|
10401
|
-
function serializeResource(resource) {
|
|
10402
|
-
|
|
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
|
|
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
|
+
*/
|
|
10462
|
+
const PLATFORMS = ["desktop", "mobile"];
|
|
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
|
+
*/
|
|
10472
|
+
const SCOPE_KEY = "config:scope";
|
|
10473
|
+
/**
|
|
10474
|
+
* Bucket-qualified resource identity: `platform:category:name`. Shared with the
|
|
10475
|
+
* force/firstTime pull path (`preservePaths`) so path preservation matches the
|
|
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.
|
|
10481
|
+
*/
|
|
10482
|
+
function resourceKey(platform, category, name) {
|
|
10483
|
+
return `${platform}:${category}:${name}`;
|
|
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
|
+
}
|
|
10518
|
+
function platformList(config, platform, category) {
|
|
10519
|
+
const p = config[platform];
|
|
10520
|
+
return category === "js" ? p.js : p.css;
|
|
10521
|
+
}
|
|
10522
|
+
/**
|
|
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.
|
|
10526
|
+
*/
|
|
10527
|
+
function toMap(config, tags, side) {
|
|
10528
|
+
const map = /* @__PURE__ */ new Map();
|
|
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, {
|
|
10533
|
+
resource,
|
|
10534
|
+
tag
|
|
10535
|
+
});
|
|
10536
|
+
}
|
|
10537
|
+
const scope = config.scope ?? "ALL";
|
|
10538
|
+
map.set(SCOPE_KEY, { scope });
|
|
10539
|
+
return map;
|
|
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
|
+
}
|
|
10547
|
+
function isScopeValue(v) {
|
|
10548
|
+
return "scope" in v;
|
|
10549
|
+
}
|
|
10550
|
+
function eq(a, b) {
|
|
10551
|
+
if (isScopeValue(a) || isScopeValue(b)) return isScopeValue(a) && isScopeValue(b) && a.scope === b.scope;
|
|
10552
|
+
return a.tag === b.tag;
|
|
10553
|
+
}
|
|
10554
|
+
/**
|
|
10555
|
+
* Computes the resource-key-keyed 3-way merge of base/local/remote customization.
|
|
10556
|
+
*
|
|
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.
|
|
10561
|
+
*/
|
|
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
|
+
};
|
|
10618
|
+
}
|
|
10619
|
+
/**
|
|
10620
|
+
* Applies a resolved 3-way merge, returning the merged customization config.
|
|
10621
|
+
*
|
|
10622
|
+
* Auto-merged entries take their `merged` value; conflicts take the chosen
|
|
10623
|
+
* side. The resulting resources are flattened back into per-platform js/css
|
|
10624
|
+
* lists, preserving the order they appear in the chosen/merged side. Throws a
|
|
10625
|
+
* BusinessRuleError when a conflict is left unresolved (programmer invariant).
|
|
10626
|
+
*/
|
|
10627
|
+
function resolveCustomizationMerge(merge, resolution, local, remote) {
|
|
10628
|
+
const chosen = /* @__PURE__ */ new Map();
|
|
10629
|
+
let scope = "ALL";
|
|
10630
|
+
for (const entry of merge.entries) {
|
|
10631
|
+
let value;
|
|
10632
|
+
if (entry.change.kind === "conflict") {
|
|
10633
|
+
const choice = resolution.get(entry.key);
|
|
10634
|
+
if (choice === void 0) throw new BusinessRuleError(CustomizationErrorCode.CzUnresolvedConflict, `Unresolved conflict for "${entry.key}"`);
|
|
10635
|
+
value = choice === "local" ? entry.local : entry.remote;
|
|
10636
|
+
} else value = entry.merged;
|
|
10637
|
+
if (value === void 0) continue;
|
|
10638
|
+
if (isScopeValue(value)) scope = value.scope;
|
|
10639
|
+
else chosen.set(entry.key, value.resource);
|
|
10640
|
+
}
|
|
10641
|
+
return rebuild(chosen, scope, local, remote);
|
|
10642
|
+
}
|
|
10643
|
+
/**
|
|
10644
|
+
* Rebuilds the config from the chosen resource keys, ordering each bucket by the
|
|
10645
|
+
* resource's position in local first, then any remote-only resources, so the
|
|
10646
|
+
* resulting list is stable and matches the user's local ordering intent.
|
|
10647
|
+
*
|
|
10648
|
+
* `path` is a local-owned concern (the build-output location) that is orthogonal
|
|
10649
|
+
* to which content wins the merge: whenever a key exists locally, the local
|
|
10650
|
+
* resource (its declared path) is kept regardless of the chosen content side.
|
|
10651
|
+
* Only remote-only keys fall back to the chosen (remote/basename) resource.
|
|
10652
|
+
*/
|
|
10653
|
+
function rebuild(chosen, scope, local, remote) {
|
|
10654
|
+
function bucket(platform, category) {
|
|
10655
|
+
const seen = /* @__PURE__ */ new Set();
|
|
10656
|
+
const out = [];
|
|
10657
|
+
const localByKey = /* @__PURE__ */ new Map();
|
|
10658
|
+
for (const resource of platformList(local, platform, category)) localByKey.set(resourceKey(platform, category, resourceName(resource)), resource);
|
|
10659
|
+
const append = (resources) => {
|
|
10660
|
+
for (const resource of resources) {
|
|
10661
|
+
const key = resourceKey(platform, category, resourceName(resource));
|
|
10662
|
+
if (seen.has(key)) continue;
|
|
10663
|
+
const picked = chosen.get(key);
|
|
10664
|
+
if (picked !== void 0) {
|
|
10665
|
+
out.push(localByKey.get(key) ?? picked);
|
|
10666
|
+
seen.add(key);
|
|
10667
|
+
}
|
|
10668
|
+
}
|
|
10669
|
+
};
|
|
10670
|
+
append(platformList(local, platform, category));
|
|
10671
|
+
append(platformList(remote, platform, category));
|
|
10672
|
+
return out;
|
|
10673
|
+
}
|
|
10674
|
+
return {
|
|
10675
|
+
scope,
|
|
10676
|
+
desktop: {
|
|
10677
|
+
js: bucket("desktop", "js"),
|
|
10678
|
+
css: bucket("desktop", "css")
|
|
10679
|
+
},
|
|
10680
|
+
mobile: {
|
|
10681
|
+
js: bucket("mobile", "js"),
|
|
10682
|
+
css: bucket("mobile", "css")
|
|
10683
|
+
}
|
|
10684
|
+
};
|
|
10685
|
+
}
|
|
10686
|
+
//#endregion
|
|
10431
10687
|
//#region src/lib/deduplicateName.ts
|
|
10432
10688
|
const DEFAULT_MAX_COUNTER = 1e4;
|
|
10433
10689
|
/**
|
|
@@ -10475,8 +10731,6 @@ function sanitizeFileName(name) {
|
|
|
10475
10731
|
* Unlike {@link deduplicateName} from `@/lib/deduplicateName`, this function is
|
|
10476
10732
|
* file-name-aware: it inserts the counter before the extension (e.g. `file_1.js`)
|
|
10477
10733
|
* rather than appending it at the end (which would produce `file.js_1`).
|
|
10478
|
-
* This extension-aware behavior cannot be achieved by the generic lib utility,
|
|
10479
|
-
* so a local implementation is necessary.
|
|
10480
10734
|
*/
|
|
10481
10735
|
function deduplicateFileName(baseName, usedNames) {
|
|
10482
10736
|
if (!usedNames.has(baseName)) {
|
|
@@ -10500,7 +10754,7 @@ function usedStemsFor(usedNames, ext) {
|
|
|
10500
10754
|
for (const name of usedNames) if (name.endsWith(ext)) stems.add(name.slice(0, name.length - ext.length));
|
|
10501
10755
|
return stems;
|
|
10502
10756
|
}
|
|
10503
|
-
function planResources(resources, platformDir, resourceType, relativeBaseDir) {
|
|
10757
|
+
function planResources(resources, platformName, platformDir, resourceType, relativeBaseDir, contentBase, preservePaths) {
|
|
10504
10758
|
const planned = [];
|
|
10505
10759
|
const filesToDownload = [];
|
|
10506
10760
|
const dir = join(platformDir, resourceType);
|
|
@@ -10511,28 +10765,42 @@ function planResources(resources, platformDir, resourceType, relativeBaseDir) {
|
|
|
10511
10765
|
});
|
|
10512
10766
|
else {
|
|
10513
10767
|
const fileName = deduplicateFileName(sanitizeFileName(resource.file.name), usedNames);
|
|
10514
|
-
const
|
|
10515
|
-
|
|
10516
|
-
|
|
10517
|
-
|
|
10518
|
-
|
|
10519
|
-
|
|
10520
|
-
|
|
10521
|
-
|
|
10522
|
-
|
|
10523
|
-
|
|
10524
|
-
|
|
10768
|
+
const declaredPath = preservePaths?.get(remoteFileResourceKey(platformName, resourceType, resource.file.name));
|
|
10769
|
+
if (declaredPath !== void 0) {
|
|
10770
|
+
planned.push({
|
|
10771
|
+
type: "FILE",
|
|
10772
|
+
path: declaredPath
|
|
10773
|
+
});
|
|
10774
|
+
filesToDownload.push({
|
|
10775
|
+
fileName,
|
|
10776
|
+
fileKey: resource.file.fileKey,
|
|
10777
|
+
absolutePath: resolve(contentBase, declaredPath)
|
|
10778
|
+
});
|
|
10779
|
+
} else {
|
|
10780
|
+
const absolutePath = join(dir, fileName);
|
|
10781
|
+
const relativePath = join(relativeBaseDir, resourceType, fileName);
|
|
10782
|
+
planned.push({
|
|
10783
|
+
type: "FILE",
|
|
10784
|
+
path: relativePath
|
|
10785
|
+
});
|
|
10786
|
+
filesToDownload.push({
|
|
10787
|
+
fileName,
|
|
10788
|
+
fileKey: resource.file.fileKey,
|
|
10789
|
+
absolutePath
|
|
10790
|
+
});
|
|
10791
|
+
}
|
|
10525
10792
|
}
|
|
10526
10793
|
return {
|
|
10527
10794
|
resources: planned,
|
|
10528
10795
|
filesToDownload
|
|
10529
10796
|
};
|
|
10530
10797
|
}
|
|
10531
|
-
function planPlatform(remotePlatform, platformName, basePath, filePrefix) {
|
|
10532
|
-
const
|
|
10798
|
+
function planPlatform(remotePlatform, platformName, basePath, filePrefix, preservePaths) {
|
|
10799
|
+
const contentBase = join(basePath, filePrefix);
|
|
10800
|
+
const platformDir = join(contentBase, platformName);
|
|
10533
10801
|
const platformPrefix = platformName;
|
|
10534
|
-
const jsPlan = planResources(remotePlatform.js, platformDir, "js", platformPrefix);
|
|
10535
|
-
const cssPlan = planResources(remotePlatform.css, platformDir, "css", platformPrefix);
|
|
10802
|
+
const jsPlan = planResources(remotePlatform.js, platformName, platformDir, "js", platformPrefix, contentBase, preservePaths);
|
|
10803
|
+
const cssPlan = planResources(remotePlatform.css, platformName, platformDir, "css", platformPrefix, contentBase, preservePaths);
|
|
10536
10804
|
const fileCount = remotePlatform.js.filter((r) => r.type === "FILE").length + remotePlatform.css.filter((r) => r.type === "FILE").length;
|
|
10537
10805
|
return {
|
|
10538
10806
|
platform: {
|
|
@@ -10560,8 +10828,8 @@ async function downloadFiles(files, container) {
|
|
|
10560
10828
|
async function captureCustomization({ container, input }) {
|
|
10561
10829
|
const existing = await container.customizationStorage.get();
|
|
10562
10830
|
const { scope, desktop, mobile } = await container.customizationConfigurator.getCustomization();
|
|
10563
|
-
const desktopPlan = planPlatform(desktop, "desktop", input.basePath, input.filePrefix);
|
|
10564
|
-
const mobilePlan = planPlatform(mobile, "mobile", input.basePath, input.filePrefix);
|
|
10831
|
+
const desktopPlan = planPlatform(desktop, "desktop", input.basePath, input.filePrefix, input.preservePaths);
|
|
10832
|
+
const mobilePlan = planPlatform(mobile, "mobile", input.basePath, input.filePrefix, input.preservePaths);
|
|
10565
10833
|
const config = {
|
|
10566
10834
|
scope,
|
|
10567
10835
|
desktop: desktopPlan.platform,
|
|
@@ -10571,6 +10839,7 @@ async function captureCustomization({ container, input }) {
|
|
|
10571
10839
|
await downloadFiles([...desktopPlan.filesToDownload, ...mobilePlan.filesToDownload], container);
|
|
10572
10840
|
return {
|
|
10573
10841
|
configText,
|
|
10842
|
+
config,
|
|
10574
10843
|
hasExistingConfig: existing.exists,
|
|
10575
10844
|
fileResourceCount: desktopPlan.fileCount + mobilePlan.fileCount
|
|
10576
10845
|
};
|
|
@@ -11565,115 +11834,214 @@ var apply_default$9 = define({
|
|
|
11565
11834
|
}
|
|
11566
11835
|
});
|
|
11567
11836
|
//#endregion
|
|
11568
|
-
//#region src/
|
|
11569
|
-
const
|
|
11570
|
-
const
|
|
11571
|
-
|
|
11572
|
-
|
|
11573
|
-
|
|
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")}`;
|
|
11574
11852
|
}
|
|
11575
|
-
|
|
11576
|
-
|
|
11577
|
-
|
|
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);
|
|
11578
11863
|
}
|
|
11864
|
+
//#endregion
|
|
11865
|
+
//#region src/core/application/customization/customizationDigests.ts
|
|
11579
11866
|
/**
|
|
11580
|
-
*
|
|
11581
|
-
*
|
|
11582
|
-
*
|
|
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}).
|
|
11583
11877
|
*/
|
|
11584
|
-
function
|
|
11585
|
-
|
|
11586
|
-
|
|
11587
|
-
|
|
11588
|
-
|
|
11589
|
-
|
|
11590
|
-
|
|
11591
|
-
|
|
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)
|
|
11592
11898
|
});
|
|
11593
11899
|
}
|
|
11594
|
-
const
|
|
11595
|
-
|
|
11596
|
-
|
|
11597
|
-
}
|
|
11598
|
-
|
|
11599
|
-
|
|
11600
|
-
|
|
11601
|
-
function eq(a, b) {
|
|
11602
|
-
if (isScopeValue(a) || isScopeValue(b)) return isScopeValue(a) && isScopeValue(b) && a.scope === b.scope;
|
|
11603
|
-
return a.tag === b.tag;
|
|
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;
|
|
11604
11907
|
}
|
|
11605
11908
|
/**
|
|
11606
|
-
*
|
|
11909
|
+
* Reads and digests one local file, or reports "unknown" when it cannot be read.
|
|
11607
11910
|
*
|
|
11608
|
-
*
|
|
11609
|
-
*
|
|
11610
|
-
*
|
|
11611
|
-
*
|
|
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.
|
|
11612
11915
|
*/
|
|
11613
|
-
function
|
|
11614
|
-
|
|
11615
|
-
|
|
11916
|
+
async function readLocalDigest(reader, absolutePath) {
|
|
11917
|
+
try {
|
|
11918
|
+
return computeContentDigest(await reader.read(absolutePath));
|
|
11919
|
+
} catch {
|
|
11920
|
+
return;
|
|
11921
|
+
}
|
|
11616
11922
|
}
|
|
11617
11923
|
/**
|
|
11618
|
-
*
|
|
11924
|
+
* Downloads and digests the given remote FILE resources.
|
|
11619
11925
|
*
|
|
11620
|
-
*
|
|
11621
|
-
*
|
|
11622
|
-
*
|
|
11623
|
-
*
|
|
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.
|
|
11624
11932
|
*/
|
|
11625
|
-
function
|
|
11626
|
-
const
|
|
11627
|
-
|
|
11628
|
-
|
|
11629
|
-
|
|
11630
|
-
if (
|
|
11631
|
-
|
|
11632
|
-
|
|
11633
|
-
|
|
11634
|
-
}
|
|
11635
|
-
if (value === void 0) continue;
|
|
11636
|
-
if (isScopeValue(value)) scope = value.scope;
|
|
11637
|
-
else chosen.set(entry.key, value.resource);
|
|
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
|
+
});
|
|
11638
11943
|
}
|
|
11639
|
-
|
|
11944
|
+
const results = await Promise.all(targets.map(async ({ key, fileKey }) => [key, computeContentDigest(await downloader.download(fileKey))]));
|
|
11945
|
+
return new Map(results);
|
|
11640
11946
|
}
|
|
11947
|
+
//#endregion
|
|
11948
|
+
//#region src/core/application/customization/customizationThreeWayMerge.ts
|
|
11641
11949
|
/**
|
|
11642
|
-
*
|
|
11643
|
-
*
|
|
11644
|
-
*
|
|
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.
|
|
11645
11957
|
*/
|
|
11646
|
-
function
|
|
11647
|
-
|
|
11648
|
-
|
|
11649
|
-
|
|
11650
|
-
|
|
11651
|
-
|
|
11652
|
-
|
|
11653
|
-
|
|
11654
|
-
|
|
11655
|
-
|
|
11656
|
-
|
|
11657
|
-
|
|
11658
|
-
|
|
11659
|
-
}
|
|
11660
|
-
};
|
|
11661
|
-
append(platformList(local, platform, category));
|
|
11662
|
-
append(platformList(remote, platform, category));
|
|
11663
|
-
return out;
|
|
11664
|
-
}
|
|
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
|
+
});
|
|
11665
11971
|
return {
|
|
11666
|
-
|
|
11667
|
-
|
|
11668
|
-
|
|
11669
|
-
|
|
11670
|
-
},
|
|
11671
|
-
|
|
11672
|
-
|
|
11673
|
-
|
|
11674
|
-
}
|
|
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)
|
|
11675
11980
|
};
|
|
11676
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
|
+
}) };
|
|
11677
12045
|
//#endregion
|
|
11678
12046
|
//#region src/core/application/customization/customizationRemote.ts
|
|
11679
12047
|
/**
|
|
@@ -11703,59 +12071,6 @@ function toResource(resource) {
|
|
|
11703
12071
|
path: resource.file.name
|
|
11704
12072
|
};
|
|
11705
12073
|
}
|
|
11706
|
-
/**
|
|
11707
|
-
* Computes the set of same-name FILE basenames whose local content differs from
|
|
11708
|
-
* the remote content, across all platforms/categories — the same signal the
|
|
11709
|
-
* 2-way `detectCustomizationDiff` uses for the `modified` entries. Reused here so
|
|
11710
|
-
* the 3-way merge marks divergent same-name files as conflicts.
|
|
11711
|
-
*/
|
|
11712
|
-
async function computeModifiedFileNames(local, remote, basePath, container) {
|
|
11713
|
-
const pairs = [
|
|
11714
|
-
...matchedPairs(local.desktop.js, remote.desktop.js),
|
|
11715
|
-
...matchedPairs(local.desktop.css, remote.desktop.css),
|
|
11716
|
-
...matchedPairs(local.mobile.js, remote.mobile.js),
|
|
11717
|
-
...matchedPairs(local.mobile.css, remote.mobile.css)
|
|
11718
|
-
];
|
|
11719
|
-
const modified = /* @__PURE__ */ new Set();
|
|
11720
|
-
if (pairs.length === 0) return modified;
|
|
11721
|
-
const results = await Promise.all(pairs.map(async ({ localResource, remoteFileKey }) => {
|
|
11722
|
-
const localPath = join(basePath, localResource.path);
|
|
11723
|
-
const [localContent, remoteContent] = await Promise.all([container.fileContentReader.read(localPath), container.fileDownloader.download(remoteFileKey)]);
|
|
11724
|
-
const isEqual = Buffer.from(localContent).equals(Buffer.from(remoteContent));
|
|
11725
|
-
return {
|
|
11726
|
-
name: resourceName(localResource),
|
|
11727
|
-
isEqual
|
|
11728
|
-
};
|
|
11729
|
-
}));
|
|
11730
|
-
for (const { name, isEqual } of results) if (!isEqual) modified.add(name);
|
|
11731
|
-
return modified;
|
|
11732
|
-
}
|
|
11733
|
-
/** Finds matched local-FILE + remote-FILE pairs by basename. */
|
|
11734
|
-
function matchedPairs(localResources, remoteResources) {
|
|
11735
|
-
const remoteFileMap = /* @__PURE__ */ new Map();
|
|
11736
|
-
for (const r of remoteResources) if (r.type === "FILE") remoteFileMap.set(remoteResourceName(r), r.file.fileKey);
|
|
11737
|
-
const pairs = [];
|
|
11738
|
-
for (const local of localResources) {
|
|
11739
|
-
if (local.type !== "FILE") continue;
|
|
11740
|
-
const remoteFileKey = remoteFileMap.get(resourceName(local));
|
|
11741
|
-
if (remoteFileKey !== void 0) pairs.push({
|
|
11742
|
-
localResource: local,
|
|
11743
|
-
remoteFileKey
|
|
11744
|
-
});
|
|
11745
|
-
}
|
|
11746
|
-
return pairs;
|
|
11747
|
-
}
|
|
11748
|
-
//#endregion
|
|
11749
|
-
//#region src/core/domain/customization/services/customizationStateParser.ts
|
|
11750
|
-
/**
|
|
11751
|
-
* Parses pre-parsed (codec-decoded) data into a {@link CustomizationState}.
|
|
11752
|
-
*
|
|
11753
|
-
* The inverse of {@link CustomizationStateSerializer}: the captured config is
|
|
11754
|
-
* parsed via the same {@link CustomizationConfigParser} as the local YAML, so
|
|
11755
|
-
* the base snapshot is validated identically. The domain layer does not depend
|
|
11756
|
-
* on YAML; the application layer handles codec decoding before calling this.
|
|
11757
|
-
*/
|
|
11758
|
-
const CustomizationStateParser = { parse: (parsed) => ({ config: CustomizationConfigParser.parse(parsed) }) };
|
|
11759
12074
|
//#endregion
|
|
11760
12075
|
//#region src/core/application/customization/loadCustomizationThreeWayInputs.ts
|
|
11761
12076
|
/**
|
|
@@ -11769,7 +12084,7 @@ async function loadCustomizationThreeWayInputs(container) {
|
|
|
11769
12084
|
codec: container.configCodec,
|
|
11770
12085
|
stateStorage: container.customizationStateStorage,
|
|
11771
12086
|
appRevisionStorage: container.appRevisionStorage,
|
|
11772
|
-
parseState: (parsed) => CustomizationStateParser.parse(parsed)
|
|
12087
|
+
parseState: (parsed) => CustomizationStateParser.parse(parsed),
|
|
11773
12088
|
stateLabel: "Customization state",
|
|
11774
12089
|
loadLocal: async () => {
|
|
11775
12090
|
const result = await container.customizationStorage.get();
|
|
@@ -11793,17 +12108,58 @@ async function loadCustomizationThreeWayInputs(container) {
|
|
|
11793
12108
|
}
|
|
11794
12109
|
//#endregion
|
|
11795
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
|
+
}
|
|
11796
12152
|
/**
|
|
11797
12153
|
* Detects customization differences with 3-way awareness.
|
|
11798
12154
|
*
|
|
11799
12155
|
* When a state (base snapshot) exists, classifies changes into local-only,
|
|
11800
|
-
* remote drift, and conflicts via the
|
|
11801
|
-
*
|
|
11802
|
-
* existing 2-way `detectCustomizationDiff` (the CLI renders it with its
|
|
11803
|
-
* 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).
|
|
11804
12160
|
*/
|
|
11805
12161
|
async function detectCustomizationThreeWayDiff({ container, input }) {
|
|
11806
|
-
const { state, local, remote } = await loadCustomizationThreeWayInputs(container);
|
|
12162
|
+
const { state, baseRevision, local, remote } = await loadCustomizationThreeWayInputs(container);
|
|
11807
12163
|
if (state === void 0 || local === void 0) return {
|
|
11808
12164
|
mode: "two-way",
|
|
11809
12165
|
diff: await detectCustomizationDiff({
|
|
@@ -11811,8 +12167,15 @@ async function detectCustomizationThreeWayDiff({ container, input }) {
|
|
|
11811
12167
|
input: { basePath: input.basePath }
|
|
11812
12168
|
})
|
|
11813
12169
|
};
|
|
11814
|
-
const
|
|
11815
|
-
|
|
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))));
|
|
11816
12179
|
}
|
|
11817
12180
|
//#endregion
|
|
11818
12181
|
//#region src/cli/commands/customize/diff.ts
|
|
@@ -11867,20 +12230,37 @@ var diff_default$10 = define({
|
|
|
11867
12230
|
* stringification (via the codec port in the application layer).
|
|
11868
12231
|
*
|
|
11869
12232
|
* The snapshot is serialized through the exact same path as `capture`
|
|
11870
|
-
* ({@link CustomizationConfigSerializer})
|
|
11871
|
-
*
|
|
11872
|
-
*
|
|
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`.
|
|
11873
12239
|
*/
|
|
11874
|
-
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
|
+
}) };
|
|
11875
12245
|
//#endregion
|
|
11876
12246
|
//#region src/core/application/customization/customizationStateIo.ts
|
|
11877
12247
|
/**
|
|
11878
|
-
* Persists the new base: the customization snapshot (
|
|
11879
|
-
* revision (`state/<appName>/revision.yaml`) side by side.
|
|
11880
|
-
* kept together so they always advance as a pair
|
|
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.
|
|
11881
12258
|
*/
|
|
11882
|
-
async function saveCustomizationSnapshotAndRevision(container, config, revision) {
|
|
11883
|
-
await saveSnapshotState(container.customizationStateStorage, container.configCodec, CustomizationStateSerializer.serialize, {
|
|
12259
|
+
async function saveCustomizationSnapshotAndRevision(container, config, fileDigests, revision) {
|
|
12260
|
+
await saveSnapshotState(container.customizationStateStorage, container.configCodec, CustomizationStateSerializer.serialize, {
|
|
12261
|
+
config,
|
|
12262
|
+
fileDigests
|
|
12263
|
+
});
|
|
11884
12264
|
await saveAppRevision(container.appRevisionStorage, container.configCodec, revision);
|
|
11885
12265
|
}
|
|
11886
12266
|
//#endregion
|
|
@@ -11897,27 +12277,40 @@ async function saveCustomizationSnapshotAndRevision(container, config, revision)
|
|
|
11897
12277
|
* resolution, so an aborted resolution leaves the working copy untouched.
|
|
11898
12278
|
*/
|
|
11899
12279
|
async function pullCustomization({ container, input }) {
|
|
11900
|
-
const { state, local, remote } = await loadCustomizationThreeWayInputs(container);
|
|
12280
|
+
const { state, baseRevision, local, remote } = await loadCustomizationThreeWayInputs(container);
|
|
11901
12281
|
if (input.force || state === void 0 || local === void 0) {
|
|
12282
|
+
const preservePaths = local !== void 0 ? buildPreservePaths(local) : void 0;
|
|
11902
12283
|
const captured = await captureCustomization({
|
|
11903
12284
|
container,
|
|
11904
12285
|
input: {
|
|
11905
12286
|
basePath: input.captureBasePath,
|
|
11906
|
-
filePrefix: input.filePrefix
|
|
12287
|
+
filePrefix: input.filePrefix,
|
|
12288
|
+
preservePaths
|
|
11907
12289
|
}
|
|
11908
12290
|
});
|
|
11909
12291
|
await container.customizationStorage.update(captured.configText);
|
|
11910
|
-
await
|
|
12292
|
+
const fileDigests = await computeSnapshotFileDigests(captured.config, input.basePath, container.fileContentReader);
|
|
12293
|
+
await saveCustomizationSnapshotAndRevision(container, captured.config, fileDigests, remote.revision);
|
|
11911
12294
|
return { mode: input.force ? "force" : "firstTime" };
|
|
11912
12295
|
}
|
|
11913
|
-
const
|
|
12296
|
+
const { merge, remoteDigests, estimatedKeys, unreadableLocalKeys } = await buildCustomizationThreeWayMerge({
|
|
12297
|
+
container,
|
|
12298
|
+
state,
|
|
12299
|
+
baseRevision,
|
|
12300
|
+
local,
|
|
12301
|
+
remote,
|
|
12302
|
+
basePath: input.basePath
|
|
12303
|
+
});
|
|
11914
12304
|
return {
|
|
11915
12305
|
mode: "merged",
|
|
11916
|
-
merge
|
|
12306
|
+
merge,
|
|
11917
12307
|
local,
|
|
11918
12308
|
remote: remote.raw,
|
|
11919
12309
|
remoteConfig: remote.config,
|
|
11920
|
-
remoteRevision: remote.revision
|
|
12310
|
+
remoteRevision: remote.revision,
|
|
12311
|
+
remoteDigests,
|
|
12312
|
+
estimatedKeys,
|
|
12313
|
+
unreadableLocalKeys
|
|
11921
12314
|
};
|
|
11922
12315
|
}
|
|
11923
12316
|
/**
|
|
@@ -11933,18 +12326,15 @@ async function pullCustomization({ container, input }) {
|
|
|
11933
12326
|
async function applyPulledCustomizationMerge({ container, input }) {
|
|
11934
12327
|
const merged = wrapBusinessRuleError(() => resolveCustomizationMerge(input.merge, input.resolution, input.local, input.remoteConfig));
|
|
11935
12328
|
const fromRemote = /* @__PURE__ */ new Set();
|
|
11936
|
-
for (const entry of input.merge.entries)
|
|
11937
|
-
|
|
11938
|
-
if (name === void 0) continue;
|
|
11939
|
-
if (entry.change.kind === "remoteOnly") fromRemote.add(name);
|
|
11940
|
-
else if (entry.change.kind === "conflict" && input.resolution.get(entry.key) === "remote") fromRemote.add(name);
|
|
11941
|
-
}
|
|
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);
|
|
11942
12331
|
const remoteFileKeys = remoteFileKeyMap(input.remote);
|
|
11943
12332
|
const downloads = [];
|
|
11944
|
-
for (const resource of
|
|
11945
|
-
|
|
11946
|
-
|
|
11947
|
-
|
|
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);
|
|
11948
12338
|
if (fileKey === void 0) continue;
|
|
11949
12339
|
const absolutePath = resolve(input.basePath, resource.path);
|
|
11950
12340
|
downloads.push((async () => {
|
|
@@ -11955,38 +12345,111 @@ async function applyPulledCustomizationMerge({ container, input }) {
|
|
|
11955
12345
|
await Promise.all(downloads);
|
|
11956
12346
|
const configText = stringifyConfig(container.configCodec, CustomizationConfigSerializer.serialize(merged));
|
|
11957
12347
|
await container.customizationStorage.update(configText);
|
|
11958
|
-
await saveCustomizationSnapshotAndRevision(container, input.
|
|
12348
|
+
await saveCustomizationSnapshotAndRevision(container, merged, await buildMergedBaseDigests(merged, input.basePath, input.remoteDigests, container), input.remoteRevision);
|
|
11959
12349
|
}
|
|
11960
|
-
|
|
11961
|
-
|
|
11962
|
-
|
|
11963
|
-
|
|
11964
|
-
|
|
11965
|
-
|
|
11966
|
-
|
|
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;
|
|
11967
12370
|
}
|
|
11968
12371
|
/**
|
|
11969
|
-
*
|
|
11970
|
-
* `platform
|
|
12372
|
+
* Builds the path-preservation map for `captureCustomization`: bucket-qualified
|
|
12373
|
+
* key (`resourceKey(platform, category, FILE basename)`) → declared local
|
|
12374
|
+
* relative path. The composite key matches the merge identity so cross-bucket
|
|
12375
|
+
* same-basename files (e.g. `app/desktop/js/main.js` vs `app/mobile/js/main.js`)
|
|
12376
|
+
* stay distinct instead of colliding under a flat basename map. Only FILE
|
|
12377
|
+
* resources carry a path.
|
|
11971
12378
|
*/
|
|
11972
|
-
function
|
|
11973
|
-
const
|
|
11974
|
-
if (
|
|
11975
|
-
return
|
|
12379
|
+
function buildPreservePaths(config) {
|
|
12380
|
+
const map = /* @__PURE__ */ new Map();
|
|
12381
|
+
for (const { platform, category, resource } of walkCustomizationBuckets(config)) if (resource.type === "FILE") map.set(resourceKey(platform, category, resourceName(resource)), resource.path);
|
|
12382
|
+
return map;
|
|
11976
12383
|
}
|
|
12384
|
+
/**
|
|
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.
|
|
12390
|
+
*/
|
|
11977
12391
|
function remoteFileKeyMap(remote) {
|
|
11978
12392
|
const map = /* @__PURE__ */ new Map();
|
|
11979
|
-
const
|
|
11980
|
-
for (const r of resources) if (r.type === "FILE") map.set(remoteResourceName(r), r.file.fileKey);
|
|
11981
|
-
};
|
|
11982
|
-
add(remote.desktop.js);
|
|
11983
|
-
add(remote.desktop.css);
|
|
11984
|
-
add(remote.mobile.js);
|
|
11985
|
-
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);
|
|
11986
12394
|
return map;
|
|
11987
12395
|
}
|
|
11988
12396
|
//#endregion
|
|
11989
12397
|
//#region src/cli/commands/customize/pull.ts
|
|
12398
|
+
/**
|
|
12399
|
+
* Derives the path bases `runPull` feeds to the application layer.
|
|
12400
|
+
*
|
|
12401
|
+
* - `basePath` (= push's {@link computeBasePath}) is where merged/downloaded
|
|
12402
|
+
* content resolves and push uploads from.
|
|
12403
|
+
* - `captureBasePath` + `filePrefix` are passed to the capture-equivalent
|
|
12404
|
+
* (force / first-time) path.
|
|
12405
|
+
*
|
|
12406
|
+
* Invariant: `join(captureBasePath, filePrefix) === basePath`, so pull writes
|
|
12407
|
+
* exactly where push uploads from. Exported so the invariant test pins these
|
|
12408
|
+
* exact derivations rather than a hand-mirrored copy.
|
|
12409
|
+
*/
|
|
12410
|
+
function derivePullPaths(customizeFilePath) {
|
|
12411
|
+
return {
|
|
12412
|
+
basePath: computeBasePath(customizeFilePath),
|
|
12413
|
+
captureBasePath: dirname(resolve(customizeFilePath)),
|
|
12414
|
+
filePrefix: deriveFilePrefix(customizeFilePath)
|
|
12415
|
+
};
|
|
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
|
+
}
|
|
11990
12453
|
async function resolveConflicts$1(merged, options) {
|
|
11991
12454
|
const resolution = /* @__PURE__ */ new Map();
|
|
11992
12455
|
for (const conflict of merged.merge.conflicts) {
|
|
@@ -11999,7 +12462,7 @@ async function resolveConflicts$1(merged, options) {
|
|
|
11999
12462
|
continue;
|
|
12000
12463
|
}
|
|
12001
12464
|
const selected = await p.select({
|
|
12002
|
-
message:
|
|
12465
|
+
message: buildConflictPrompt(merged, conflict.key),
|
|
12003
12466
|
options: [{
|
|
12004
12467
|
value: "local",
|
|
12005
12468
|
label: "local (ours)"
|
|
@@ -12015,9 +12478,7 @@ async function resolveConflicts$1(merged, options) {
|
|
|
12015
12478
|
}
|
|
12016
12479
|
async function runPull$1(containerConfig, options) {
|
|
12017
12480
|
const container = createCustomizationCliContainer(containerConfig);
|
|
12018
|
-
const basePath =
|
|
12019
|
-
const captureBasePath = dirname(resolve(containerConfig.customizeFilePath));
|
|
12020
|
-
const filePrefix = deriveFilePrefix(containerConfig.customizeFilePath);
|
|
12481
|
+
const { basePath, captureBasePath, filePrefix } = derivePullPaths(containerConfig.customizeFilePath);
|
|
12021
12482
|
const s = p.spinner();
|
|
12022
12483
|
s.start("Pulling customization from kintone...");
|
|
12023
12484
|
let result;
|
|
@@ -12055,7 +12516,8 @@ async function runPull$1(containerConfig, options) {
|
|
|
12055
12516
|
local: result.local,
|
|
12056
12517
|
remote: result.remote,
|
|
12057
12518
|
remoteConfig: result.remoteConfig,
|
|
12058
|
-
remoteRevision: result.remoteRevision
|
|
12519
|
+
remoteRevision: result.remoteRevision,
|
|
12520
|
+
remoteDigests: result.remoteDigests
|
|
12059
12521
|
}
|
|
12060
12522
|
});
|
|
12061
12523
|
p.log.success("Local customization merged and written.");
|
|
@@ -12077,7 +12539,8 @@ async function runPull$1(containerConfig, options) {
|
|
|
12077
12539
|
local: result.local,
|
|
12078
12540
|
remote: result.remote,
|
|
12079
12541
|
remoteConfig: result.remoteConfig,
|
|
12080
|
-
remoteRevision: result.remoteRevision
|
|
12542
|
+
remoteRevision: result.remoteRevision,
|
|
12543
|
+
remoteDigests: result.remoteDigests
|
|
12081
12544
|
}
|
|
12082
12545
|
});
|
|
12083
12546
|
p.log.success("Conflicts resolved. Local customization written.");
|
|
@@ -12131,6 +12594,9 @@ var pull_default$10 = define({
|
|
|
12131
12594
|
//#region src/core/application/customization/pushCustomization.ts
|
|
12132
12595
|
/** Pull command name surfaced in the drift hint message. */
|
|
12133
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
|
+
}
|
|
12134
12600
|
async function resolvePlatform(platform, basePath, fileUploader) {
|
|
12135
12601
|
const resolveList = async (resources) => Promise.all(resources.map(async (resource) => {
|
|
12136
12602
|
if (resource.type === "URL") return resource;
|
|
@@ -12151,10 +12617,11 @@ async function resolvePlatform(platform, basePath, fileUploader) {
|
|
|
12151
12617
|
/**
|
|
12152
12618
|
* Applies the local customization config to the remote with drift detection.
|
|
12153
12619
|
*
|
|
12154
|
-
* - Loads base/local/remote and computes the
|
|
12155
|
-
* (remoteOnly or conflict entries) && !force → {@link buildDriftConflict}
|
|
12156
|
-
* tagged with `ConfigDrift`.
|
|
12157
|
-
*
|
|
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.
|
|
12158
12625
|
* - Replaces the full js/css lists with the local config's resolved (uploaded)
|
|
12159
12626
|
* resources via `updateAppCustomize`, so renames are expressed as old-name
|
|
12160
12627
|
* removal + new-name add (full-list replace — NOT an inexpressible
|
|
@@ -12165,12 +12632,25 @@ async function resolvePlatform(platform, basePath, fileUploader) {
|
|
|
12165
12632
|
* post-apply revision as the new base (snapshot → revision).
|
|
12166
12633
|
*/
|
|
12167
12634
|
async function pushCustomization({ container, input }) {
|
|
12168
|
-
const { state, local, remote } = await loadCustomizationThreeWayInputs(container);
|
|
12635
|
+
const { state, baseRevision, local, remote } = await loadCustomizationThreeWayInputs(container);
|
|
12169
12636
|
if (local === void 0) throw new ValidationError(ValidationErrorCode.InvalidInput, "Customization config file not found");
|
|
12170
12637
|
const firstTime = state === void 0;
|
|
12171
|
-
|
|
12172
|
-
|
|
12173
|
-
|
|
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
|
+
}
|
|
12174
12654
|
}
|
|
12175
12655
|
const [desktop, mobile] = await Promise.all([resolvePlatform(local.desktop, input.basePath, container.fileUploader), resolvePlatform(local.mobile, input.basePath, container.fileUploader)]);
|
|
12176
12656
|
ResourceMerger.assertResourceCount("desktop.js", desktop.js);
|
|
@@ -12194,7 +12674,7 @@ async function pushCustomization({ container, input }) {
|
|
|
12194
12674
|
scope: local.scope,
|
|
12195
12675
|
desktop: local.desktop,
|
|
12196
12676
|
mobile: local.mobile
|
|
12197
|
-
}, newRevision);
|
|
12677
|
+
}, localDigests, newRevision);
|
|
12198
12678
|
return {
|
|
12199
12679
|
mode: firstTime ? "firstTime" : "push",
|
|
12200
12680
|
revision: newRevision
|
|
@@ -15600,7 +16080,8 @@ function buildTasks(args) {
|
|
|
15600
16080
|
local: result.local,
|
|
15601
16081
|
remote: result.remote,
|
|
15602
16082
|
remoteConfig: result.remoteConfig,
|
|
15603
|
-
remoteRevision: result.remoteRevision
|
|
16083
|
+
remoteRevision: result.remoteRevision,
|
|
16084
|
+
remoteDigests: result.remoteDigests
|
|
15604
16085
|
}
|
|
15605
16086
|
});
|
|
15606
16087
|
return "merged";
|