@loopress/cli 0.32.2 → 0.33.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 +511 -154
- package/dist/commands/acf/list.d.ts +1 -1
- package/dist/commands/acf/list.js +3 -4
- package/dist/commands/acf/pull.d.ts +1 -1
- package/dist/commands/acf/pull.js +9 -6
- package/dist/commands/acf/push.d.ts +1 -1
- package/dist/commands/acf/push.js +5 -4
- package/dist/commands/api/publish.js +2 -1
- package/dist/commands/app/pull.js +2 -1
- package/dist/commands/app/push.js +5 -4
- package/dist/commands/app/{remove.d.ts → rm.d.ts} +1 -0
- package/dist/commands/app/{remove.js → rm.js} +2 -1
- package/dist/commands/block/diff.d.ts +2 -0
- package/dist/commands/block/diff.js +5 -0
- package/dist/commands/block/list.d.ts +8 -0
- package/dist/commands/block/list.js +23 -0
- package/dist/commands/block/pull.d.ts +21 -0
- package/dist/commands/block/pull.js +67 -0
- package/dist/commands/block/push.d.ts +20 -0
- package/dist/commands/block/push.js +48 -0
- package/dist/commands/block/rm.d.ts +23 -0
- package/dist/commands/block/rm.js +69 -0
- package/dist/commands/block/rollback.d.ts +2 -0
- package/dist/commands/block/rollback.js +5 -0
- package/dist/commands/composer/init.js +1 -0
- package/dist/commands/composer/push.js +5 -4
- package/dist/commands/content/copy.d.ts +3 -0
- package/dist/commands/content/copy.js +79 -2
- package/dist/commands/dev.js +4 -3
- package/dist/commands/diff.d.ts +1 -0
- package/dist/commands/diff.js +33 -3
- package/dist/commands/doctor.d.ts +4 -2
- package/dist/commands/doctor.js +37 -23
- package/dist/commands/form/pull.js +7 -5
- package/dist/commands/form/push.js +4 -3
- package/dist/commands/init.js +4 -3
- package/dist/commands/menu/push.js +8 -8
- package/dist/commands/option/list.js +5 -2
- package/dist/commands/option/push.js +2 -1
- package/dist/commands/option/{remove.d.ts → rm.d.ts} +1 -0
- package/dist/commands/option/{remove.js → rm.js} +2 -1
- package/dist/commands/page/push.js +2 -1
- package/dist/commands/plugin/audit.d.ts +4 -4
- package/dist/commands/plugin/audit.js +35 -15
- package/dist/commands/plugin/pull.js +3 -2
- package/dist/commands/plugin/push.js +2 -2
- package/dist/commands/plugin/status.js +3 -1
- package/dist/commands/project/config.js +7 -6
- package/dist/commands/project/pull.js +3 -1
- package/dist/commands/project/push.js +6 -4
- package/dist/commands/project/{remove.d.ts → rm.d.ts} +1 -0
- package/dist/commands/project/{remove.js → rm.js} +3 -2
- package/dist/commands/project/switch.js +6 -4
- package/dist/commands/promote.js +7 -2
- package/dist/commands/pull.d.ts +2 -0
- package/dist/commands/pull.js +42 -18
- package/dist/commands/push.d.ts +2 -1
- package/dist/commands/push.js +69 -25
- package/dist/commands/snippet/publish.js +9 -5
- package/dist/commands/snippet/push.d.ts +1 -1
- package/dist/commands/snippet/push.js +15 -12
- package/dist/commands/status.js +1 -0
- package/dist/commands/theme/activate.d.ts +21 -0
- package/dist/commands/theme/activate.js +44 -0
- package/dist/commands/theme/pull.js +2 -1
- package/dist/commands/theme/push.js +7 -3
- package/dist/commands/theme/style/push.js +7 -2
- package/dist/commands/theme/template/push.js +25 -6
- package/dist/config/auth.manager.d.ts +10 -7
- package/dist/config/auth.manager.js +5 -1
- package/dist/config/json-file.d.ts +1 -1
- package/dist/config/project-config.manager.d.ts +2 -2
- package/dist/config/project-config.manager.js +13 -21
- package/dist/lib/api-client.js +16 -21
- package/dist/lib/app-build.js +6 -2
- package/dist/lib/app-manifest.js +6 -6
- package/dist/lib/base.d.ts +2 -1
- package/dist/lib/base.js +10 -8
- package/dist/lib/bootstrap-full-install.js +8 -6
- package/dist/lib/capabilities.d.ts +44 -0
- package/dist/lib/capabilities.js +85 -0
- package/dist/lib/declared-type-command.js +4 -2
- package/dist/lib/dev-watch.js +5 -5
- package/dist/lib/diff-command.d.ts +2 -3
- package/dist/lib/diff-command.js +9 -7
- package/dist/lib/diff-state.js +2 -3
- package/dist/lib/load-files.js +2 -1
- package/dist/lib/load-snippets.js +37 -42
- package/dist/lib/local-callback-server.js +7 -1
- package/dist/lib/narrow.d.ts +5 -0
- package/dist/lib/narrow.js +26 -0
- package/dist/lib/php-files-command.js +8 -5
- package/dist/lib/plugin-detection.d.ts +0 -1
- package/dist/lib/plugin-detection.js +2 -4
- package/dist/lib/push-command.d.ts +1 -1
- package/dist/lib/push-command.js +10 -5
- package/dist/lib/put-or-create.d.ts +2 -2
- package/dist/lib/readdir-tolerant.js +3 -1
- package/dist/lib/resource-state.js +38 -13
- package/dist/lib/rollback-command.d.ts +1 -1
- package/dist/lib/rollback-command.js +3 -2
- package/dist/lib/rollback-materialize.js +28 -8
- package/dist/lib/rotate-app-password.js +1 -0
- package/dist/lib/sentry.js +2 -2
- package/dist/lib/snapshot-store.d.ts +10 -7
- package/dist/lib/snapshot-store.js +21 -5
- package/dist/lib/spinner.js +4 -0
- package/dist/lib/validate-local.js +35 -20
- package/dist/lib/wp-client.d.ts +6 -1
- package/dist/lib/wp-client.js +54 -69
- package/dist/lib/wp-site-diagnostic.js +3 -5
- package/dist/types/global-config.generated.d.ts +1 -1
- package/dist/types/plugin.d.ts +7 -5
- package/dist/types/plugin.js +6 -1
- package/dist/types/project-config.generated.d.ts +9 -1
- package/dist/types/snippet.d.ts +2 -2
- package/dist/types/snippet.js +1 -1
- package/dist/utils/acf-format.js +6 -3
- package/dist/utils/block-format.d.ts +27 -0
- package/dist/utils/block-format.js +93 -0
- package/dist/utils/composer.d.ts +7 -15
- package/dist/utils/composer.js +43 -16
- package/dist/utils/declared-type-format.js +26 -19
- package/dist/utils/form-format.js +2 -2
- package/dist/utils/loopress-config.js +9 -1
- package/dist/utils/menu-format.d.ts +3 -1
- package/dist/utils/menu-format.js +5 -0
- package/dist/utils/option-format.d.ts +2 -2
- package/dist/utils/option-format.js +15 -25
- package/dist/utils/page-format.js +7 -6
- package/dist/utils/php-top-level.js +15 -4
- package/dist/utils/plugin-sync.d.ts +7 -7
- package/dist/utils/plugin-sync.js +11 -11
- package/dist/utils/plugins.js +9 -6
- package/dist/utils/resource-dirs.d.ts +2 -0
- package/dist/utils/resource-dirs.js +9 -1
- package/dist/utils/snippet-format.d.ts +1 -1
- package/dist/utils/snippet-format.js +5 -3
- package/dist/utils/template-format.js +2 -2
- package/dist/utils/theme-css.js +14 -6
- package/dist/utils/theme-styles-format.d.ts +3 -0
- package/dist/utils/theme-styles-format.js +15 -5
- package/dist/utils/version.js +1 -1
- package/oclif.manifest.json +1938 -1393
- package/package.json +42 -17
- package/schemas/global-config.schema.json +1 -1
- package/schemas/project-config.schema.json +11 -1
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
1
2
|
import { type ResourceState } from './diff-state.js';
|
|
2
3
|
export declare const SNAPSHOT_RETENTION = 10;
|
|
3
|
-
|
|
4
|
-
afterState:
|
|
5
|
-
beforeState:
|
|
6
|
-
createdAt:
|
|
7
|
-
environment:
|
|
8
|
-
resource:
|
|
9
|
-
}
|
|
4
|
+
declare const SNAPSHOT: z.ZodObject<{
|
|
5
|
+
afterState: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
6
|
+
beforeState: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
7
|
+
createdAt: z.ZodString;
|
|
8
|
+
environment: z.ZodString;
|
|
9
|
+
resource: z.ZodString;
|
|
10
|
+
}, z.core.$strip>;
|
|
11
|
+
export type Snapshot = z.infer<typeof SNAPSHOT>;
|
|
10
12
|
export type SnapshotSummary = {
|
|
11
13
|
createdAt: string;
|
|
12
14
|
environment: string;
|
|
@@ -28,3 +30,4 @@ export declare function readSnapshot(rootDir: string, resource: string, environm
|
|
|
28
30
|
id: string;
|
|
29
31
|
}>;
|
|
30
32
|
export declare function pruneSnapshots(rootDir: string, resource: string, environment: string, retain: number): Promise<void>;
|
|
33
|
+
export {};
|
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import {} from './diff-state.js';
|
|
5
|
+
import { errorCode } from './narrow.js';
|
|
3
6
|
// How many snapshots `writeSnapshot` keeps per resource before pruning the oldest, so the
|
|
4
7
|
// cache directory doesn't grow without bound across a project's lifetime.
|
|
5
8
|
export const SNAPSHOT_RETENTION = 10;
|
|
9
|
+
// One `lps <resource> push` recorded as a restore point. `beforeState` is the remote state
|
|
10
|
+
// read immediately before that push (the "left" side `lps diff` would have shown); `afterState`
|
|
11
|
+
// is the local state that was about to be pushed (the "right" side). Rolling back restores
|
|
12
|
+
// `beforeState`; comparing the environment's *current* state against `afterState` at rollback
|
|
13
|
+
// time is what detects whether anything else touched the environment since.
|
|
14
|
+
const SNAPSHOT = z.object({
|
|
15
|
+
afterState: z.record(z.string(), z.unknown()),
|
|
16
|
+
beforeState: z.record(z.string(), z.unknown()),
|
|
17
|
+
createdAt: z.string(),
|
|
18
|
+
environment: z.string(),
|
|
19
|
+
resource: z.string(),
|
|
20
|
+
});
|
|
6
21
|
// `ResourceState` (a Map, for O(1) lookups during comparison) has no direct JSON
|
|
7
22
|
// representation; snapshots round-trip through a plain object instead. Keys are sorted so two
|
|
8
23
|
// snapshots of the same state serialize identically (stable diffs, deterministic tests).
|
|
@@ -27,10 +42,10 @@ function snapshotPath(rootDir, resource, environment, id) {
|
|
|
27
42
|
return join(snapshotsDir(rootDir, resource, environment), `${id}.json`);
|
|
28
43
|
}
|
|
29
44
|
function isEnoent(error) {
|
|
30
|
-
return error
|
|
45
|
+
return errorCode(error) === 'ENOENT';
|
|
31
46
|
}
|
|
32
47
|
function isEexist(error) {
|
|
33
|
-
return error
|
|
48
|
+
return errorCode(error) === 'EEXIST';
|
|
34
49
|
}
|
|
35
50
|
// Snapshot ids are the millisecond timestamp they were written at: sortable, unique enough for
|
|
36
51
|
// a single-machine CLI run, and directly usable as `--to <id>`.
|
|
@@ -105,7 +120,8 @@ export async function listSnapshots(rootDir, resource, environment) {
|
|
|
105
120
|
for (const entry of snapshotFiles) {
|
|
106
121
|
const id = entry.slice(0, -'.json'.length);
|
|
107
122
|
try {
|
|
108
|
-
const
|
|
123
|
+
const raw = JSON.parse(await readFile(join(dir, entry), 'utf8'));
|
|
124
|
+
const parsed = SNAPSHOT.parse(raw);
|
|
109
125
|
summaries.push({ createdAt: parsed.createdAt, environment: parsed.environment, id, resource });
|
|
110
126
|
}
|
|
111
127
|
catch {
|
|
@@ -122,7 +138,7 @@ export async function listSnapshots(rootDir, resource, environment) {
|
|
|
122
138
|
export async function readSnapshot(rootDir, resource, environment, id) {
|
|
123
139
|
let targetId = id;
|
|
124
140
|
if (targetId === undefined) {
|
|
125
|
-
const
|
|
141
|
+
const latest = (await listSnapshots(rootDir, resource, environment)).at(0);
|
|
126
142
|
if (!latest) {
|
|
127
143
|
throw new Error(`No snapshots found for "${resource}" on "${environment}". A snapshot is written automatically by \`lps ${resource} push\`.`);
|
|
128
144
|
}
|
|
@@ -138,7 +154,7 @@ export async function readSnapshot(rootDir, resource, environment, id) {
|
|
|
138
154
|
}
|
|
139
155
|
throw error;
|
|
140
156
|
}
|
|
141
|
-
return { ...JSON.parse(raw), id: targetId };
|
|
157
|
+
return { ...SNAPSHOT.parse(JSON.parse(raw)), id: targetId };
|
|
142
158
|
}
|
|
143
159
|
export async function pruneSnapshots(rootDir, resource, environment, retain) {
|
|
144
160
|
const summaries = await listSnapshots(rootDir, resource, environment);
|
package/dist/lib/spinner.js
CHANGED
|
@@ -16,6 +16,7 @@ function start() {
|
|
|
16
16
|
const rawOut = process.stdout.write;
|
|
17
17
|
const rawErr = process.stderr.write;
|
|
18
18
|
const draw = () => {
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- typed as number, but undefined when stderr is not a TTY
|
|
19
20
|
const text = `${FRAMES[frame]} ${label}`.slice(0, (process.stderr.columns ?? 80) - 1);
|
|
20
21
|
rawErr.call(process.stderr, `\r\u{1B}[2K${text}`);
|
|
21
22
|
};
|
|
@@ -26,7 +27,10 @@ function start() {
|
|
|
26
27
|
draw();
|
|
27
28
|
return result;
|
|
28
29
|
};
|
|
30
|
+
// write() is overloaded, and a single rest-args wrapper can't be typed as every overload.
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see above
|
|
29
32
|
process.stdout.write = intercept(process.stdout, rawOut);
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see above
|
|
30
34
|
process.stderr.write = intercept(process.stderr, rawErr);
|
|
31
35
|
draw();
|
|
32
36
|
const timer = setInterval(() => {
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { join, relative, sep } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
3
4
|
import { configManager } from '../config/project-config.manager.js';
|
|
5
|
+
import { PLUGIN_PIN } from '../types/plugin.js';
|
|
6
|
+
import { readLocalBlocks } from '../utils/block-format.js';
|
|
7
|
+
import { COMPOSER_JSON } from '../utils/composer.js';
|
|
4
8
|
import { CPT_SPEC, declaredFileChecker, META_SPEC, PATTERN_SPEC, ROLE_SPEC, TAXONOMY_SPEC } from '../utils/declared-type-format.js';
|
|
5
9
|
import { parseLocalOption } from '../utils/option-format.js';
|
|
6
10
|
import { readLocalHtmlFiles, readLocalPages } from '../utils/page-format.js';
|
|
@@ -8,6 +12,7 @@ import { RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
|
|
|
8
12
|
import { parseType } from '../utils/snippet-format.js';
|
|
9
13
|
import { readLocalTemplates } from '../utils/template-format.js';
|
|
10
14
|
import { loadSnippets } from './load-snippets.js';
|
|
15
|
+
import { errorCode, errorMessage, isRecord } from './narrow.js';
|
|
11
16
|
import { API_FILES_RESOURCE, HOOK_FILES_RESOURCE, phpFileProblems } from './php-files-command.js';
|
|
12
17
|
import { readdirTolerant } from './readdir-tolerant.js';
|
|
13
18
|
// Directories holding one JSON document per resource. Snippets (code file plus JSON sidecar)
|
|
@@ -16,6 +21,7 @@ const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'menuDir', 'themeStylesDir'];
|
|
|
16
21
|
const DEFAULT_DIR = {
|
|
17
22
|
acfDir: 'acf',
|
|
18
23
|
apiDir: 'api',
|
|
24
|
+
blockDir: RESOURCE_DIR_DEFAULTS.block,
|
|
19
25
|
cptDir: 'cpt',
|
|
20
26
|
formDir: 'forms',
|
|
21
27
|
hooksDir: 'hooks',
|
|
@@ -61,6 +67,9 @@ export async function validateLocal(cwd) {
|
|
|
61
67
|
checked += await checkSnippets(resolve('snippetsDir'), problems);
|
|
62
68
|
checked += await checkPhpDir(resolve('apiDir'), problems, 'API route file is empty', API_FILES_RESOURCE);
|
|
63
69
|
checked += await checkPhpDir(resolve('hooksDir'), problems, 'Hook file is empty', HOOK_FILES_RESOURCE);
|
|
70
|
+
const blocks = await readLocalBlocks(resolve('blockDir'));
|
|
71
|
+
checked += blocks.blocks.length;
|
|
72
|
+
problems.push(...blocks.problems);
|
|
64
73
|
const pages = await readLocalPages(resolve('pageDir'));
|
|
65
74
|
checked += pages.pages.length + pages.problems.length;
|
|
66
75
|
problems.push(...pages.problems);
|
|
@@ -108,6 +117,7 @@ async function checkPhpDir(dir, problems, emptyMessage, spec) {
|
|
|
108
117
|
}
|
|
109
118
|
return checked;
|
|
110
119
|
}
|
|
120
|
+
const JSON_OBJECT = z.record(z.string(), z.unknown());
|
|
111
121
|
async function checkComposerJson(cwd, problems) {
|
|
112
122
|
let raw;
|
|
113
123
|
try {
|
|
@@ -122,14 +132,13 @@ async function checkComposerJson(cwd, problems) {
|
|
|
122
132
|
parsed = JSON.parse(raw);
|
|
123
133
|
}
|
|
124
134
|
catch (error) {
|
|
125
|
-
problems.push({ file: 'composer.json', message: `not valid JSON: ${error
|
|
135
|
+
problems.push({ file: 'composer.json', message: `not valid JSON: ${errorMessage(error)}` });
|
|
126
136
|
return 1;
|
|
127
137
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
problems.push({ file: 'composer.json', message
|
|
132
|
-
}
|
|
138
|
+
const result = COMPOSER_JSON.safeParse(parsed);
|
|
139
|
+
if (!result.success) {
|
|
140
|
+
for (const { message } of result.error.issues)
|
|
141
|
+
problems.push({ file: 'composer.json', message });
|
|
133
142
|
}
|
|
134
143
|
return 1;
|
|
135
144
|
}
|
|
@@ -151,10 +160,10 @@ async function checkJsonDir(dir, problems, shape) {
|
|
|
151
160
|
parsed = JSON.parse(raw);
|
|
152
161
|
}
|
|
153
162
|
catch (error) {
|
|
154
|
-
problems.push({ file: filePath, message: `not valid JSON: ${error
|
|
163
|
+
problems.push({ file: filePath, message: `not valid JSON: ${errorMessage(error)}` });
|
|
155
164
|
continue;
|
|
156
165
|
}
|
|
157
|
-
if (
|
|
166
|
+
if (!JSON_OBJECT.safeParse(parsed).success) {
|
|
158
167
|
problems.push({ file: filePath, message: 'expected a JSON object' });
|
|
159
168
|
continue;
|
|
160
169
|
}
|
|
@@ -162,7 +171,7 @@ async function checkJsonDir(dir, problems, shape) {
|
|
|
162
171
|
shape?.(raw, filePath);
|
|
163
172
|
}
|
|
164
173
|
catch (error) {
|
|
165
|
-
problems.push({ file: filePath, message: error
|
|
174
|
+
problems.push({ file: filePath, message: errorMessage(error) });
|
|
166
175
|
}
|
|
167
176
|
}
|
|
168
177
|
return checked;
|
|
@@ -184,19 +193,16 @@ function checkLoopressJson(localConfig, problems) {
|
|
|
184
193
|
const { plugins } = localConfig;
|
|
185
194
|
if (plugins === undefined)
|
|
186
195
|
return;
|
|
187
|
-
|
|
196
|
+
const manifest = JSON_OBJECT.safeParse(plugins);
|
|
197
|
+
if (!manifest.success) {
|
|
188
198
|
problems.push({ file: 'loopress.json', message: '"plugins" must be an object of slug -> version string or {version, active}' });
|
|
189
199
|
return;
|
|
190
200
|
}
|
|
191
|
-
for (const [slug, version] of Object.entries(
|
|
201
|
+
for (const [slug, version] of Object.entries(manifest.data)) {
|
|
192
202
|
// The object form is what `plugin pull` writes for a plugin installed but inactive (see PluginPin).
|
|
193
|
-
|
|
194
|
-
(typeof version === 'object' &&
|
|
195
|
-
version !== null &&
|
|
196
|
-
typeof version.version === 'string' &&
|
|
197
|
-
['boolean', 'undefined'].includes(typeof version.active));
|
|
198
|
-
if (!isPin)
|
|
203
|
+
if (!PLUGIN_PIN.safeParse(version).success) {
|
|
199
204
|
problems.push({ file: 'loopress.json', message: `plugin "${slug}" must map to a version string or {version, active}` });
|
|
205
|
+
}
|
|
200
206
|
}
|
|
201
207
|
}
|
|
202
208
|
async function checkSnippets(dir, problems) {
|
|
@@ -211,7 +217,7 @@ async function checkSnippets(dir, problems) {
|
|
|
211
217
|
});
|
|
212
218
|
}
|
|
213
219
|
catch (error) {
|
|
214
|
-
problems.push({ file: dir, message: error
|
|
220
|
+
problems.push({ file: dir, message: errorMessage(error) });
|
|
215
221
|
return 0;
|
|
216
222
|
}
|
|
217
223
|
// loadSnippets coerces an invalid `type` to the extension default rather than failing, so
|
|
@@ -227,6 +233,10 @@ async function checkSnippets(dir, problems) {
|
|
|
227
233
|
problems.push({ file: filePath, message: 'not valid JSON' });
|
|
228
234
|
continue;
|
|
229
235
|
}
|
|
236
|
+
if (!isRecord(meta)) {
|
|
237
|
+
problems.push({ file: filePath, message: 'expected a JSON object' });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
230
240
|
if (meta.type !== undefined && parseType(meta.type) === null) {
|
|
231
241
|
problems.push({ file: filePath, message: `"type": ${JSON.stringify(meta.type)} is not one of css, html, js, php, text` });
|
|
232
242
|
}
|
|
@@ -245,15 +255,20 @@ async function readConfig(cwd, problems) {
|
|
|
245
255
|
raw = await readFile(join(cwd, 'loopress.json'), 'utf8');
|
|
246
256
|
}
|
|
247
257
|
catch (error) {
|
|
248
|
-
if (error
|
|
258
|
+
if (errorCode(error) === 'ENOENT')
|
|
249
259
|
return {};
|
|
250
260
|
throw error;
|
|
251
261
|
}
|
|
262
|
+
let parsed;
|
|
252
263
|
try {
|
|
253
|
-
|
|
264
|
+
parsed = JSON.parse(raw);
|
|
254
265
|
}
|
|
255
266
|
catch {
|
|
256
267
|
problems.push({ file: 'loopress.json', message: 'loopress.json is not valid JSON. Fix or delete it, then run `lps init` again.' });
|
|
257
268
|
return {};
|
|
258
269
|
}
|
|
270
|
+
if (isRecord(parsed) && !Array.isArray(parsed))
|
|
271
|
+
return parsed;
|
|
272
|
+
problems.push({ file: 'loopress.json', message: 'expected a JSON object' });
|
|
273
|
+
return {};
|
|
259
274
|
}
|
package/dist/lib/wp-client.d.ts
CHANGED
|
@@ -7,8 +7,8 @@ export declare function assertSecureSiteUrl(siteUrl: string, environmentName: st
|
|
|
7
7
|
* Paths are relative to `<site>/wp-json/`, e.g. `loopress/v1/snippets` or `wp/v2/plugins`.
|
|
8
8
|
*/
|
|
9
9
|
export declare class WpClient {
|
|
10
|
-
private readonly siteUrl;
|
|
11
10
|
private readonly client;
|
|
11
|
+
private readonly siteUrl;
|
|
12
12
|
constructor(siteUrl: string, token: string, environmentName?: string);
|
|
13
13
|
delete<T = unknown>(path: string, options?: RequestOptions): Promise<T>;
|
|
14
14
|
get<T>(path: string, options?: RequestOptions): Promise<T>;
|
|
@@ -28,5 +28,10 @@ export declare function isApplicative404(error: unknown, marker: string): boolea
|
|
|
28
28
|
export declare function isConflictCode(error: unknown, code: string): boolean;
|
|
29
29
|
export declare function isTimeoutError(error: unknown): boolean;
|
|
30
30
|
export declare function isInvalidPageNumber(error: unknown): boolean;
|
|
31
|
+
export declare function responseOf(error: unknown): {
|
|
32
|
+
body?: string | undefined;
|
|
33
|
+
statusCode?: number | undefined;
|
|
34
|
+
};
|
|
35
|
+
export declare function parseBody(body: string | undefined): Record<string, unknown>;
|
|
31
36
|
export declare function formatWpError(error: unknown, url: string, timeoutMs?: number): string;
|
|
32
37
|
export {};
|
package/dist/lib/wp-client.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import got from 'got';
|
|
1
|
+
import got, {} from 'got';
|
|
2
2
|
import { Buffer } from 'node:buffer';
|
|
3
|
+
import { causeOf, errorCode, isRecord } from './narrow.js';
|
|
3
4
|
import { withSpinner } from './spinner.js';
|
|
4
5
|
export const REQUEST_TIMEOUT_MS = 30_000;
|
|
5
6
|
// The one environment allowed to talk plain HTTP: Loopress's local dev environment, identified
|
|
@@ -30,11 +31,11 @@ export function assertSecureSiteUrl(siteUrl, environmentName) {
|
|
|
30
31
|
* Paths are relative to `<site>/wp-json/`, e.g. `loopress/v1/snippets` or `wp/v2/plugins`.
|
|
31
32
|
*/
|
|
32
33
|
export class WpClient {
|
|
33
|
-
siteUrl;
|
|
34
34
|
client;
|
|
35
|
+
siteUrl;
|
|
35
36
|
constructor(siteUrl, token, environmentName) {
|
|
36
|
-
this.siteUrl = siteUrl;
|
|
37
37
|
assertSecureSiteUrl(siteUrl, environmentName);
|
|
38
|
+
this.siteUrl = siteUrl;
|
|
38
39
|
this.client = got.extend({
|
|
39
40
|
headers: { Authorization: `Basic ${Buffer.from(token).toString('base64')}` },
|
|
40
41
|
prefixUrl: `${siteUrl}/wp-json`,
|
|
@@ -83,6 +84,8 @@ export class WpClient {
|
|
|
83
84
|
const timeoutMs = options?.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
84
85
|
try {
|
|
85
86
|
const response = await withSpinner(`Waiting for ${new URL(this.siteUrl).host}`, async () => this.client(path, { json, method, timeout: { request: timeoutMs } }));
|
|
87
|
+
// The REST contract boundary: each endpoint's shape is the caller's `T`, not checked here.
|
|
88
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see above
|
|
86
89
|
return (response.body ? JSON.parse(response.body) : undefined);
|
|
87
90
|
}
|
|
88
91
|
catch (error) {
|
|
@@ -91,95 +94,91 @@ export class WpClient {
|
|
|
91
94
|
}
|
|
92
95
|
}
|
|
93
96
|
export function isNotFoundError(error) {
|
|
94
|
-
|
|
95
|
-
return cause?.response?.statusCode === 404;
|
|
97
|
+
return responseOf(causeOf(error)).statusCode === 404;
|
|
96
98
|
}
|
|
97
99
|
// Authenticated but missing the capability the endpoint needs (e.g. `list_users`).
|
|
98
100
|
export function isForbiddenError(error) {
|
|
99
|
-
|
|
100
|
-
return cause?.response?.statusCode === 403;
|
|
101
|
+
return responseOf(causeOf(error)).statusCode === 403;
|
|
101
102
|
}
|
|
102
103
|
// A 404 a Loopress controller raises on purpose, identified by its JSON body's `error` field
|
|
103
104
|
// (e.g. `{"error": "composer.lock not found"}` from ComposerController). Narrower than
|
|
104
105
|
// isNotFoundError: a bare 404 also covers the route being absent because the plugin isn't
|
|
105
106
|
// installed or predates the feature, which callers must still surface normally.
|
|
106
107
|
export function isApplicative404(error, marker) {
|
|
107
|
-
|
|
108
|
-
return false;
|
|
109
|
-
const body = error.cause?.response?.body;
|
|
110
|
-
if (!body)
|
|
111
|
-
return false;
|
|
112
|
-
try {
|
|
113
|
-
return JSON.parse(body).error === marker;
|
|
114
|
-
}
|
|
115
|
-
catch {
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
108
|
+
return isNotFoundError(error) && parseBody(responseOf(causeOf(error)).body).error === marker;
|
|
118
109
|
}
|
|
119
110
|
// A 409 a Loopress controller tags with a `code`, for a refusal the CLI can act on (e.g.
|
|
120
111
|
// `loopress_page_not_managed`, which `lps page push` offers to take over).
|
|
121
112
|
export function isConflictCode(error, code) {
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
return false;
|
|
125
|
-
try {
|
|
126
|
-
return JSON.parse(response.body).code === code;
|
|
127
|
-
}
|
|
128
|
-
catch {
|
|
129
|
-
return false;
|
|
130
|
-
}
|
|
113
|
+
const { body, statusCode } = responseOf(causeOf(error));
|
|
114
|
+
return statusCode === 409 && parseBody(body).code === code;
|
|
131
115
|
}
|
|
132
116
|
export function isTimeoutError(error) {
|
|
133
|
-
const cause = error
|
|
134
|
-
return cause
|
|
117
|
+
const cause = causeOf(error);
|
|
118
|
+
return isRecord(cause) && cause.name === 'TimeoutError';
|
|
135
119
|
}
|
|
136
120
|
// WordPress core answers 400 with this `code` when `?page=N` is past the last page of a
|
|
137
121
|
// collection, which is how `WpClient.getAll` knows it has walked them all.
|
|
138
122
|
export function isInvalidPageNumber(error) {
|
|
139
|
-
|
|
123
|
+
return parseBody(responseOf(causeOf(error)).body).code === 'rest_post_invalid_page_number';
|
|
124
|
+
}
|
|
125
|
+
// The HTTP response a got error carries, read structurally so a mocked error reads the same.
|
|
126
|
+
export function responseOf(error) {
|
|
127
|
+
const response = isRecord(error) ? error.response : undefined;
|
|
128
|
+
if (!isRecord(response))
|
|
129
|
+
return {};
|
|
130
|
+
return {
|
|
131
|
+
body: typeof response.body === 'string' ? response.body : undefined,
|
|
132
|
+
statusCode: typeof response.statusCode === 'number' ? response.statusCode : undefined,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
// A response body's top-level JSON fields, or none when it isn't JSON.
|
|
136
|
+
export function parseBody(body) {
|
|
140
137
|
if (!body)
|
|
141
|
-
return
|
|
138
|
+
return {};
|
|
142
139
|
try {
|
|
143
|
-
|
|
140
|
+
const parsed = JSON.parse(body);
|
|
141
|
+
return isRecord(parsed) ? parsed : {};
|
|
144
142
|
}
|
|
145
143
|
catch {
|
|
146
|
-
return
|
|
144
|
+
return {};
|
|
147
145
|
}
|
|
148
146
|
}
|
|
149
147
|
export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
150
|
-
const
|
|
151
|
-
const status = err.response?.statusCode;
|
|
148
|
+
const { body, statusCode: status } = responseOf(error);
|
|
152
149
|
if (status === 401 || status === 403) {
|
|
153
150
|
// A Loopress controller's own `{"error": ...}` on a 403 is a deliberate refusal of this one
|
|
154
151
|
// request (e.g. OptionsController protecting a secret-looking option), not bad credentials:
|
|
155
152
|
// pointing at `lps project config` there sends the user the wrong way. WordPress core's own
|
|
156
153
|
// auth failures use `{code, message}` instead and keep the credentials hint.
|
|
157
|
-
const refusal = extractLoopressError(
|
|
154
|
+
const refusal = extractLoopressError(body);
|
|
158
155
|
if (refusal)
|
|
159
156
|
return `Request refused (${status}) on ${url}: ${refusal}`;
|
|
160
157
|
return `Authentication failed (${status}) on ${url}. Check your credentials with \`lps project config\`.`;
|
|
161
158
|
}
|
|
162
159
|
if (status === 404) {
|
|
163
|
-
const reason = extractServerErrorMessage(
|
|
160
|
+
const reason = extractServerErrorMessage(body);
|
|
164
161
|
return reason
|
|
165
162
|
? `Request failed (404) on ${url}: ${reason}`
|
|
166
163
|
: `Endpoint not found (404) on ${url}. Is the required plugin installed and up to date on the site?`;
|
|
167
164
|
}
|
|
168
165
|
if (status !== undefined) {
|
|
169
|
-
const reason = extractServerErrorMessage(
|
|
166
|
+
const reason = extractServerErrorMessage(body);
|
|
170
167
|
return reason ? `Request failed (${status}) on ${url}: ${reason}` : `Request failed (${status}) on ${url}.`;
|
|
171
168
|
}
|
|
172
|
-
if (
|
|
169
|
+
if (isRecord(error) && error.name === 'TimeoutError') {
|
|
173
170
|
return `Request timed out after ${timeoutMs / 1000}s on ${url}. Is the site reachable?`;
|
|
174
171
|
}
|
|
175
|
-
const
|
|
172
|
+
const code = errorCode(error);
|
|
173
|
+
const reason = code ? NETWORK_ERROR_HINTS[code] : undefined;
|
|
176
174
|
// The site, not the endpoint: every resource fails the same way when the site is down, so
|
|
177
175
|
// `lps diff` can print it once (see DiffCommand.report).
|
|
178
176
|
if (reason)
|
|
179
177
|
return `Could not reach ${new URL(url).origin}: ${reason}. Run \`lps doctor\` to check the environment.`;
|
|
180
178
|
// Node leaves `message` empty on some network errors (an AggregateError over IPv4 + IPv6):
|
|
181
179
|
// fall back to the error code so the reason is never blank.
|
|
182
|
-
|
|
180
|
+
const message = isRecord(error) && typeof error.message === 'string' ? error.message : '';
|
|
181
|
+
return `Request to ${url} failed: ${message || (code ?? String(error))}`;
|
|
183
182
|
}
|
|
184
183
|
const NETWORK_ERROR_HINTS = {
|
|
185
184
|
ECONNREFUSED: 'connection refused, the site may be down',
|
|
@@ -188,15 +187,8 @@ const NETWORK_ERROR_HINTS = {
|
|
|
188
187
|
ETIMEDOUT: 'connection timed out',
|
|
189
188
|
};
|
|
190
189
|
function extractLoopressError(body) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
try {
|
|
194
|
-
const { error } = JSON.parse(body);
|
|
195
|
-
return typeof error === 'string' && error.trim() ? decodeEntities(error) : undefined;
|
|
196
|
-
}
|
|
197
|
-
catch {
|
|
198
|
-
return undefined;
|
|
199
|
-
}
|
|
190
|
+
const { error } = parseBody(body);
|
|
191
|
+
return typeof error === 'string' && error.trim() ? decodeEntities(error) : undefined;
|
|
200
192
|
}
|
|
201
193
|
// Plugin exception messages go through esc_html() (WordPress coding standards require it for
|
|
202
194
|
// anything thrown), so quotes and ampersands reach the JSON body as entities. They're meant for
|
|
@@ -219,24 +211,17 @@ function decodeEntities(text) {
|
|
|
219
211
|
// specifically so a caller that only reads `error` doesn't see it, which is exactly what this
|
|
220
212
|
// function used to do, hiding the one piece of text that actually explains the failure.
|
|
221
213
|
function extractServerErrorMessage(body) {
|
|
222
|
-
|
|
214
|
+
const parsed = parseBody(body);
|
|
215
|
+
// WP core's own generic body for a route it never registered (e.g. a plugin too old to
|
|
216
|
+
// expose this endpoint yet) is never more useful than the "is the plugin up to date?"
|
|
217
|
+
// fallback below; surfacing it verbatim buries a version-skew mismatch behind text that
|
|
218
|
+
// reads like the endpoint doesn't exist at all, pointing the user in the wrong direction.
|
|
219
|
+
if (parsed.code === 'rest_no_route')
|
|
223
220
|
return undefined;
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
if (parsed.code === 'rest_no_route')
|
|
231
|
-
return undefined;
|
|
232
|
-
const reason = parsed.error ?? parsed.message;
|
|
233
|
-
const summary = typeof reason === 'string' && reason.trim() ? decodeEntities(reason) : undefined;
|
|
234
|
-
const detail = typeof parsed.output === 'string' && parsed.output.trim() ? parsed.output.trim() : undefined;
|
|
235
|
-
if (summary && detail)
|
|
236
|
-
return `${summary}\n${detail}`;
|
|
237
|
-
return summary ?? detail;
|
|
238
|
-
}
|
|
239
|
-
catch {
|
|
240
|
-
return undefined;
|
|
241
|
-
}
|
|
221
|
+
const reason = parsed.error ?? parsed.message;
|
|
222
|
+
const summary = typeof reason === 'string' && reason.trim() ? decodeEntities(reason) : undefined;
|
|
223
|
+
const detail = typeof parsed.output === 'string' && parsed.output.trim() ? parsed.output.trim() : undefined;
|
|
224
|
+
if (summary && detail)
|
|
225
|
+
return `${summary}\n${detail}`;
|
|
226
|
+
return summary ?? detail;
|
|
242
227
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import got from 'got';
|
|
2
|
+
import { errorMessage, isRecord } from './narrow.js';
|
|
2
3
|
import { withSpinner } from './spinner.js';
|
|
3
4
|
export const REQUEST_TIMEOUT_MS = 10_000;
|
|
4
5
|
/**
|
|
@@ -28,10 +29,10 @@ export async function diagnoseWpSite(siteUrl) {
|
|
|
28
29
|
catch (error) {
|
|
29
30
|
return {
|
|
30
31
|
ok: false,
|
|
31
|
-
reason: `Could not reach the WordPress REST API at ${siteUrl}/wp-json/. The site may be unreachable, or a security plugin may be blocking it. (${
|
|
32
|
+
reason: `Could not reach the WordPress REST API at ${siteUrl}/wp-json/. The site may be unreachable, or a security plugin may be blocking it. (${errorMessage(error)})`,
|
|
32
33
|
};
|
|
33
34
|
}
|
|
34
|
-
if (!index?.authentication?.['application-passwords']) {
|
|
35
|
+
if (!isRecord(index?.authentication?.['application-passwords'])) {
|
|
35
36
|
return {
|
|
36
37
|
ok: false,
|
|
37
38
|
reason: `Application Passwords are not available on ${siteUrl}. The site may be older than WordPress 5.6, require HTTPS, or have the feature disabled by a plugin or filter.`,
|
|
@@ -69,6 +70,3 @@ async function detectWpVersion(siteUrl) {
|
|
|
69
70
|
}
|
|
70
71
|
return undefined;
|
|
71
72
|
}
|
|
72
|
-
function describe(error) {
|
|
73
|
-
return error instanceof Error ? error.message : String(error);
|
|
74
|
-
}
|
|
@@ -54,7 +54,7 @@ export interface EnvironmentConfig {
|
|
|
54
54
|
*/
|
|
55
55
|
name: string;
|
|
56
56
|
/**
|
|
57
|
-
*
|
|
57
|
+
* WordPress Application Password credentials for this environment, as "user:password". Not the Loopress account token.
|
|
58
58
|
*/
|
|
59
59
|
token?: string;
|
|
60
60
|
/**
|
package/dist/types/plugin.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
1
2
|
export type InstalledPlugin = {
|
|
2
3
|
active: boolean;
|
|
3
4
|
file: string;
|
|
@@ -12,11 +13,12 @@ export type WpNativePlugin = {
|
|
|
12
13
|
status: 'active' | 'inactive' | 'network-active';
|
|
13
14
|
version: string;
|
|
14
15
|
};
|
|
15
|
-
export
|
|
16
|
-
active
|
|
17
|
-
version:
|
|
18
|
-
}
|
|
16
|
+
export declare const PLUGIN_PIN: z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
17
|
+
active: z.ZodExactOptional<z.ZodBoolean>;
|
|
18
|
+
version: z.ZodString;
|
|
19
|
+
}, z.core.$strip>]>;
|
|
20
|
+
export type PluginPin = z.infer<typeof PLUGIN_PIN>;
|
|
19
21
|
export type PluginManifest = Record<string, PluginPin>;
|
|
20
22
|
export declare const pinVersion: (pin: PluginPin) => string;
|
|
21
|
-
export declare const pinActive: (pin: PluginPin) => boolean;
|
|
23
|
+
export declare const pinActive: (pin: PluginPin | undefined) => boolean;
|
|
22
24
|
export declare const pinVersions: (manifest: PluginManifest) => Record<string, string>;
|
package/dist/types/plugin.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
// A bare version string means "installed and active"; the object form records a plugin that
|
|
3
|
+
// must stay installed but inactive.
|
|
4
|
+
export const PLUGIN_PIN = z.union([z.string(), z.object({ active: z.boolean().exactOptional(), version: z.string() })]);
|
|
1
5
|
export const pinVersion = (pin) => (typeof pin === 'string' ? pin : pin.version);
|
|
2
|
-
|
|
6
|
+
// No pin at all (a slug loopress.json doesn't list) keeps the default: active.
|
|
7
|
+
export const pinActive = (pin) => typeof pin === 'string' || pin?.active !== false;
|
|
3
8
|
// The version-only view the Composer sync endpoint expects.
|
|
4
9
|
export const pinVersions = (manifest) => Object.fromEntries(Object.entries(manifest).map(([slug, pin]) => [slug, pinVersion(pin)]));
|
|
@@ -23,6 +23,10 @@ export interface LoopressProjectConfiguration {
|
|
|
23
23
|
* Directory where custom API route files are read from and written to, relative to rootDir.
|
|
24
24
|
*/
|
|
25
25
|
apiDir?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Directory of dynamic blocks, one <slug>/ folder holding block.json and render.php, registered as loopress/<slug>, relative to rootDir.
|
|
28
|
+
*/
|
|
29
|
+
blockDir?: string;
|
|
26
30
|
/**
|
|
27
31
|
* Directory where hook files (WordPress actions, filters, and scheduled tasks) are read from and written to, relative to rootDir.
|
|
28
32
|
*/
|
|
@@ -103,7 +107,11 @@ export interface LoopressProjectConfiguration {
|
|
|
103
107
|
};
|
|
104
108
|
};
|
|
105
109
|
/**
|
|
106
|
-
*
|
|
110
|
+
* Keep the Loopress child theme (<parent>-loopress) active: `lps theme template push`, and so `lps push`, activates it when it isn't. Off by default, a theme switch changes the settings WordPress stores per theme.
|
|
111
|
+
*/
|
|
112
|
+
activateChildTheme?: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* WordPress.org themes managed by Loopress, installed via Composer + WPackagist on the site. Keys are theme slugs; values are an exact version or "latest". Loopress manages installed theme versions only, it never switches the active theme (see activateChildTheme and `lps theme activate`). Ignored when a composer.json exists in rootDir.
|
|
107
115
|
*/
|
|
108
116
|
themes?: {
|
|
109
117
|
[k: string]: string;
|
package/dist/types/snippet.d.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { type SnippetInsertMethod, type SnippetLocation, type SnippetType } from
|
|
|
2
2
|
export type Snippet = {
|
|
3
3
|
active: boolean;
|
|
4
4
|
code: string;
|
|
5
|
-
description?: string;
|
|
6
|
-
id?: number;
|
|
5
|
+
description?: string | undefined;
|
|
6
|
+
id?: number | undefined;
|
|
7
7
|
insertMethod: SnippetInsertMethod;
|
|
8
8
|
location: SnippetLocation;
|
|
9
9
|
name: string;
|
package/dist/types/snippet.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
import {} from '../utils/snippet-format.js';
|
package/dist/utils/acf-format.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
import { isRecord } from '../lib/narrow.js';
|
|
2
3
|
export const ACF_OBJECT_TYPES = ['field-groups', 'post-types', 'taxonomies', 'options-pages'];
|
|
3
4
|
export function acfEndpoint(type) {
|
|
4
5
|
return `loopress/v1/acf/${type}`;
|
|
@@ -23,21 +24,23 @@ export function getAcfKey(data) {
|
|
|
23
24
|
return typeof data.key === 'string' && data.key.trim() !== '' ? data.key : null;
|
|
24
25
|
}
|
|
25
26
|
function topLevelFields(group) {
|
|
26
|
-
const fields = group
|
|
27
|
+
const fields = isRecord(group) ? group.fields : undefined;
|
|
27
28
|
const byKey = new Map();
|
|
28
29
|
for (const field of Array.isArray(fields) ? fields : []) {
|
|
29
|
-
if (typeof field
|
|
30
|
+
if (isRecord(field) && typeof field.key === 'string')
|
|
30
31
|
byKey.set(field.key, field);
|
|
31
32
|
}
|
|
32
33
|
return byKey;
|
|
33
34
|
}
|
|
35
|
+
function label(field) {
|
|
36
|
+
return typeof field.name === 'string' && field.name !== '' ? field.name : String(field.key);
|
|
37
|
+
}
|
|
34
38
|
// A changed field group's top-level fields, matched by `key` and named by `name` (a repeater's
|
|
35
39
|
// sub-field change shows as its parent changing). Only for the dry-run preview: the push itself
|
|
36
40
|
// always sends the whole group.
|
|
37
41
|
export function acfFieldChanges(before, after) {
|
|
38
42
|
const old = topLevelFields(before);
|
|
39
43
|
const next = topLevelFields(after);
|
|
40
|
-
const label = (field) => (typeof field.name === 'string' && field.name !== '' ? field.name : String(field.key));
|
|
41
44
|
const changes = { added: [], changed: [], removed: [] };
|
|
42
45
|
for (const [key, field] of next) {
|
|
43
46
|
if (!old.has(key))
|