kintone-migrator 0.34.5 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -6
- package/dist/index.mjs +682 -203
- 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) {
|
|
@@ -1323,6 +1324,10 @@ var KintoneFileUploader = class {
|
|
|
1323
1324
|
*/
|
|
1324
1325
|
const SKIP_REVISION_CHECK = Symbol("skip-revision-check");
|
|
1325
1326
|
//#endregion
|
|
1327
|
+
//#region src/core/domain/formSchema/ports/formReadTarget.ts
|
|
1328
|
+
/** The generation read when a caller does not specify a target. */
|
|
1329
|
+
const DEFAULT_FORM_READ_TARGET = "preview";
|
|
1330
|
+
//#endregion
|
|
1326
1331
|
//#region src/lib/charValidation.ts
|
|
1327
1332
|
/** Returns true if the string contains any control characters (0x00–0x1f or 0x7f). */
|
|
1328
1333
|
function hasControlChars(s) {
|
|
@@ -1676,6 +1681,14 @@ function toKintoneLayoutItem(item) {
|
|
|
1676
1681
|
default: throw new SystemError(SystemErrorCode.ExternalApiError, `Unknown layout item type: ${item.type}`);
|
|
1677
1682
|
}
|
|
1678
1683
|
}
|
|
1684
|
+
const GET_FIELDS_FAILURE_MESSAGES = {
|
|
1685
|
+
preview: "Failed to get form fields",
|
|
1686
|
+
published: "Failed to get published form fields (the app may not be deployed yet, or the credentials may not be allowed to read it)"
|
|
1687
|
+
};
|
|
1688
|
+
const GET_LAYOUT_FAILURE_MESSAGES = {
|
|
1689
|
+
preview: "Failed to get form layout",
|
|
1690
|
+
published: "Failed to get published form layout (the app may not be deployed yet, or the credentials may not be allowed to read it)"
|
|
1691
|
+
};
|
|
1679
1692
|
var KintoneFormConfigurator = class {
|
|
1680
1693
|
revisionTracker;
|
|
1681
1694
|
constructor(client, appId, revisionTracker) {
|
|
@@ -1683,13 +1696,14 @@ var KintoneFormConfigurator = class {
|
|
|
1683
1696
|
this.appId = appId;
|
|
1684
1697
|
this.revisionTracker = revisionTracker ?? new RevisionTracker();
|
|
1685
1698
|
}
|
|
1686
|
-
async getFields() {
|
|
1699
|
+
async getFields(target = DEFAULT_FORM_READ_TARGET) {
|
|
1700
|
+
const preview = target === "preview";
|
|
1687
1701
|
try {
|
|
1688
1702
|
const { properties, revision } = await this.client.app.getFormFields({
|
|
1689
1703
|
app: this.appId,
|
|
1690
|
-
preview
|
|
1704
|
+
preview
|
|
1691
1705
|
});
|
|
1692
|
-
if (revision) this.revisionTracker.track(revision);
|
|
1706
|
+
if (revision && target === "preview") this.revisionTracker.track(revision);
|
|
1693
1707
|
const fields = /* @__PURE__ */ new Map();
|
|
1694
1708
|
for (const [code, prop] of Object.entries(properties)) {
|
|
1695
1709
|
const kintoneProp = prop;
|
|
@@ -1700,7 +1714,7 @@ var KintoneFormConfigurator = class {
|
|
|
1700
1714
|
}
|
|
1701
1715
|
return fields;
|
|
1702
1716
|
} catch (error) {
|
|
1703
|
-
throw wrapKintoneError(error,
|
|
1717
|
+
throw wrapKintoneError(error, GET_FIELDS_FAILURE_MESSAGES[target]);
|
|
1704
1718
|
}
|
|
1705
1719
|
}
|
|
1706
1720
|
async getRevision() {
|
|
@@ -1763,16 +1777,17 @@ var KintoneFormConfigurator = class {
|
|
|
1763
1777
|
throw wrapKintoneError(error, "Failed to delete form fields");
|
|
1764
1778
|
}
|
|
1765
1779
|
}
|
|
1766
|
-
async getLayout() {
|
|
1780
|
+
async getLayout(target = DEFAULT_FORM_READ_TARGET) {
|
|
1781
|
+
const preview = target === "preview";
|
|
1767
1782
|
try {
|
|
1768
1783
|
const response = await this.client.app.getFormLayout({
|
|
1769
1784
|
app: this.appId,
|
|
1770
|
-
preview
|
|
1785
|
+
preview
|
|
1771
1786
|
});
|
|
1772
|
-
if (response.revision) this.revisionTracker.track(response.revision);
|
|
1787
|
+
if (response.revision && target === "preview") this.revisionTracker.track(response.revision);
|
|
1773
1788
|
return response.layout.map(fromKintoneLayoutItem);
|
|
1774
1789
|
} catch (error) {
|
|
1775
|
-
throw wrapKintoneError(error,
|
|
1790
|
+
throw wrapKintoneError(error, GET_LAYOUT_FAILURE_MESSAGES[target]);
|
|
1776
1791
|
}
|
|
1777
1792
|
}
|
|
1778
1793
|
async updateLayout(layout, expectedRevision) {
|
|
@@ -2705,6 +2720,13 @@ function printCustomizationDiffResult(result) {
|
|
|
2705
2720
|
return `${colorize(prefix)} ${pc.dim("[")}${colorize(location)}${pc.dim("]")} ${entry.name}${pc.dim(":")} ${entry.details}`;
|
|
2706
2721
|
});
|
|
2707
2722
|
}
|
|
2723
|
+
function printSchemaDiffTarget(target) {
|
|
2724
|
+
if (target === "published") {
|
|
2725
|
+
p.log.info("Comparing against the published (deployed) form (2-way; base snapshot not used).");
|
|
2726
|
+
return;
|
|
2727
|
+
}
|
|
2728
|
+
p.log.info("Comparing against the preview (unpublished) form.");
|
|
2729
|
+
}
|
|
2708
2730
|
function printDiffResult(result) {
|
|
2709
2731
|
if (result.isEmpty) {
|
|
2710
2732
|
p.log.info("No changes detected.");
|
|
@@ -2739,6 +2761,7 @@ function printThreeWayDiffResult(result, printTwoWay) {
|
|
|
2739
2761
|
printTwoWay(result.diff);
|
|
2740
2762
|
return;
|
|
2741
2763
|
}
|
|
2764
|
+
for (const note of result.notes ?? []) p.log.warn(note);
|
|
2742
2765
|
if (result.isEmpty) {
|
|
2743
2766
|
p.log.info("No changes detected (local, remote, and base are in sync).");
|
|
2744
2767
|
return;
|
|
@@ -3443,7 +3466,7 @@ function resolveActionMerge(merge, resolution) {
|
|
|
3443
3466
|
* record-keyed merge (view / report / action / plugin). The caller supplies a
|
|
3444
3467
|
* `label` extractor (e.g. the view's name) for display.
|
|
3445
3468
|
*/
|
|
3446
|
-
function buildRecordThreeWayDiff(merge, label, extras = []) {
|
|
3469
|
+
function buildRecordThreeWayDiff(merge, label, extras = [], notes = []) {
|
|
3447
3470
|
const localChanges = [];
|
|
3448
3471
|
const remoteDrift = [];
|
|
3449
3472
|
const conflicts = [];
|
|
@@ -3471,13 +3494,15 @@ function buildRecordThreeWayDiff(merge, label, extras = []) {
|
|
|
3471
3494
|
break;
|
|
3472
3495
|
default: break;
|
|
3473
3496
|
}
|
|
3497
|
+
const isEmpty = localChanges.length === 0 && remoteDrift.length === 0 && conflicts.length === 0 && extras.length === 0;
|
|
3474
3498
|
return {
|
|
3475
3499
|
mode: "three-way",
|
|
3476
3500
|
localChanges,
|
|
3477
3501
|
remoteDrift,
|
|
3478
3502
|
conflicts,
|
|
3479
3503
|
extras,
|
|
3480
|
-
|
|
3504
|
+
...notes.length > 0 ? { notes } : {},
|
|
3505
|
+
isEmpty
|
|
3481
3506
|
};
|
|
3482
3507
|
}
|
|
3483
3508
|
/**
|
|
@@ -3606,7 +3631,7 @@ async function saveSnapshotState(storage, codec, serialize, snapshot) {
|
|
|
3606
3631
|
* Loads the base snapshot, the base app revision, the local config, and the
|
|
3607
3632
|
* remote config in parallel for a 3-way sync. Generic over the snapshot
|
|
3608
3633
|
* (`TSnapshot`) and remote (`TRemote`) shapes so every config domain reuses one
|
|
3609
|
-
* loader
|
|
3634
|
+
* loader.
|
|
3610
3635
|
*/
|
|
3611
3636
|
async function loadThreeWayInputs$1(args) {
|
|
3612
3637
|
const [state, appRevision, local, remote] = await Promise.all([
|
|
@@ -3980,9 +4005,13 @@ function buildDriftMessage(pullCommand) {
|
|
|
3980
4005
|
* The CLI distinguishes this snapshot drift from API optimistic-lock (TOCTOU)
|
|
3981
4006
|
* conflicts by the `ConfigDrift` code, not by the message string, so the
|
|
3982
4007
|
* message stays a free-form, domain-specific hint.
|
|
4008
|
+
*
|
|
4009
|
+
* `extraHint` appends a domain-specific caveat (e.g. customization's "this
|
|
4010
|
+
* drift may be inferred") on its own line; omitted, the message is unchanged.
|
|
3983
4011
|
*/
|
|
3984
|
-
function buildDriftConflict(pullCommand) {
|
|
3985
|
-
|
|
4012
|
+
function buildDriftConflict(pullCommand, extraHint) {
|
|
4013
|
+
const message = buildDriftMessage(pullCommand);
|
|
4014
|
+
return new ConflictError(ConflictErrorCode.ConfigDrift, extraHint === void 0 ? message : `${message}\n${extraHint}`);
|
|
3986
4015
|
}
|
|
3987
4016
|
//#endregion
|
|
3988
4017
|
//#region src/core/application/action/pushAction.ts
|
|
@@ -9164,11 +9193,12 @@ function toFieldDto(field) {
|
|
|
9164
9193
|
properties: fieldPropertiesToDto(field)
|
|
9165
9194
|
};
|
|
9166
9195
|
}
|
|
9167
|
-
async function detectDiff({ container }) {
|
|
9196
|
+
async function detectDiff({ container, input }) {
|
|
9197
|
+
const target = input?.target ?? "preview";
|
|
9168
9198
|
const result = await container.schemaStorage.get();
|
|
9169
9199
|
if (!result.exists) throw new ValidationError(ValidationErrorCode.InvalidInput, "Schema file not found");
|
|
9170
9200
|
const schema = parseSchemaText(container.configCodec, result.content);
|
|
9171
|
-
const [currentFields, currentLayout] = await Promise.all([container.formConfigurator.getFields(), container.formConfigurator.getLayout()]);
|
|
9201
|
+
const [currentFields, currentLayout] = await Promise.all([container.formConfigurator.getFields(target), container.formConfigurator.getLayout(target)]);
|
|
9172
9202
|
const diff = DiffDetector.detect(schema, currentFields);
|
|
9173
9203
|
const enrichedCurrentLayout = enrichLayoutWithFields(currentLayout, currentFields);
|
|
9174
9204
|
const hasLayoutChanges = DiffDetector.detectLayoutChanges(schema.layout, enrichedCurrentLayout);
|
|
@@ -10398,63 +10428,130 @@ function createCliCaptureContainers(input) {
|
|
|
10398
10428
|
}
|
|
10399
10429
|
//#endregion
|
|
10400
10430
|
//#region src/core/domain/customization/services/configSerializer.ts
|
|
10401
|
-
function serializeResource(resource) {
|
|
10402
|
-
|
|
10431
|
+
function serializeResource(resource, platform, category, decorate) {
|
|
10432
|
+
const serialized = resource.type === "FILE" ? {
|
|
10403
10433
|
type: "FILE",
|
|
10404
10434
|
path: resource.path
|
|
10405
|
-
}
|
|
10406
|
-
return {
|
|
10435
|
+
} : {
|
|
10407
10436
|
type: "URL",
|
|
10408
10437
|
url: resource.url
|
|
10409
10438
|
};
|
|
10439
|
+
const extra = decorate?.(platform, category, resource);
|
|
10440
|
+
return extra === void 0 ? serialized : {
|
|
10441
|
+
...extra,
|
|
10442
|
+
...serialized
|
|
10443
|
+
};
|
|
10410
10444
|
}
|
|
10411
|
-
function serializeResourceList(resources) {
|
|
10412
|
-
return resources.map(serializeResource);
|
|
10445
|
+
function serializeResourceList(resources, platform, category, decorate) {
|
|
10446
|
+
return resources.map((resource) => serializeResource(resource, platform, category, decorate));
|
|
10413
10447
|
}
|
|
10414
10448
|
function hasPlatformResources(platform) {
|
|
10415
10449
|
return platform.js.length > 0 || platform.css.length > 0;
|
|
10416
10450
|
}
|
|
10417
|
-
function serializePlatform(platform) {
|
|
10451
|
+
function serializePlatform(platform, platformName, decorate) {
|
|
10418
10452
|
return {
|
|
10419
|
-
...platform.js.length > 0 ? { js: serializeResourceList(platform.js) } : {},
|
|
10420
|
-
...platform.css.length > 0 ? { css: serializeResourceList(platform.css) } : {}
|
|
10453
|
+
...platform.js.length > 0 ? { js: serializeResourceList(platform.js, platformName, "js", decorate) } : {},
|
|
10454
|
+
...platform.css.length > 0 ? { css: serializeResourceList(platform.css, platformName, "css", decorate) } : {}
|
|
10421
10455
|
};
|
|
10422
10456
|
}
|
|
10423
|
-
const CustomizationConfigSerializer = { serialize: (config) => {
|
|
10457
|
+
const CustomizationConfigSerializer = { serialize: (config, decorate) => {
|
|
10424
10458
|
return {
|
|
10425
10459
|
...config.scope !== void 0 ? { scope: config.scope } : {},
|
|
10426
|
-
...hasPlatformResources(config.desktop) ? { desktop: serializePlatform(config.desktop) } : {},
|
|
10427
|
-
...hasPlatformResources(config.mobile) ? { mobile: serializePlatform(config.mobile) } : {}
|
|
10460
|
+
...hasPlatformResources(config.desktop) ? { desktop: serializePlatform(config.desktop, "desktop", decorate) } : {},
|
|
10461
|
+
...hasPlatformResources(config.mobile) ? { mobile: serializePlatform(config.mobile, "mobile", decorate) } : {}
|
|
10428
10462
|
};
|
|
10429
10463
|
} };
|
|
10430
10464
|
//#endregion
|
|
10431
10465
|
//#region src/core/domain/customization/services/customizationMerge.ts
|
|
10466
|
+
/**
|
|
10467
|
+
* Per-side placeholder for "no content digest for this key". Distinct per side
|
|
10468
|
+
* so an untracked key never compares equal across sides by accident.
|
|
10469
|
+
*/
|
|
10470
|
+
const UNTRACKED = {
|
|
10471
|
+
base: "untracked:base",
|
|
10472
|
+
local: "untracked:local",
|
|
10473
|
+
remote: "untracked:remote"
|
|
10474
|
+
};
|
|
10475
|
+
/**
|
|
10476
|
+
* The bucket space every walk over a customization config must agree on.
|
|
10477
|
+
*
|
|
10478
|
+
* Shared rather than redeclared per module: the merge keys, the snapshot's
|
|
10479
|
+
* digest keys and the locally/remotely computed digest keys are only comparable
|
|
10480
|
+
* while they enumerate the same buckets, and a bucket missing from one walk
|
|
10481
|
+
* would not be a type error — it would silently leave digests unrecorded and
|
|
10482
|
+
* degrade the classification to an estimate.
|
|
10483
|
+
*/
|
|
10432
10484
|
const PLATFORMS = ["desktop", "mobile"];
|
|
10433
10485
|
const CATEGORIES = ["js", "css"];
|
|
10486
|
+
/**
|
|
10487
|
+
* The merge key holding the customization scope rather than a resource.
|
|
10488
|
+
*
|
|
10489
|
+
* Exported so every consumer branches on the same literal: the CLI's conflict
|
|
10490
|
+
* prompt tells scope conflicts and file conflicts apart by this key alone, and a
|
|
10491
|
+
* private copy drifting from this one would describe overwriting a file that
|
|
10492
|
+
* does not exist.
|
|
10493
|
+
*/
|
|
10434
10494
|
const SCOPE_KEY = "config:scope";
|
|
10435
10495
|
/**
|
|
10436
10496
|
* Bucket-qualified resource identity: `platform:category:name`. Shared with the
|
|
10437
10497
|
* force/firstTime pull path (`preservePaths`) so path preservation matches the
|
|
10438
10498
|
* merge on the same identity and cross-bucket same-basename files stay distinct.
|
|
10499
|
+
*
|
|
10500
|
+
* The bucket parameters are the bucket types rather than `string` so a typo or a
|
|
10501
|
+
* stale literal at a call site is a compile error instead of a key nobody ever
|
|
10502
|
+
* looks up.
|
|
10439
10503
|
*/
|
|
10440
10504
|
function resourceKey(platform, category, name) {
|
|
10441
10505
|
return `${platform}:${category}:${name}`;
|
|
10442
10506
|
}
|
|
10507
|
+
/**
|
|
10508
|
+
* The {@link resourceKey} of a remote FILE, from the name kintone reports.
|
|
10509
|
+
*
|
|
10510
|
+
* The remote name is normalized exactly like a local declared path, because the
|
|
10511
|
+
* remote config the merge is built from carries `file.name` as its `path`: a
|
|
10512
|
+
* name containing `/` becomes its trailing segment there, and a lookup keyed by
|
|
10513
|
+
* the raw name would miss the very entry it describes.
|
|
10514
|
+
*/
|
|
10515
|
+
function remoteFileResourceKey(platform, category, fileName) {
|
|
10516
|
+
return resourceKey(platform, category, resourceName({
|
|
10517
|
+
type: "FILE",
|
|
10518
|
+
path: fileName
|
|
10519
|
+
}));
|
|
10520
|
+
}
|
|
10521
|
+
/**
|
|
10522
|
+
* Walks every (platform, category) bucket, tagging each resource with the bucket
|
|
10523
|
+
* it belongs to. Generic over the resource so the local config, the raw remote
|
|
10524
|
+
* shape and a merged config are all walked by the same code.
|
|
10525
|
+
*
|
|
10526
|
+
* Key-producing walks over a typed config go through here: a hand-written
|
|
10527
|
+
* enumeration that misses a bucket is not a type error, and the symptom —
|
|
10528
|
+
* digests silently left unrecorded, downloads silently skipped — surfaces far
|
|
10529
|
+
* from the cause. Walks over raw `unknown` data (the state parser) cannot be
|
|
10530
|
+
* typed as {@link BucketedResources} and instead iterate {@link PLATFORMS} and
|
|
10531
|
+
* {@link CATEGORIES} directly, which keeps them on the same bucket space.
|
|
10532
|
+
*/
|
|
10533
|
+
function* walkCustomizationBuckets(resources) {
|
|
10534
|
+
for (const platform of PLATFORMS) for (const category of CATEGORIES) for (const resource of resources[platform][category]) yield {
|
|
10535
|
+
platform,
|
|
10536
|
+
category,
|
|
10537
|
+
resource
|
|
10538
|
+
};
|
|
10539
|
+
}
|
|
10443
10540
|
function platformList(config, platform, category) {
|
|
10444
10541
|
const p = config[platform];
|
|
10445
10542
|
return category === "js" ? p.js : p.css;
|
|
10446
10543
|
}
|
|
10447
10544
|
/**
|
|
10448
|
-
* Builds the per-side map keyed by `platform:category:name`.
|
|
10449
|
-
*
|
|
10450
|
-
*
|
|
10545
|
+
* Builds the per-side map keyed by `platform:category:name`. A FILE's tag is its
|
|
10546
|
+
* content digest for this side, falling back to the side's untracked sentinel so
|
|
10547
|
+
* an unknown content never compares equal to another side's unknown content.
|
|
10451
10548
|
*/
|
|
10452
|
-
function toMap(config,
|
|
10549
|
+
function toMap(config, tags, side) {
|
|
10453
10550
|
const map = /* @__PURE__ */ new Map();
|
|
10454
|
-
for (const platform
|
|
10455
|
-
const
|
|
10456
|
-
const tag = resource.type === "FILE" ? `FILE:${
|
|
10457
|
-
map.set(
|
|
10551
|
+
for (const { platform, category, resource } of walkCustomizationBuckets(config)) {
|
|
10552
|
+
const key = resourceKey(platform, category, resourceName(resource));
|
|
10553
|
+
const tag = resource.type === "FILE" ? `FILE:${tags.get(key) ?? UNTRACKED[side]}` : "URL";
|
|
10554
|
+
map.set(key, {
|
|
10458
10555
|
resource,
|
|
10459
10556
|
tag
|
|
10460
10557
|
});
|
|
@@ -10463,6 +10560,12 @@ function toMap(config, contentTag) {
|
|
|
10463
10560
|
map.set(SCOPE_KEY, { scope });
|
|
10464
10561
|
return map;
|
|
10465
10562
|
}
|
|
10563
|
+
/** The `resourceKey` set of every FILE resource in a config. */
|
|
10564
|
+
function fileResourceKeys(config) {
|
|
10565
|
+
const keys = /* @__PURE__ */ new Set();
|
|
10566
|
+
for (const { platform, category, resource } of walkCustomizationBuckets(config)) if (resource.type === "FILE") keys.add(resourceKey(platform, category, resourceName(resource)));
|
|
10567
|
+
return keys;
|
|
10568
|
+
}
|
|
10466
10569
|
function isScopeValue(v) {
|
|
10467
10570
|
return "scope" in v;
|
|
10468
10571
|
}
|
|
@@ -10471,16 +10574,69 @@ function eq(a, b) {
|
|
|
10471
10574
|
return a.tag === b.tag;
|
|
10472
10575
|
}
|
|
10473
10576
|
/**
|
|
10474
|
-
* Computes the
|
|
10577
|
+
* Computes the resource-key-keyed 3-way merge of base/local/remote customization.
|
|
10475
10578
|
*
|
|
10476
|
-
* `
|
|
10477
|
-
*
|
|
10478
|
-
*
|
|
10479
|
-
*
|
|
10579
|
+
* `tags` carries each side's FILE content tokens (digests, or the base tags
|
|
10580
|
+
* produced by {@link resolveBaseFileDigests}). A key whose base tag equals the
|
|
10581
|
+
* local tag but not the remote tag is `remoteOnly`, and vice versa — so editing
|
|
10582
|
+
* a file locally is a local change, not a conflict.
|
|
10480
10583
|
*/
|
|
10481
|
-
function computeCustomizationThreeWayMerge(base, local, remote,
|
|
10482
|
-
|
|
10483
|
-
|
|
10584
|
+
function computeCustomizationThreeWayMerge(base, local, remote, tags) {
|
|
10585
|
+
return classifyThreeWay(toMap(base, tags.base, "base"), toMap(local, tags.local, "local"), toMap(remote, tags.remote, "remote"), eq);
|
|
10586
|
+
}
|
|
10587
|
+
/**
|
|
10588
|
+
* Determines the base-side comparison tag of every FILE key in the base
|
|
10589
|
+
* snapshot, filling in the keys whose digest was never recorded.
|
|
10590
|
+
*
|
|
10591
|
+
* Rules, highest priority first:
|
|
10592
|
+
*
|
|
10593
|
+
* 1. a recorded digest wins — the classification is exact;
|
|
10594
|
+
* 2. otherwise, for a legacy state (no digest at all) whose app revision has not
|
|
10595
|
+
* moved, the remote digest is assumed to still be the base content, so a
|
|
10596
|
+
* purely local edit is `localOnly` instead of a spurious conflict;
|
|
10597
|
+
* 3. otherwise, when the key exists on exactly one side, the base takes that
|
|
10598
|
+
* side's tag, so the classification is decided by existence alone;
|
|
10599
|
+
* 4. otherwise, when both sides carry the same tag, the base takes it
|
|
10600
|
+
* (`unchanged`);
|
|
10601
|
+
* 5. otherwise the base gets its own sentinel — both sides diverged
|
|
10602
|
+
* (`conflict`), or the key exists on neither side (`bothSame`).
|
|
10603
|
+
*
|
|
10604
|
+
* Rules 3-5 decide from the two sides alone, which is all a snapshot without
|
|
10605
|
+
* digests allows.
|
|
10606
|
+
*/
|
|
10607
|
+
function resolveBaseFileDigests(baseConfig, baseDigests, localDigests, remoteDigests, options) {
|
|
10608
|
+
const tags = /* @__PURE__ */ new Map();
|
|
10609
|
+
const optimistic = /* @__PURE__ */ new Set();
|
|
10610
|
+
const conservative = /* @__PURE__ */ new Set();
|
|
10611
|
+
for (const key of fileResourceKeys(baseConfig)) {
|
|
10612
|
+
const recorded = baseDigests.get(key);
|
|
10613
|
+
if (recorded !== void 0) {
|
|
10614
|
+
tags.set(key, recorded);
|
|
10615
|
+
continue;
|
|
10616
|
+
}
|
|
10617
|
+
const inLocal = options.localKeys.has(key);
|
|
10618
|
+
const inRemote = options.remoteKeys.has(key);
|
|
10619
|
+
const classifiable = inLocal || inRemote;
|
|
10620
|
+
const remoteDigest = remoteDigests.get(key);
|
|
10621
|
+
if (options.legacyState && options.remoteUnchanged && remoteDigest !== void 0) {
|
|
10622
|
+
tags.set(key, remoteDigest);
|
|
10623
|
+
if (classifiable) optimistic.add(key);
|
|
10624
|
+
continue;
|
|
10625
|
+
}
|
|
10626
|
+
const localTag = localDigests.get(key) ?? UNTRACKED.local;
|
|
10627
|
+
const remoteTag = remoteDigest ?? UNTRACKED.remote;
|
|
10628
|
+
if (inLocal !== inRemote) tags.set(key, inLocal ? localTag : remoteTag);
|
|
10629
|
+
else if (inLocal && localTag === remoteTag) tags.set(key, localTag);
|
|
10630
|
+
else tags.set(key, UNTRACKED.base);
|
|
10631
|
+
if (classifiable) conservative.add(key);
|
|
10632
|
+
}
|
|
10633
|
+
return {
|
|
10634
|
+
tags,
|
|
10635
|
+
estimatedKeys: {
|
|
10636
|
+
optimistic,
|
|
10637
|
+
conservative
|
|
10638
|
+
}
|
|
10639
|
+
};
|
|
10484
10640
|
}
|
|
10485
10641
|
/**
|
|
10486
10642
|
* Applies a resolved 3-way merge, returning the merged customization config.
|
|
@@ -10597,8 +10753,6 @@ function sanitizeFileName(name) {
|
|
|
10597
10753
|
* Unlike {@link deduplicateName} from `@/lib/deduplicateName`, this function is
|
|
10598
10754
|
* file-name-aware: it inserts the counter before the extension (e.g. `file_1.js`)
|
|
10599
10755
|
* 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
10756
|
*/
|
|
10603
10757
|
function deduplicateFileName(baseName, usedNames) {
|
|
10604
10758
|
if (!usedNames.has(baseName)) {
|
|
@@ -10633,7 +10787,7 @@ function planResources(resources, platformName, platformDir, resourceType, relat
|
|
|
10633
10787
|
});
|
|
10634
10788
|
else {
|
|
10635
10789
|
const fileName = deduplicateFileName(sanitizeFileName(resource.file.name), usedNames);
|
|
10636
|
-
const declaredPath = preservePaths?.get(
|
|
10790
|
+
const declaredPath = preservePaths?.get(remoteFileResourceKey(platformName, resourceType, resource.file.name));
|
|
10637
10791
|
if (declaredPath !== void 0) {
|
|
10638
10792
|
planned.push({
|
|
10639
10793
|
type: "FILE",
|
|
@@ -10642,7 +10796,7 @@ function planResources(resources, platformName, platformDir, resourceType, relat
|
|
|
10642
10796
|
filesToDownload.push({
|
|
10643
10797
|
fileName,
|
|
10644
10798
|
fileKey: resource.file.fileKey,
|
|
10645
|
-
absolutePath:
|
|
10799
|
+
absolutePath: resolve(contentBase, declaredPath)
|
|
10646
10800
|
});
|
|
10647
10801
|
} else {
|
|
10648
10802
|
const absolutePath = join(dir, fileName);
|
|
@@ -11702,6 +11856,215 @@ var apply_default$9 = define({
|
|
|
11702
11856
|
}
|
|
11703
11857
|
});
|
|
11704
11858
|
//#endregion
|
|
11859
|
+
//#region src/lib/contentDigest.ts
|
|
11860
|
+
const PREFIX = "sha256:";
|
|
11861
|
+
const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
11862
|
+
/**
|
|
11863
|
+
* Computes the digest of raw bytes.
|
|
11864
|
+
*
|
|
11865
|
+
* The bytes are hashed as-is: no newline / BOM / trailing-newline normalization.
|
|
11866
|
+
* kintone stores the uploaded byte sequence verbatim, so normalizing here would
|
|
11867
|
+
* report "no changes" for content that actually changes once uploaded. A
|
|
11868
|
+
* zero-byte input is a normal input and yields the sha256 of the empty string,
|
|
11869
|
+
* so "no digest recorded" stays distinguishable from "the content is empty".
|
|
11870
|
+
*/
|
|
11871
|
+
function computeContentDigest(data) {
|
|
11872
|
+
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
11873
|
+
return `${PREFIX}${createHash("sha256").update(bytes).digest("hex")}`;
|
|
11874
|
+
}
|
|
11875
|
+
/**
|
|
11876
|
+
* Narrows an untrusted value (e.g. a parsed state file) to a {@link ContentDigest}.
|
|
11877
|
+
*
|
|
11878
|
+
* The full `sha256:<64 lowercase hex chars>` shape is enforced, not just the
|
|
11879
|
+
* prefix: state files are hand-editable, and a truncated or otherwise malformed
|
|
11880
|
+
* digest would be adopted as a comparison token and silently report the remote
|
|
11881
|
+
* as changed. Every value {@link computeContentDigest} produces matches.
|
|
11882
|
+
*/
|
|
11883
|
+
function isContentDigest(value) {
|
|
11884
|
+
return typeof value === "string" && DIGEST_PATTERN.test(value);
|
|
11885
|
+
}
|
|
11886
|
+
//#endregion
|
|
11887
|
+
//#region src/core/application/customization/customizationDigests.ts
|
|
11888
|
+
/**
|
|
11889
|
+
* Digests the given local FILE resources.
|
|
11890
|
+
*
|
|
11891
|
+
* Paths resolve with `resolve(basePath, path)` — the same resolution push
|
|
11892
|
+
* uploads from and pull writes downloads to — so the digest always describes the
|
|
11893
|
+
* bytes that actually travel to kintone, including when an absolute path is
|
|
11894
|
+
* declared.
|
|
11895
|
+
*
|
|
11896
|
+
* `keys` restricts the read to the resource keys the caller needs; keys whose
|
|
11897
|
+
* body cannot be read are simply absent from the result (see
|
|
11898
|
+
* {@link readLocalDigest}).
|
|
11899
|
+
*/
|
|
11900
|
+
async function computeFileDigests(config, basePath, reader, keys) {
|
|
11901
|
+
return digestLocalFiles(config, basePath, reader, keys);
|
|
11902
|
+
}
|
|
11903
|
+
/**
|
|
11904
|
+
* Digests every local FILE resource of a config about to be persisted as the new
|
|
11905
|
+
* base snapshot. Same path resolution and same failure handling as
|
|
11906
|
+
* {@link computeFileDigests}; unreadable files stay untracked in the snapshot.
|
|
11907
|
+
*/
|
|
11908
|
+
async function computeSnapshotFileDigests(config, basePath, reader) {
|
|
11909
|
+
return digestLocalFiles(config, basePath, reader, void 0);
|
|
11910
|
+
}
|
|
11911
|
+
async function digestLocalFiles(config, basePath, reader, keys) {
|
|
11912
|
+
const targets = [];
|
|
11913
|
+
for (const { platform, category, resource } of walkCustomizationBuckets(config)) {
|
|
11914
|
+
if (resource.type !== "FILE") continue;
|
|
11915
|
+
const key = resourceKey(platform, category, resourceName(resource));
|
|
11916
|
+
if (keys !== void 0 && !keys.has(key)) continue;
|
|
11917
|
+
targets.push({
|
|
11918
|
+
key,
|
|
11919
|
+
absolutePath: resolve(basePath, resource.path)
|
|
11920
|
+
});
|
|
11921
|
+
}
|
|
11922
|
+
const results = await Promise.all(targets.map(async ({ key, absolutePath }) => ({
|
|
11923
|
+
key,
|
|
11924
|
+
digest: await readLocalDigest(reader, absolutePath)
|
|
11925
|
+
})));
|
|
11926
|
+
const digests = /* @__PURE__ */ new Map();
|
|
11927
|
+
for (const { key, digest } of results) if (digest !== void 0) digests.set(key, digest);
|
|
11928
|
+
return digests;
|
|
11929
|
+
}
|
|
11930
|
+
/**
|
|
11931
|
+
* Reads and digests one local file, or reports "unknown" when it cannot be read.
|
|
11932
|
+
*
|
|
11933
|
+
* The swallow is deliberate (the one try/catch of this flow): a config may
|
|
11934
|
+
* declare a build artifact that has not been generated yet, and neither `diff`
|
|
11935
|
+
* nor `pull` nor drift detection should fail because of it. The key stays
|
|
11936
|
+
* untracked and the merge falls back to its conservative classification.
|
|
11937
|
+
*/
|
|
11938
|
+
async function readLocalDigest(reader, absolutePath) {
|
|
11939
|
+
try {
|
|
11940
|
+
return computeContentDigest(await reader.read(absolutePath));
|
|
11941
|
+
} catch {
|
|
11942
|
+
return;
|
|
11943
|
+
}
|
|
11944
|
+
}
|
|
11945
|
+
/**
|
|
11946
|
+
* Downloads and digests the given remote FILE resources.
|
|
11947
|
+
*
|
|
11948
|
+
* The resource key and the fileKey are paired in a single walk of the raw remote
|
|
11949
|
+
* shape: an index keyed by the remote file name could not be looked up when a
|
|
11950
|
+
* name contains `/`, because the merge key uses the trailing segment only. A
|
|
11951
|
+
* download failure propagates — a transient network error must not silently
|
|
11952
|
+
* degrade into "the remote has no content for this key" and change the
|
|
11953
|
+
* classification.
|
|
11954
|
+
*/
|
|
11955
|
+
async function computeRemoteFileDigests(remote, downloader, keys) {
|
|
11956
|
+
const targets = [];
|
|
11957
|
+
for (const { platform, category, resource } of walkCustomizationBuckets(remote)) {
|
|
11958
|
+
if (resource.type !== "FILE") continue;
|
|
11959
|
+
const key = remoteFileResourceKey(platform, category, resource.file.name);
|
|
11960
|
+
if (!keys.has(key)) continue;
|
|
11961
|
+
targets.push({
|
|
11962
|
+
key,
|
|
11963
|
+
fileKey: resource.file.fileKey
|
|
11964
|
+
});
|
|
11965
|
+
}
|
|
11966
|
+
const results = await Promise.all(targets.map(async ({ key, fileKey }) => [key, computeContentDigest(await downloader.download(fileKey))]));
|
|
11967
|
+
return new Map(results);
|
|
11968
|
+
}
|
|
11969
|
+
//#endregion
|
|
11970
|
+
//#region src/core/application/customization/customizationThreeWayMerge.ts
|
|
11971
|
+
/**
|
|
11972
|
+
* Assembles the customization 3-way merge shared by `diff`, `push` and `pull`.
|
|
11973
|
+
*
|
|
11974
|
+
* Only the contents that can change a classification are fetched: a key present
|
|
11975
|
+
* on one side alone is decided by existence, so local reads are limited to
|
|
11976
|
+
* `local ∩ (base ∪ remote)` and downloads to `remote ∩ (base ∪ local)`. Base
|
|
11977
|
+
* tags come from the snapshot's recorded digests, with
|
|
11978
|
+
* {@link resolveBaseFileDigests} filling in whatever was never recorded.
|
|
11979
|
+
*/
|
|
11980
|
+
async function buildCustomizationThreeWayMerge({ container, state, baseRevision, local, remote, basePath, localDigests: precomputedLocalDigests }) {
|
|
11981
|
+
const baseKeys = fileResourceKeys(state.config);
|
|
11982
|
+
const localKeys = fileResourceKeys(local);
|
|
11983
|
+
const remoteKeys = fileResourceKeys(remote.config);
|
|
11984
|
+
const localNeeded = intersect(localKeys, union(baseKeys, remoteKeys));
|
|
11985
|
+
const remoteNeeded = intersect(remoteKeys, union(baseKeys, localKeys));
|
|
11986
|
+
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)]);
|
|
11987
|
+
const { tags: baseTags, estimatedKeys } = resolveBaseFileDigests(state.config, state.fileDigests, localDigests, remoteDigests, {
|
|
11988
|
+
localKeys,
|
|
11989
|
+
remoteKeys,
|
|
11990
|
+
legacyState: state.fileDigests.size === 0,
|
|
11991
|
+
remoteUnchanged: baseRevision !== void 0 && baseRevision === remote.revision
|
|
11992
|
+
});
|
|
11993
|
+
return {
|
|
11994
|
+
merge: computeCustomizationThreeWayMerge(state.config, local, remote.config, {
|
|
11995
|
+
base: baseTags,
|
|
11996
|
+
local: localDigests,
|
|
11997
|
+
remote: remoteDigests
|
|
11998
|
+
}),
|
|
11999
|
+
estimatedKeys,
|
|
12000
|
+
remoteDigests,
|
|
12001
|
+
unreadableLocalKeys: difference(localNeeded, localDigests)
|
|
12002
|
+
};
|
|
12003
|
+
}
|
|
12004
|
+
function difference(keys, digests) {
|
|
12005
|
+
return new Set([...keys].filter((key) => !digests.has(key)));
|
|
12006
|
+
}
|
|
12007
|
+
function union(a, b) {
|
|
12008
|
+
return new Set([...a, ...b]);
|
|
12009
|
+
}
|
|
12010
|
+
function intersect(a, b) {
|
|
12011
|
+
const result = /* @__PURE__ */ new Set();
|
|
12012
|
+
for (const key of a) if (b.has(key)) result.add(key);
|
|
12013
|
+
return result;
|
|
12014
|
+
}
|
|
12015
|
+
function subset(digests, keys) {
|
|
12016
|
+
return new Map([...digests].filter(([key]) => keys.has(key)));
|
|
12017
|
+
}
|
|
12018
|
+
//#endregion
|
|
12019
|
+
//#region src/core/domain/customization/services/customizationStateParser.ts
|
|
12020
|
+
/**
|
|
12021
|
+
* Collects the per-FILE `digest` entries into the merge's key space. The
|
|
12022
|
+
* structure itself is already validated by {@link CustomizationConfigParser},
|
|
12023
|
+
* so this pass only reads the extra key and rejects malformed values. A missing
|
|
12024
|
+
* `digest` means "untracked", not an error: a state file carrying no digest at
|
|
12025
|
+
* all parses into an empty map.
|
|
12026
|
+
*/
|
|
12027
|
+
function parseFileDigests(parsed) {
|
|
12028
|
+
const digests = /* @__PURE__ */ new Map();
|
|
12029
|
+
if (!isRecord(parsed)) return digests;
|
|
12030
|
+
for (const platform of PLATFORMS) {
|
|
12031
|
+
const platformValue = parsed[platform];
|
|
12032
|
+
if (!isRecord(platformValue)) continue;
|
|
12033
|
+
for (const category of CATEGORIES) {
|
|
12034
|
+
const list = platformValue[category];
|
|
12035
|
+
if (!Array.isArray(list)) continue;
|
|
12036
|
+
for (const raw of list) {
|
|
12037
|
+
const entry = digestEntry(raw, platform, category);
|
|
12038
|
+
if (entry !== void 0) digests.set(entry[0], entry[1]);
|
|
12039
|
+
}
|
|
12040
|
+
}
|
|
12041
|
+
}
|
|
12042
|
+
return digests;
|
|
12043
|
+
}
|
|
12044
|
+
/** The `[resourceKey, digest]` pair of one serialized resource, if it has one. */
|
|
12045
|
+
function digestEntry(raw, platform, category) {
|
|
12046
|
+
if (!isRecord(raw) || raw.type !== "FILE" || typeof raw.path !== "string") return;
|
|
12047
|
+
const digest = raw.digest;
|
|
12048
|
+
if (digest === void 0 || digest === null) return;
|
|
12049
|
+
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`);
|
|
12050
|
+
return [resourceKey(platform, category, resourceName({
|
|
12051
|
+
type: "FILE",
|
|
12052
|
+
path: raw.path
|
|
12053
|
+
})), digest];
|
|
12054
|
+
}
|
|
12055
|
+
/**
|
|
12056
|
+
* Parses pre-parsed (codec-decoded) data into a {@link CustomizationState}.
|
|
12057
|
+
*
|
|
12058
|
+
* The inverse of {@link CustomizationStateSerializer}: the captured config is
|
|
12059
|
+
* parsed via the same {@link CustomizationConfigParser} as the local YAML, so
|
|
12060
|
+
* the base snapshot is validated identically, then the per-FILE `digest` keys
|
|
12061
|
+
* are collected into the snapshot's digest map.
|
|
12062
|
+
*/
|
|
12063
|
+
const CustomizationStateParser = { parse: (parsed) => ({
|
|
12064
|
+
config: CustomizationConfigParser.parse(parsed),
|
|
12065
|
+
fileDigests: parseFileDigests(parsed)
|
|
12066
|
+
}) };
|
|
12067
|
+
//#endregion
|
|
11705
12068
|
//#region src/core/application/customization/customizationRemote.ts
|
|
11706
12069
|
/**
|
|
11707
12070
|
* Converts the kintone remote customization (file metadata) into a
|
|
@@ -11730,59 +12093,6 @@ function toResource(resource) {
|
|
|
11730
12093
|
path: resource.file.name
|
|
11731
12094
|
};
|
|
11732
12095
|
}
|
|
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
12096
|
//#endregion
|
|
11787
12097
|
//#region src/core/application/customization/loadCustomizationThreeWayInputs.ts
|
|
11788
12098
|
/**
|
|
@@ -11796,7 +12106,7 @@ async function loadCustomizationThreeWayInputs(container) {
|
|
|
11796
12106
|
codec: container.configCodec,
|
|
11797
12107
|
stateStorage: container.customizationStateStorage,
|
|
11798
12108
|
appRevisionStorage: container.appRevisionStorage,
|
|
11799
|
-
parseState: (parsed) => CustomizationStateParser.parse(parsed)
|
|
12109
|
+
parseState: (parsed) => CustomizationStateParser.parse(parsed),
|
|
11800
12110
|
stateLabel: "Customization state",
|
|
11801
12111
|
loadLocal: async () => {
|
|
11802
12112
|
const result = await container.customizationStorage.get();
|
|
@@ -11820,17 +12130,58 @@ async function loadCustomizationThreeWayInputs(container) {
|
|
|
11820
12130
|
}
|
|
11821
12131
|
//#endregion
|
|
11822
12132
|
//#region src/core/application/customization/detectCustomizationThreeWayDiff.ts
|
|
12133
|
+
/** How many resource keys a note lists inline before summarizing the rest. */
|
|
12134
|
+
const MAX_LISTED_KEYS = 10;
|
|
12135
|
+
/**
|
|
12136
|
+
* Renders the affected resource keys so the note identifies its subjects.
|
|
12137
|
+
*
|
|
12138
|
+
* The counts alone cannot be acted on: neither the diff lines nor `customize
|
|
12139
|
+
* push` mark which entries were inferred, so "N file(s)" leaves the user with no
|
|
12140
|
+
* way to tell them apart from the exactly-compared ones.
|
|
12141
|
+
*/
|
|
12142
|
+
function formatKeys(keys) {
|
|
12143
|
+
const listed = keys.slice(0, MAX_LISTED_KEYS).join(", ");
|
|
12144
|
+
const rest = keys.length - MAX_LISTED_KEYS;
|
|
12145
|
+
return rest > 0 ? `${listed}, and ${rest} more` : listed;
|
|
12146
|
+
}
|
|
12147
|
+
function optimisticNote(keys) {
|
|
12148
|
+
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");
|
|
12149
|
+
}
|
|
12150
|
+
function conservativeNote(keys) {
|
|
12151
|
+
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");
|
|
12152
|
+
}
|
|
12153
|
+
function unreadableNote(keys) {
|
|
12154
|
+
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");
|
|
12155
|
+
}
|
|
12156
|
+
/**
|
|
12157
|
+
* Builds the caveats for keys the classification could not compare exactly:
|
|
12158
|
+
* those whose base content was inferred rather than read from the snapshot, and
|
|
12159
|
+
* those whose local body was unreadable. Only keys that actually surfaced as a
|
|
12160
|
+
* diff line are reported: such a key classified as `unchanged` tells the user
|
|
12161
|
+
* nothing, and over-reporting would train them to ignore the note.
|
|
12162
|
+
*/
|
|
12163
|
+
function buildNotes(estimatedKeys, unreadableLocalKeys, changedKeys) {
|
|
12164
|
+
const changedIn = (keys) => [...keys].filter((key) => changedKeys.has(key));
|
|
12165
|
+
const notes = [];
|
|
12166
|
+
const optimistic = changedIn(estimatedKeys.optimistic);
|
|
12167
|
+
if (optimistic.length > 0) notes.push(optimisticNote(optimistic));
|
|
12168
|
+
const conservative = changedIn(estimatedKeys.conservative);
|
|
12169
|
+
if (conservative.length > 0) notes.push(conservativeNote(conservative));
|
|
12170
|
+
const unreadable = changedIn(unreadableLocalKeys);
|
|
12171
|
+
if (unreadable.length > 0) notes.push(unreadableNote(unreadable));
|
|
12172
|
+
return notes;
|
|
12173
|
+
}
|
|
11823
12174
|
/**
|
|
11824
12175
|
* Detects customization differences with 3-way awareness.
|
|
11825
12176
|
*
|
|
11826
12177
|
* When a state (base snapshot) exists, classifies changes into local-only,
|
|
11827
|
-
* remote drift, and conflicts via the
|
|
11828
|
-
*
|
|
11829
|
-
* existing 2-way `detectCustomizationDiff` (the CLI renders it with its
|
|
11830
|
-
* printer).
|
|
12178
|
+
* remote drift, and conflicts via the resource-key-keyed merge, comparing FILE
|
|
12179
|
+
* contents by digest against the base snapshot. When no state exists, falls back
|
|
12180
|
+
* to the existing 2-way `detectCustomizationDiff` (the CLI renders it with its
|
|
12181
|
+
* 2-way printer).
|
|
11831
12182
|
*/
|
|
11832
12183
|
async function detectCustomizationThreeWayDiff({ container, input }) {
|
|
11833
|
-
const { state, local, remote } = await loadCustomizationThreeWayInputs(container);
|
|
12184
|
+
const { state, baseRevision, local, remote } = await loadCustomizationThreeWayInputs(container);
|
|
11834
12185
|
if (state === void 0 || local === void 0) return {
|
|
11835
12186
|
mode: "two-way",
|
|
11836
12187
|
diff: await detectCustomizationDiff({
|
|
@@ -11838,8 +12189,15 @@ async function detectCustomizationThreeWayDiff({ container, input }) {
|
|
|
11838
12189
|
input: { basePath: input.basePath }
|
|
11839
12190
|
})
|
|
11840
12191
|
};
|
|
11841
|
-
const
|
|
11842
|
-
|
|
12192
|
+
const { merge, estimatedKeys, unreadableLocalKeys } = await buildCustomizationThreeWayMerge({
|
|
12193
|
+
container,
|
|
12194
|
+
state,
|
|
12195
|
+
baseRevision,
|
|
12196
|
+
local,
|
|
12197
|
+
remote,
|
|
12198
|
+
basePath: input.basePath
|
|
12199
|
+
});
|
|
12200
|
+
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
12201
|
}
|
|
11844
12202
|
//#endregion
|
|
11845
12203
|
//#region src/cli/commands/customize/diff.ts
|
|
@@ -11894,20 +12252,37 @@ var diff_default$10 = define({
|
|
|
11894
12252
|
* stringification (via the codec port in the application layer).
|
|
11895
12253
|
*
|
|
11896
12254
|
* The snapshot is serialized through the exact same path as `capture`
|
|
11897
|
-
* ({@link CustomizationConfigSerializer})
|
|
11898
|
-
*
|
|
11899
|
-
*
|
|
12255
|
+
* ({@link CustomizationConfigSerializer}), with the recorded content digest
|
|
12256
|
+
* injected into each FILE entry via the serializer's decorate hook. URL
|
|
12257
|
+
* resources and FILE resources with no recorded digest are written unchanged,
|
|
12258
|
+
* so the state file stays round-trip compatible with the captured config. The
|
|
12259
|
+
* app revision is NOT written here; it is persisted separately via
|
|
12260
|
+
* `AppRevisionStorage`.
|
|
11900
12261
|
*/
|
|
11901
|
-
const CustomizationStateSerializer = { serialize: (state) => CustomizationConfigSerializer.serialize(state.config)
|
|
12262
|
+
const CustomizationStateSerializer = { serialize: (state) => CustomizationConfigSerializer.serialize(state.config, (platform, category, resource) => {
|
|
12263
|
+
if (resource.type !== "FILE") return;
|
|
12264
|
+
const digest = state.fileDigests.get(resourceKey(platform, category, resourceName(resource)));
|
|
12265
|
+
return digest === void 0 ? void 0 : { digest };
|
|
12266
|
+
}) };
|
|
11902
12267
|
//#endregion
|
|
11903
12268
|
//#region src/core/application/customization/customizationStateIo.ts
|
|
11904
12269
|
/**
|
|
11905
|
-
* Persists the new base: the customization snapshot (
|
|
11906
|
-
* revision (`state/<appName>/revision.yaml`) side by side.
|
|
11907
|
-
* kept together so they always advance as a pair
|
|
12270
|
+
* Persists the new base: the customization snapshot (config + FILE content
|
|
12271
|
+
* digests) and the app revision (`state/<appName>/revision.yaml`) side by side.
|
|
12272
|
+
* The two writes are kept together so they always advance as a pair
|
|
12273
|
+
* (snapshot → revision).
|
|
12274
|
+
*
|
|
12275
|
+
* `fileDigests` is computed by the caller rather than here: this function runs
|
|
12276
|
+
* after `updateCustomization` (it needs the resulting revision), while push must
|
|
12277
|
+
* digest its files *before* uploading them, and pull's merge path records the
|
|
12278
|
+
* remote's digests rather than what is on disk. Keeping the parameter required
|
|
12279
|
+
* still makes a digest-less snapshot impossible to write by accident.
|
|
11908
12280
|
*/
|
|
11909
|
-
async function saveCustomizationSnapshotAndRevision(container, config, revision) {
|
|
11910
|
-
await saveSnapshotState(container.customizationStateStorage, container.configCodec, CustomizationStateSerializer.serialize, {
|
|
12281
|
+
async function saveCustomizationSnapshotAndRevision(container, config, fileDigests, revision) {
|
|
12282
|
+
await saveSnapshotState(container.customizationStateStorage, container.configCodec, CustomizationStateSerializer.serialize, {
|
|
12283
|
+
config,
|
|
12284
|
+
fileDigests
|
|
12285
|
+
});
|
|
11911
12286
|
await saveAppRevision(container.appRevisionStorage, container.configCodec, revision);
|
|
11912
12287
|
}
|
|
11913
12288
|
//#endregion
|
|
@@ -11924,7 +12299,7 @@ async function saveCustomizationSnapshotAndRevision(container, config, revision)
|
|
|
11924
12299
|
* resolution, so an aborted resolution leaves the working copy untouched.
|
|
11925
12300
|
*/
|
|
11926
12301
|
async function pullCustomization({ container, input }) {
|
|
11927
|
-
const { state, local, remote } = await loadCustomizationThreeWayInputs(container);
|
|
12302
|
+
const { state, baseRevision, local, remote } = await loadCustomizationThreeWayInputs(container);
|
|
11928
12303
|
if (input.force || state === void 0 || local === void 0) {
|
|
11929
12304
|
const preservePaths = local !== void 0 ? buildPreservePaths(local) : void 0;
|
|
11930
12305
|
const captured = await captureCustomization({
|
|
@@ -11936,17 +12311,28 @@ async function pullCustomization({ container, input }) {
|
|
|
11936
12311
|
}
|
|
11937
12312
|
});
|
|
11938
12313
|
await container.customizationStorage.update(captured.configText);
|
|
11939
|
-
await
|
|
12314
|
+
const fileDigests = await computeSnapshotFileDigests(captured.config, input.basePath, container.fileContentReader);
|
|
12315
|
+
await saveCustomizationSnapshotAndRevision(container, captured.config, fileDigests, remote.revision);
|
|
11940
12316
|
return { mode: input.force ? "force" : "firstTime" };
|
|
11941
12317
|
}
|
|
11942
|
-
const
|
|
12318
|
+
const { merge, remoteDigests, estimatedKeys, unreadableLocalKeys } = await buildCustomizationThreeWayMerge({
|
|
12319
|
+
container,
|
|
12320
|
+
state,
|
|
12321
|
+
baseRevision,
|
|
12322
|
+
local,
|
|
12323
|
+
remote,
|
|
12324
|
+
basePath: input.basePath
|
|
12325
|
+
});
|
|
11943
12326
|
return {
|
|
11944
12327
|
mode: "merged",
|
|
11945
|
-
merge
|
|
12328
|
+
merge,
|
|
11946
12329
|
local,
|
|
11947
12330
|
remote: remote.raw,
|
|
11948
12331
|
remoteConfig: remote.config,
|
|
11949
|
-
remoteRevision: remote.revision
|
|
12332
|
+
remoteRevision: remote.revision,
|
|
12333
|
+
remoteDigests,
|
|
12334
|
+
estimatedKeys,
|
|
12335
|
+
unreadableLocalKeys
|
|
11950
12336
|
};
|
|
11951
12337
|
}
|
|
11952
12338
|
/**
|
|
@@ -11962,18 +12348,15 @@ async function pullCustomization({ container, input }) {
|
|
|
11962
12348
|
async function applyPulledCustomizationMerge({ container, input }) {
|
|
11963
12349
|
const merged = wrapBusinessRuleError(() => resolveCustomizationMerge(input.merge, input.resolution, input.local, input.remoteConfig));
|
|
11964
12350
|
const fromRemote = /* @__PURE__ */ new Set();
|
|
11965
|
-
for (const entry of input.merge.entries)
|
|
11966
|
-
|
|
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
|
-
}
|
|
12351
|
+
for (const entry of input.merge.entries) if (entry.change.kind === "remoteOnly") fromRemote.add(entry.key);
|
|
12352
|
+
else if (entry.change.kind === "conflict" && input.resolution.get(entry.key) === "remote") fromRemote.add(entry.key);
|
|
11971
12353
|
const remoteFileKeys = remoteFileKeyMap(input.remote);
|
|
11972
12354
|
const downloads = [];
|
|
11973
|
-
for (const resource of
|
|
11974
|
-
|
|
11975
|
-
|
|
11976
|
-
|
|
12355
|
+
for (const { platform, category, resource } of walkCustomizationBuckets(merged)) {
|
|
12356
|
+
if (resource.type !== "FILE") continue;
|
|
12357
|
+
const key = resourceKey(platform, category, resourceName(resource));
|
|
12358
|
+
if (!fromRemote.has(key)) continue;
|
|
12359
|
+
const fileKey = remoteFileKeys.get(key);
|
|
11977
12360
|
if (fileKey === void 0) continue;
|
|
11978
12361
|
const absolutePath = resolve(input.basePath, resource.path);
|
|
11979
12362
|
downloads.push((async () => {
|
|
@@ -11984,7 +12367,28 @@ async function applyPulledCustomizationMerge({ container, input }) {
|
|
|
11984
12367
|
await Promise.all(downloads);
|
|
11985
12368
|
const configText = stringifyConfig(container.configCodec, CustomizationConfigSerializer.serialize(merged));
|
|
11986
12369
|
await container.customizationStorage.update(configText);
|
|
11987
|
-
await saveCustomizationSnapshotAndRevision(container, merged, input.remoteRevision);
|
|
12370
|
+
await saveCustomizationSnapshotAndRevision(container, merged, await buildMergedBaseDigests(merged, input.basePath, input.remoteDigests, container), input.remoteRevision);
|
|
12371
|
+
}
|
|
12372
|
+
/**
|
|
12373
|
+
* Base digests for a merge that was just applied.
|
|
12374
|
+
*
|
|
12375
|
+
* A key the remote still holds records the *remote's* digest, even when the
|
|
12376
|
+
* local side won the merge: the base means "what local and remote last agreed
|
|
12377
|
+
* on", and content that stayed local was never uploaded. Recording the on-disk
|
|
12378
|
+
* bytes instead would make the very next diff report the local edit as remote
|
|
12379
|
+
* drift and block the push. Keys the remote does not have (locally added files,
|
|
12380
|
+
* or remote-only files freshly downloaded) take the on-disk digest.
|
|
12381
|
+
*/
|
|
12382
|
+
async function buildMergedBaseDigests(merged, basePath, remoteDigests, container) {
|
|
12383
|
+
const mergedKeys = fileResourceKeys(merged);
|
|
12384
|
+
const fromDisk = new Set([...mergedKeys].filter((key) => !remoteDigests.has(key)));
|
|
12385
|
+
const diskDigests = await computeFileDigests(merged, basePath, container.fileContentReader, fromDisk);
|
|
12386
|
+
const digests = /* @__PURE__ */ new Map();
|
|
12387
|
+
for (const key of mergedKeys) {
|
|
12388
|
+
const digest = remoteDigests.get(key) ?? diskDigests.get(key);
|
|
12389
|
+
if (digest !== void 0) digests.set(key, digest);
|
|
12390
|
+
}
|
|
12391
|
+
return digests;
|
|
11988
12392
|
}
|
|
11989
12393
|
/**
|
|
11990
12394
|
* Builds the path-preservation map for `captureCustomization`: bucket-qualified
|
|
@@ -11996,41 +12400,19 @@ async function applyPulledCustomizationMerge({ container, input }) {
|
|
|
11996
12400
|
*/
|
|
11997
12401
|
function buildPreservePaths(config) {
|
|
11998
12402
|
const map = /* @__PURE__ */ new Map();
|
|
11999
|
-
const
|
|
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);
|
|
12403
|
+
for (const { platform, category, resource } of walkCustomizationBuckets(config)) if (resource.type === "FILE") map.set(resourceKey(platform, category, resourceName(resource)), resource.path);
|
|
12006
12404
|
return map;
|
|
12007
12405
|
}
|
|
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
12406
|
/**
|
|
12017
|
-
*
|
|
12018
|
-
*
|
|
12407
|
+
* Remote FILE `fileKey`s under the merge identity, so a body is looked up by the
|
|
12408
|
+
* same key the merge decided to take from the remote. A flat basename map would
|
|
12409
|
+
* make cross-bucket same-basename files (e.g. `desktop/js/main.js` and
|
|
12410
|
+
* `mobile/js/main.js`) resolve to whichever bucket was registered last,
|
|
12411
|
+
* overwriting one bucket's body with the other's.
|
|
12019
12412
|
*/
|
|
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
12413
|
function remoteFileKeyMap(remote) {
|
|
12026
12414
|
const map = /* @__PURE__ */ new Map();
|
|
12027
|
-
const
|
|
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);
|
|
12415
|
+
for (const { platform, category, resource } of walkCustomizationBuckets(remote)) if (resource.type === "FILE") map.set(remoteFileResourceKey(platform, category, resource.file.name), resource.file.fileKey);
|
|
12034
12416
|
return map;
|
|
12035
12417
|
}
|
|
12036
12418
|
//#endregion
|
|
@@ -12054,6 +12436,42 @@ function derivePullPaths(customizeFilePath) {
|
|
|
12054
12436
|
filePrefix: deriveFilePrefix(customizeFilePath)
|
|
12055
12437
|
};
|
|
12056
12438
|
}
|
|
12439
|
+
/**
|
|
12440
|
+
* What each choice does. `pull` never writes to kintone, so the destruction is
|
|
12441
|
+
* one-directional: only the copy on disk can be lost here, and the wording says
|
|
12442
|
+
* which choice loses it.
|
|
12443
|
+
*
|
|
12444
|
+
* The scope is a single value in the customization config file, so no file moves
|
|
12445
|
+
* either way. A resource whose remote side is a URL — or gone — carries no body
|
|
12446
|
+
* to write over the local copy, hence "wherever the remote side has a body"
|
|
12447
|
+
* rather than a flat promise that the file on disk is replaced.
|
|
12448
|
+
*/
|
|
12449
|
+
const SCOPE_EFFECT = "remote takes the remote scope; local keeps the local one, and the remote stays untouched until the next `customize push`";
|
|
12450
|
+
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`";
|
|
12451
|
+
/**
|
|
12452
|
+
* Why the divergence may not be real, when it may not be.
|
|
12453
|
+
*
|
|
12454
|
+
* An unreadable local file counts as unknown content, so it conflicts with any
|
|
12455
|
+
* remote change even where a baseline was recorded — a separate case from a
|
|
12456
|
+
* missing baseline, and the only one where picking `local` leaves the merge
|
|
12457
|
+
* pointing at a file that is not on disk.
|
|
12458
|
+
*/
|
|
12459
|
+
function conflictCaveat(context, key) {
|
|
12460
|
+
if (context.unreadableLocalKeys.has(key)) return "the local file could not be read, so this may not be a real conflict";
|
|
12461
|
+
if (context.estimatedKeys.conservative.has(key)) return "no recorded baseline for this file, so this may not be a real conflict";
|
|
12462
|
+
}
|
|
12463
|
+
/**
|
|
12464
|
+
* The conflict prompt. Exported so its wording is pinned by a test: this prompt
|
|
12465
|
+
* is the only place where picking the wrong side discards content, and both the
|
|
12466
|
+
* caveat and the effect are chosen here.
|
|
12467
|
+
*/
|
|
12468
|
+
function buildConflictPrompt(context, key) {
|
|
12469
|
+
const isScope = key === SCOPE_KEY;
|
|
12470
|
+
const effect = isScope ? SCOPE_EFFECT : RESOURCE_EFFECT;
|
|
12471
|
+
const caveat = conflictCaveat(context, key);
|
|
12472
|
+
const detail = caveat === void 0 ? effect : `${caveat}; ${effect}`;
|
|
12473
|
+
return `Conflict on ${isScope ? "scope" : `entry "${key}"`}. Keep which side? (${detail})`;
|
|
12474
|
+
}
|
|
12057
12475
|
async function resolveConflicts$1(merged, options) {
|
|
12058
12476
|
const resolution = /* @__PURE__ */ new Map();
|
|
12059
12477
|
for (const conflict of merged.merge.conflicts) {
|
|
@@ -12066,7 +12484,7 @@ async function resolveConflicts$1(merged, options) {
|
|
|
12066
12484
|
continue;
|
|
12067
12485
|
}
|
|
12068
12486
|
const selected = await p.select({
|
|
12069
|
-
message:
|
|
12487
|
+
message: buildConflictPrompt(merged, conflict.key),
|
|
12070
12488
|
options: [{
|
|
12071
12489
|
value: "local",
|
|
12072
12490
|
label: "local (ours)"
|
|
@@ -12120,7 +12538,8 @@ async function runPull$1(containerConfig, options) {
|
|
|
12120
12538
|
local: result.local,
|
|
12121
12539
|
remote: result.remote,
|
|
12122
12540
|
remoteConfig: result.remoteConfig,
|
|
12123
|
-
remoteRevision: result.remoteRevision
|
|
12541
|
+
remoteRevision: result.remoteRevision,
|
|
12542
|
+
remoteDigests: result.remoteDigests
|
|
12124
12543
|
}
|
|
12125
12544
|
});
|
|
12126
12545
|
p.log.success("Local customization merged and written.");
|
|
@@ -12142,7 +12561,8 @@ async function runPull$1(containerConfig, options) {
|
|
|
12142
12561
|
local: result.local,
|
|
12143
12562
|
remote: result.remote,
|
|
12144
12563
|
remoteConfig: result.remoteConfig,
|
|
12145
|
-
remoteRevision: result.remoteRevision
|
|
12564
|
+
remoteRevision: result.remoteRevision,
|
|
12565
|
+
remoteDigests: result.remoteDigests
|
|
12146
12566
|
}
|
|
12147
12567
|
});
|
|
12148
12568
|
p.log.success("Conflicts resolved. Local customization written.");
|
|
@@ -12196,6 +12616,9 @@ var pull_default$10 = define({
|
|
|
12196
12616
|
//#region src/core/application/customization/pushCustomization.ts
|
|
12197
12617
|
/** Pull command name surfaced in the drift hint message. */
|
|
12198
12618
|
const CUSTOMIZE_PULL_COMMAND = "customize pull";
|
|
12619
|
+
function inferredDriftHint(count) {
|
|
12620
|
+
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.`;
|
|
12621
|
+
}
|
|
12199
12622
|
async function resolvePlatform(platform, basePath, fileUploader) {
|
|
12200
12623
|
const resolveList = async (resources) => Promise.all(resources.map(async (resource) => {
|
|
12201
12624
|
if (resource.type === "URL") return resource;
|
|
@@ -12216,10 +12639,11 @@ async function resolvePlatform(platform, basePath, fileUploader) {
|
|
|
12216
12639
|
/**
|
|
12217
12640
|
* Applies the local customization config to the remote with drift detection.
|
|
12218
12641
|
*
|
|
12219
|
-
* - Loads base/local/remote and computes the
|
|
12220
|
-
* (remoteOnly or conflict entries) && !force → {@link buildDriftConflict}
|
|
12221
|
-
* tagged with `ConfigDrift`.
|
|
12222
|
-
*
|
|
12642
|
+
* - Loads base/local/remote and computes the resource-key-keyed 3-way merge.
|
|
12643
|
+
* drift (remoteOnly or conflict entries) && !force → {@link buildDriftConflict}
|
|
12644
|
+
* tagged with `ConfigDrift`. A file edited only locally is a local change, not
|
|
12645
|
+
* drift, so it pushes without `--force`; a file whose remote body moved away
|
|
12646
|
+
* from the base blocks the push.
|
|
12223
12647
|
* - Replaces the full js/css lists with the local config's resolved (uploaded)
|
|
12224
12648
|
* resources via `updateAppCustomize`, so renames are expressed as old-name
|
|
12225
12649
|
* removal + new-name add (full-list replace — NOT an inexpressible
|
|
@@ -12230,12 +12654,25 @@ async function resolvePlatform(platform, basePath, fileUploader) {
|
|
|
12230
12654
|
* post-apply revision as the new base (snapshot → revision).
|
|
12231
12655
|
*/
|
|
12232
12656
|
async function pushCustomization({ container, input }) {
|
|
12233
|
-
const { state, local, remote } = await loadCustomizationThreeWayInputs(container);
|
|
12657
|
+
const { state, baseRevision, local, remote } = await loadCustomizationThreeWayInputs(container);
|
|
12234
12658
|
if (local === void 0) throw new ValidationError(ValidationErrorCode.InvalidInput, "Customization config file not found");
|
|
12235
12659
|
const firstTime = state === void 0;
|
|
12236
|
-
|
|
12237
|
-
|
|
12238
|
-
|
|
12660
|
+
const localDigests = await computeSnapshotFileDigests(local, input.basePath, container.fileContentReader);
|
|
12661
|
+
if (state !== void 0 && !input.force) {
|
|
12662
|
+
const { merge, estimatedKeys } = await buildCustomizationThreeWayMerge({
|
|
12663
|
+
container,
|
|
12664
|
+
state,
|
|
12665
|
+
baseRevision,
|
|
12666
|
+
local,
|
|
12667
|
+
remote,
|
|
12668
|
+
basePath: input.basePath,
|
|
12669
|
+
localDigests
|
|
12670
|
+
});
|
|
12671
|
+
const driftKeys = merge.entries.filter((e) => e.change.kind === "remoteOnly" || e.change.kind === "conflict").map((e) => e.key);
|
|
12672
|
+
if (driftKeys.length > 0) {
|
|
12673
|
+
const inferred = driftKeys.filter((key) => estimatedKeys.conservative.has(key)).length;
|
|
12674
|
+
throw buildDriftConflict(CUSTOMIZE_PULL_COMMAND, inferred > 0 ? inferredDriftHint(inferred) : void 0);
|
|
12675
|
+
}
|
|
12239
12676
|
}
|
|
12240
12677
|
const [desktop, mobile] = await Promise.all([resolvePlatform(local.desktop, input.basePath, container.fileUploader), resolvePlatform(local.mobile, input.basePath, container.fileUploader)]);
|
|
12241
12678
|
ResourceMerger.assertResourceCount("desktop.js", desktop.js);
|
|
@@ -12259,7 +12696,7 @@ async function pushCustomization({ container, input }) {
|
|
|
12259
12696
|
scope: local.scope,
|
|
12260
12697
|
desktop: local.desktop,
|
|
12261
12698
|
mobile: local.mobile
|
|
12262
|
-
}, newRevision);
|
|
12699
|
+
}, localDigests, newRevision);
|
|
12263
12700
|
return {
|
|
12264
12701
|
mode: firstTime ? "firstTime" : "push",
|
|
12265
12702
|
revision: newRevision
|
|
@@ -15665,7 +16102,8 @@ function buildTasks(args) {
|
|
|
15665
16102
|
local: result.local,
|
|
15666
16103
|
remote: result.remote,
|
|
15667
16104
|
remoteConfig: result.remoteConfig,
|
|
15668
|
-
remoteRevision: result.remoteRevision
|
|
16105
|
+
remoteRevision: result.remoteRevision,
|
|
16106
|
+
remoteDigests: result.remoteDigests
|
|
15669
16107
|
}
|
|
15670
16108
|
});
|
|
15671
16109
|
return "merged";
|
|
@@ -17320,41 +17758,71 @@ function buildLayoutExtras(layoutConflict, layoutLocalChanged, layoutRemoteChang
|
|
|
17320
17758
|
return [];
|
|
17321
17759
|
}
|
|
17322
17760
|
//#endregion
|
|
17761
|
+
//#region src/cli/commands/schema/formReadTarget.ts
|
|
17762
|
+
/**
|
|
17763
|
+
* Shared `--published` flag for the schema commands that read a form definition
|
|
17764
|
+
* (`dump` / `diff`). Kept next to its users rather than in `src/cli/config.ts`,
|
|
17765
|
+
* which holds only domain-agnostic connection/execution-mode options.
|
|
17766
|
+
*/
|
|
17767
|
+
const formReadTargetArgs = { published: {
|
|
17768
|
+
type: "boolean",
|
|
17769
|
+
description: "Read the published (deployed) form instead of the preview (unpublished) one"
|
|
17770
|
+
} };
|
|
17771
|
+
function resolveFormReadTarget(values) {
|
|
17772
|
+
return values.published ? "published" : "preview";
|
|
17773
|
+
}
|
|
17774
|
+
//#endregion
|
|
17323
17775
|
//#region src/cli/commands/schema/diff.ts
|
|
17324
|
-
async function runDiff$2(container) {
|
|
17776
|
+
async function runDiff$2(container, target) {
|
|
17777
|
+
printSchemaDiffTarget(target);
|
|
17325
17778
|
const s = p.spinner();
|
|
17326
17779
|
s.start("Comparing schema...");
|
|
17327
17780
|
let result;
|
|
17328
17781
|
try {
|
|
17329
|
-
result =
|
|
17782
|
+
result = target === "published" ? {
|
|
17783
|
+
kind: "published",
|
|
17784
|
+
value: await detectDiff({
|
|
17785
|
+
container,
|
|
17786
|
+
input: { target }
|
|
17787
|
+
})
|
|
17788
|
+
} : {
|
|
17789
|
+
kind: "preview",
|
|
17790
|
+
value: await detectThreeWayDiff({ container })
|
|
17791
|
+
};
|
|
17330
17792
|
} catch (error) {
|
|
17331
17793
|
s.stop("Comparison failed.");
|
|
17332
17794
|
throw error;
|
|
17333
17795
|
}
|
|
17334
17796
|
s.stop("Comparison complete.");
|
|
17335
|
-
|
|
17797
|
+
if (result.kind === "published") {
|
|
17798
|
+
printDiffResult(result.value);
|
|
17799
|
+
return;
|
|
17800
|
+
}
|
|
17801
|
+
printThreeWayDiffResult(result.value, printDiffResult);
|
|
17336
17802
|
}
|
|
17337
17803
|
var diff_default$2 = define({
|
|
17338
17804
|
name: "diff",
|
|
17339
17805
|
description: "Detect differences between schema file and current kintone form",
|
|
17340
17806
|
args: {
|
|
17341
17807
|
...kintoneArgs,
|
|
17342
|
-
...multiAppArgs
|
|
17808
|
+
...multiAppArgs,
|
|
17809
|
+
...formReadTargetArgs
|
|
17343
17810
|
},
|
|
17344
17811
|
run: async (ctx) => {
|
|
17345
17812
|
try {
|
|
17813
|
+
const target = resolveFormReadTarget(ctx.values);
|
|
17346
17814
|
await routeMultiApp(ctx.values, {
|
|
17347
17815
|
singleLegacy: async () => {
|
|
17348
|
-
await runDiff$2(createCliContainer(resolveConfig(ctx.values)));
|
|
17816
|
+
await runDiff$2(createCliContainer(resolveConfig(ctx.values)), target);
|
|
17349
17817
|
},
|
|
17350
17818
|
singleApp: async (app, projectConfig) => {
|
|
17351
|
-
await runDiff$2(createCliContainer(resolveAppCliConfig(app, projectConfig, ctx.values)));
|
|
17819
|
+
await runDiff$2(createCliContainer(resolveAppCliConfig(app, projectConfig, ctx.values)), target);
|
|
17352
17820
|
},
|
|
17353
17821
|
multiApp: async (plan, projectConfig) => {
|
|
17354
17822
|
await runMultiAppWithFailCheck(plan, async (app) => {
|
|
17355
17823
|
const container = createCliContainer(resolveAppCliConfig(app, projectConfig, ctx.values));
|
|
17356
17824
|
printAppHeader(app.name, app.appId);
|
|
17357
|
-
await runDiff$2(container);
|
|
17825
|
+
await runDiff$2(container, target);
|
|
17358
17826
|
}, "All schema diffs completed successfully.");
|
|
17359
17827
|
}
|
|
17360
17828
|
});
|
|
@@ -17365,26 +17833,31 @@ var diff_default$2 = define({
|
|
|
17365
17833
|
});
|
|
17366
17834
|
//#endregion
|
|
17367
17835
|
//#region src/core/adapters/kintone/formDumpReader.ts
|
|
17836
|
+
const GET_RAW_FORM_DATA_FAILURE_MESSAGES = {
|
|
17837
|
+
preview: "Failed to fetch raw form data for dump",
|
|
17838
|
+
published: "Failed to fetch published raw form data for dump (the app may not be deployed yet, or the credentials may not be allowed to read it)"
|
|
17839
|
+
};
|
|
17368
17840
|
var KintoneFormDumpReader = class {
|
|
17369
17841
|
constructor(client, appId) {
|
|
17370
17842
|
this.client = client;
|
|
17371
17843
|
this.appId = appId;
|
|
17372
17844
|
}
|
|
17373
|
-
async getRawFormData() {
|
|
17845
|
+
async getRawFormData(target = DEFAULT_FORM_READ_TARGET) {
|
|
17846
|
+
const preview = target === "preview";
|
|
17374
17847
|
try {
|
|
17375
17848
|
const [fields, layout] = await Promise.all([this.client.app.getFormFields({
|
|
17376
17849
|
app: this.appId,
|
|
17377
|
-
preview
|
|
17850
|
+
preview
|
|
17378
17851
|
}), this.client.app.getFormLayout({
|
|
17379
17852
|
app: this.appId,
|
|
17380
|
-
preview
|
|
17853
|
+
preview
|
|
17381
17854
|
})]);
|
|
17382
17855
|
return {
|
|
17383
17856
|
fields,
|
|
17384
17857
|
layout
|
|
17385
17858
|
};
|
|
17386
17859
|
} catch (error) {
|
|
17387
|
-
throw wrapKintoneError(error,
|
|
17860
|
+
throw wrapKintoneError(error, GET_RAW_FORM_DATA_FAILURE_MESSAGES[target]);
|
|
17388
17861
|
}
|
|
17389
17862
|
}
|
|
17390
17863
|
};
|
|
@@ -17428,29 +17901,38 @@ function createDumpCliContainer(config) {
|
|
|
17428
17901
|
}
|
|
17429
17902
|
//#endregion
|
|
17430
17903
|
//#region src/core/application/formSchema/dumpForm.ts
|
|
17431
|
-
async function dumpForm({ container }) {
|
|
17432
|
-
const rawData = await container.formDumpReader.getRawFormData();
|
|
17904
|
+
async function dumpForm({ container, input }) {
|
|
17905
|
+
const rawData = await container.formDumpReader.getRawFormData(input?.target ?? "preview");
|
|
17433
17906
|
await Promise.all([container.dumpStorage.saveFields(JSON.stringify(rawData.fields, null, 2)), container.dumpStorage.saveLayout(JSON.stringify(rawData.layout, null, 2))]);
|
|
17434
17907
|
}
|
|
17435
17908
|
//#endregion
|
|
17436
17909
|
//#region src/cli/commands/schema/dump.ts
|
|
17437
|
-
async function runSingleDump(config) {
|
|
17438
|
-
const
|
|
17910
|
+
async function runSingleDump(config, basePrefix, target) {
|
|
17911
|
+
const filePrefix = `${basePrefix}${target === "published" ? "published-" : ""}`;
|
|
17912
|
+
const container = createDumpCliContainer({
|
|
17913
|
+
...config,
|
|
17914
|
+
filePrefix
|
|
17915
|
+
});
|
|
17439
17916
|
const s = p.spinner();
|
|
17440
|
-
s.start("Fetching form fields and layout...");
|
|
17441
|
-
await dumpForm({
|
|
17917
|
+
s.start(target === "published" ? "Fetching published form fields and layout..." : "Fetching form fields and layout...");
|
|
17918
|
+
await dumpForm({
|
|
17919
|
+
container,
|
|
17920
|
+
input: { target }
|
|
17921
|
+
});
|
|
17442
17922
|
s.stop("Form data fetched.");
|
|
17443
|
-
p.log.success(`Saved ${pc.cyan(`${
|
|
17923
|
+
p.log.success(`Saved ${pc.cyan(`${filePrefix}fields.json`)} and ${pc.cyan(`${filePrefix}layout.json`)}`);
|
|
17444
17924
|
}
|
|
17445
17925
|
var dump_default = define({
|
|
17446
17926
|
name: "dump",
|
|
17447
17927
|
description: "Dump current kintone form fields and layout as JSON",
|
|
17448
17928
|
args: {
|
|
17449
17929
|
...kintoneArgs,
|
|
17450
|
-
...multiAppArgs
|
|
17930
|
+
...multiAppArgs,
|
|
17931
|
+
...formReadTargetArgs
|
|
17451
17932
|
},
|
|
17452
17933
|
run: async (ctx) => {
|
|
17453
17934
|
try {
|
|
17935
|
+
const target = resolveFormReadTarget(ctx.values);
|
|
17454
17936
|
await routeMultiApp(ctx.values, {
|
|
17455
17937
|
singleLegacy: async () => {
|
|
17456
17938
|
const config = resolveConfig(ctx.values);
|
|
@@ -17458,9 +17940,8 @@ var dump_default = define({
|
|
|
17458
17940
|
baseUrl: config.baseUrl,
|
|
17459
17941
|
auth: config.auth,
|
|
17460
17942
|
appId: config.appId,
|
|
17461
|
-
guestSpaceId: config.guestSpaceId
|
|
17462
|
-
|
|
17463
|
-
});
|
|
17943
|
+
guestSpaceId: config.guestSpaceId
|
|
17944
|
+
}, "", target);
|
|
17464
17945
|
},
|
|
17465
17946
|
singleApp: async (app, projectConfig) => {
|
|
17466
17947
|
const config = resolveAppCliConfig(app, projectConfig, ctx.values);
|
|
@@ -17468,9 +17949,8 @@ var dump_default = define({
|
|
|
17468
17949
|
baseUrl: config.baseUrl,
|
|
17469
17950
|
auth: config.auth,
|
|
17470
17951
|
appId: config.appId,
|
|
17471
|
-
guestSpaceId: config.guestSpaceId
|
|
17472
|
-
|
|
17473
|
-
});
|
|
17952
|
+
guestSpaceId: config.guestSpaceId
|
|
17953
|
+
}, `${app.name}-`, target);
|
|
17474
17954
|
},
|
|
17475
17955
|
multiApp: async (plan, projectConfig) => {
|
|
17476
17956
|
await runMultiAppWithFailCheck(plan, async (app) => {
|
|
@@ -17480,9 +17960,8 @@ var dump_default = define({
|
|
|
17480
17960
|
baseUrl: config.baseUrl,
|
|
17481
17961
|
auth: config.auth,
|
|
17482
17962
|
appId: config.appId,
|
|
17483
|
-
guestSpaceId: config.guestSpaceId
|
|
17484
|
-
|
|
17485
|
-
});
|
|
17963
|
+
guestSpaceId: config.guestSpaceId
|
|
17964
|
+
}, `${app.name}-`, target);
|
|
17486
17965
|
}, "All dumps completed successfully.");
|
|
17487
17966
|
}
|
|
17488
17967
|
});
|