@loopress/cli 0.23.0 → 0.24.1
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 +603 -94
- package/dist/commands/acf/diff.d.ts +2 -0
- package/dist/commands/acf/diff.js +5 -0
- package/dist/commands/api/diff.d.ts +2 -0
- package/dist/commands/api/diff.js +5 -0
- package/dist/commands/api/list.d.ts +2 -12
- package/dist/commands/api/list.js +2 -20
- package/dist/commands/api/pull.d.ts +2 -20
- package/dist/commands/api/pull.js +2 -53
- package/dist/commands/api/push.d.ts +2 -22
- package/dist/commands/api/push.js +2 -116
- package/dist/commands/api/rm.d.ts +2 -0
- package/dist/commands/api/rm.js +2 -0
- package/dist/commands/composer/pull.js +6 -16
- package/dist/commands/composer/push.d.ts +3 -0
- package/dist/commands/composer/push.js +24 -7
- package/dist/commands/dev.js +1 -1
- package/dist/commands/diff.d.ts +13 -0
- package/dist/commands/diff.js +42 -0
- package/dist/commands/form/diff.d.ts +2 -0
- package/dist/commands/form/diff.js +5 -0
- package/dist/commands/form/push.d.ts +2 -0
- package/dist/commands/form/push.js +11 -4
- package/dist/commands/hook/diff.d.ts +2 -0
- package/dist/commands/hook/diff.js +5 -0
- package/dist/commands/hook/list.d.ts +2 -0
- package/dist/commands/hook/list.js +2 -0
- package/dist/commands/hook/pull.d.ts +2 -0
- package/dist/commands/hook/pull.js +2 -0
- package/dist/commands/hook/push.d.ts +2 -0
- package/dist/commands/hook/push.js +2 -0
- package/dist/commands/hook/rm.d.ts +2 -0
- package/dist/commands/hook/rm.js +2 -0
- package/dist/commands/init.js +1 -1
- package/dist/commands/login.js +3 -2
- package/dist/commands/option/add.d.ts +15 -0
- package/dist/commands/option/add.js +47 -0
- package/dist/commands/option/diff.d.ts +2 -0
- package/dist/commands/option/diff.js +5 -0
- package/dist/commands/{page → option}/list.d.ts +5 -1
- package/dist/commands/option/list.js +151 -0
- package/dist/commands/{page → option}/pull.d.ts +3 -12
- package/dist/commands/option/pull.js +75 -0
- package/dist/commands/{page → option}/push.d.ts +3 -8
- package/dist/commands/option/push.js +66 -0
- package/dist/commands/option/remove.d.ts +22 -0
- package/dist/commands/option/remove.js +69 -0
- package/dist/commands/plugin/push.d.ts +0 -1
- package/dist/commands/plugin/push.js +3 -10
- package/dist/commands/pull.js +3 -2
- package/dist/commands/push.js +4 -3
- package/dist/commands/seo/diff.d.ts +2 -0
- package/dist/commands/seo/diff.js +5 -0
- package/dist/commands/seo/push.d.ts +2 -0
- package/dist/commands/seo/push.js +16 -4
- package/dist/commands/snippet/diff.d.ts +2 -0
- package/dist/commands/snippet/diff.js +5 -0
- package/dist/commands/theme/push.d.ts +0 -1
- package/dist/commands/theme/push.js +3 -10
- package/dist/hooks/finally.js +4 -1
- package/dist/lib/base.d.ts +2 -1
- package/dist/lib/base.js +12 -22
- package/dist/lib/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +1 -2
- package/dist/lib/diff-command.d.ts +47 -0
- package/dist/lib/diff-command.js +180 -0
- package/dist/lib/diff-state.d.ts +22 -0
- package/dist/lib/diff-state.js +88 -0
- package/dist/lib/find-orphaned-files.js +1 -1
- package/dist/lib/interactive.d.ts +1 -0
- package/dist/lib/interactive.js +10 -0
- package/dist/lib/local-callback-server.d.ts +14 -1
- package/dist/lib/local-callback-server.js +40 -1
- package/dist/lib/php-files-command.d.ts +67 -0
- package/dist/lib/php-files-command.js +338 -0
- package/dist/lib/put-or-create.js +1 -1
- package/dist/lib/readdir-tolerant.d.ts +1 -0
- package/dist/lib/readdir-tolerant.js +3 -1
- package/dist/lib/resource-state.d.ts +14 -0
- package/dist/lib/resource-state.js +361 -0
- package/dist/lib/sentry.d.ts +3 -0
- package/dist/lib/sentry.js +30 -0
- package/dist/lib/validate-local.js +15 -7
- package/dist/lib/wp-authorize-flow.d.ts +4 -0
- package/dist/lib/wp-authorize-flow.js +10 -8
- package/dist/lib/wp-client.d.ts +5 -0
- package/dist/lib/wp-client.js +56 -0
- package/dist/types/project-config.generated.d.ts +8 -4
- package/dist/utils/option-format.d.ts +31 -0
- package/dist/utils/option-format.js +69 -0
- package/dist/utils/plugin-sync.d.ts +6 -0
- package/dist/utils/resource-dirs.d.ts +13 -0
- package/dist/utils/resource-dirs.js +34 -0
- package/oclif.manifest.json +1488 -301
- package/package.json +11 -8
- package/schemas/project-config.schema.json +10 -5
- package/dist/commands/page/list.js +0 -19
- package/dist/commands/page/pull.js +0 -57
- package/dist/commands/page/push.js +0 -132
- package/dist/utils/page-format.d.ts +0 -7
- package/dist/utils/page-format.js +0 -78
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, join, relative, sep } from 'node:path';
|
|
3
|
+
import { ACF_OBJECT_TYPES, acfEndpoint, getAcfKey } from '../utils/acf-format.js';
|
|
4
|
+
import { FORM_ENDPOINT, getFormId } from '../utils/form-format.js';
|
|
5
|
+
import { optionEndpoint, parseLocalOption } from '../utils/option-format.js';
|
|
6
|
+
import { DEFAULT_POST_TYPES, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, } from '../utils/seo-format.js';
|
|
7
|
+
import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../utils/snippet-format.js';
|
|
8
|
+
import { loadFiles } from './load-files.js';
|
|
9
|
+
import { loadSnippets } from './load-snippets.js';
|
|
10
|
+
import { isApplicative404, isNotFoundError } from './wp-client.js';
|
|
11
|
+
function readJson(raw) {
|
|
12
|
+
const parsed = JSON.parse(raw);
|
|
13
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
14
|
+
throw new Error('not a JSON object');
|
|
15
|
+
}
|
|
16
|
+
return parsed;
|
|
17
|
+
}
|
|
18
|
+
// Removes top-level keys that the server rewrites without the tracked configuration itself
|
|
19
|
+
// changing (save timestamps, hit counters), so a `diff` between two independently-configured
|
|
20
|
+
// environments reports real differences only. Top-level only: a nested user field that happens
|
|
21
|
+
// to share a name is never touched.
|
|
22
|
+
function omit(object, keys) {
|
|
23
|
+
return Object.fromEntries(Object.entries(object).filter(([key]) => !keys.includes(key)));
|
|
24
|
+
}
|
|
25
|
+
// ACF's export JSON carries `modified` (a unix timestamp bumped on every save in wp-admin).
|
|
26
|
+
const ACF_VOLATILE_KEYS = ['modified'];
|
|
27
|
+
// A WPForms form is a WP post; `modified`/`modified_gmt` move on every edit.
|
|
28
|
+
const FORM_VOLATILE_KEYS = ['modified', 'modified_gmt'];
|
|
29
|
+
// A RankMath redirect's `hits` is a live visit counter.
|
|
30
|
+
const REDIRECT_VOLATILE_KEYS = ['hits'];
|
|
31
|
+
// ---- snippets -------------------------------------------------------------------------------
|
|
32
|
+
// `description` is dropped: `snippet push` overwrites it with "Imported from <path>" on every
|
|
33
|
+
// push and `loadSnippets` never reads it back, so it is not part of the synced state. `id`
|
|
34
|
+
// becomes the map key. `code` has its leading `<?php` stripped on both sides: `snippet pull`
|
|
35
|
+
// re-adds it to PHP files for editor friendliness, WordPress stores only the body.
|
|
36
|
+
function canonicalSnippet(snippet) {
|
|
37
|
+
return {
|
|
38
|
+
active: snippet.active,
|
|
39
|
+
code: stripPhpOpeningTag(snippet.code),
|
|
40
|
+
insertMethod: snippet.insertMethod,
|
|
41
|
+
location: snippet.location,
|
|
42
|
+
name: snippet.name,
|
|
43
|
+
priority: snippet.priority,
|
|
44
|
+
shortcodeAttributes: [...snippet.shortcodeAttributes],
|
|
45
|
+
tags: [...snippet.tags],
|
|
46
|
+
type: snippet.type,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const snippetProvider = {
|
|
50
|
+
dirKind: 'snippets',
|
|
51
|
+
async local(dir) {
|
|
52
|
+
const state = new Map();
|
|
53
|
+
let snippets;
|
|
54
|
+
try {
|
|
55
|
+
snippets = await loadSnippets(dir);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
// A directory that was never created is "nothing tracked locally", the same tolerance
|
|
59
|
+
// loadFiles/readdirTolerant already have; loadSnippets wraps the ENOENT in its own Error.
|
|
60
|
+
if (error.cause?.code === 'ENOENT')
|
|
61
|
+
return state;
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
for (const snippet of snippets) {
|
|
65
|
+
const id = snippet.id === undefined ? `local:${basename(snippet.path)}` : String(snippet.id);
|
|
66
|
+
state.set(id, canonicalSnippet(snippet));
|
|
67
|
+
}
|
|
68
|
+
return state;
|
|
69
|
+
},
|
|
70
|
+
async remote(wp) {
|
|
71
|
+
const raw = await wp.get(SNIPPETS_ENDPOINT);
|
|
72
|
+
const state = new Map();
|
|
73
|
+
for (const item of raw) {
|
|
74
|
+
const snippet = normalizeSnippet(item);
|
|
75
|
+
state.set(String(snippet.id), canonicalSnippet(snippet));
|
|
76
|
+
}
|
|
77
|
+
return state;
|
|
78
|
+
},
|
|
79
|
+
resource: 'snippet',
|
|
80
|
+
title: 'Snippets',
|
|
81
|
+
};
|
|
82
|
+
// ---- forms -------------------------------------------------------------------------------
|
|
83
|
+
// The form plugin's own JSON (WPForms today) round-trips untouched through pull/push, so
|
|
84
|
+
// (minus save timestamps) the whole object is the compared value, same "deliberately loose"
|
|
85
|
+
// stance as form-format.ts.
|
|
86
|
+
const formProvider = {
|
|
87
|
+
dirKind: 'form',
|
|
88
|
+
async local(dir, onWarn) {
|
|
89
|
+
const entries = await loadFiles(dir, {
|
|
90
|
+
extension: '.json',
|
|
91
|
+
onSkip: onWarn,
|
|
92
|
+
parse: (raw, filePath) => ({ file: basename(filePath, '.json'), value: readJson(raw) }),
|
|
93
|
+
});
|
|
94
|
+
const state = new Map();
|
|
95
|
+
for (const { file, value } of entries) {
|
|
96
|
+
const formId = getFormId(value);
|
|
97
|
+
state.set(formId === null ? `local:${file}` : String(formId), omit(value, FORM_VOLATILE_KEYS));
|
|
98
|
+
}
|
|
99
|
+
return state;
|
|
100
|
+
},
|
|
101
|
+
async remote(wp) {
|
|
102
|
+
const raw = await wp.get(FORM_ENDPOINT);
|
|
103
|
+
const state = new Map();
|
|
104
|
+
for (const form of raw) {
|
|
105
|
+
const formId = getFormId(form);
|
|
106
|
+
if (formId !== null)
|
|
107
|
+
state.set(String(formId), omit(form, FORM_VOLATILE_KEYS));
|
|
108
|
+
}
|
|
109
|
+
return state;
|
|
110
|
+
},
|
|
111
|
+
resource: 'form',
|
|
112
|
+
title: 'Forms',
|
|
113
|
+
};
|
|
114
|
+
// ---- ACF --------------------------------------------------------------------------------
|
|
115
|
+
// ACF's export JSON is large, deeply nested, and versioned by ACF itself; `key` is the stable
|
|
116
|
+
// identity and the whole object (minus the `modified` timestamp) round-trips untouched, so it
|
|
117
|
+
// is all compared. Ids are namespaced by object type (`field-groups/group_x`) since keys are
|
|
118
|
+
// only unique within a type.
|
|
119
|
+
const acfProvider = {
|
|
120
|
+
dirKind: 'acf',
|
|
121
|
+
async local(dir, onWarn) {
|
|
122
|
+
const state = new Map();
|
|
123
|
+
for (const type of ACF_OBJECT_TYPES) {
|
|
124
|
+
const objects = await loadFiles(join(dir, type), {
|
|
125
|
+
extension: '.json',
|
|
126
|
+
onSkip: onWarn,
|
|
127
|
+
parse: readJson,
|
|
128
|
+
});
|
|
129
|
+
for (const object of objects) {
|
|
130
|
+
const key = getAcfKey(object);
|
|
131
|
+
if (key !== null)
|
|
132
|
+
state.set(`${type}/${key}`, omit(object, ACF_VOLATILE_KEYS));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return state;
|
|
136
|
+
},
|
|
137
|
+
async remote(wp) {
|
|
138
|
+
const state = new Map();
|
|
139
|
+
for (const type of ACF_OBJECT_TYPES) {
|
|
140
|
+
const raw = await wp.get(acfEndpoint(type));
|
|
141
|
+
for (const object of raw) {
|
|
142
|
+
const key = getAcfKey(object);
|
|
143
|
+
if (key !== null)
|
|
144
|
+
state.set(`${type}/${key}`, omit(object, ACF_VOLATILE_KEYS));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return state;
|
|
148
|
+
},
|
|
149
|
+
resource: 'acf',
|
|
150
|
+
title: 'ACF',
|
|
151
|
+
};
|
|
152
|
+
// ---- PHP file directories (API routes, hooks) -----------------------------------------
|
|
153
|
+
// api/ and hooks/ are both just a recursive directory of PHP files whose text is compared
|
|
154
|
+
// verbatim (line diff on change), no server-side normalization to undo. Only the directory
|
|
155
|
+
// kind, the endpoint and the labels differ.
|
|
156
|
+
function phpFilesProvider(spec) {
|
|
157
|
+
const toState = (files) => {
|
|
158
|
+
const state = new Map();
|
|
159
|
+
for (const file of files)
|
|
160
|
+
state.set(file.filename, file.content);
|
|
161
|
+
return state;
|
|
162
|
+
};
|
|
163
|
+
return {
|
|
164
|
+
dirKind: spec.dirKind,
|
|
165
|
+
async local(dir, onWarn) {
|
|
166
|
+
return toState(await loadFiles(dir, {
|
|
167
|
+
extension: '.php',
|
|
168
|
+
onSkip: onWarn,
|
|
169
|
+
parse: (raw, filePath) => ({
|
|
170
|
+
content: raw,
|
|
171
|
+
filename: relative(dir, filePath).slice(0, -'.php'.length).split(sep).join('/'),
|
|
172
|
+
}),
|
|
173
|
+
recursive: true,
|
|
174
|
+
}));
|
|
175
|
+
},
|
|
176
|
+
async remote(wp) {
|
|
177
|
+
return toState(await wp.get(spec.endpoint));
|
|
178
|
+
},
|
|
179
|
+
resource: spec.resource,
|
|
180
|
+
title: spec.title,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const apiProvider = phpFilesProvider({
|
|
184
|
+
dirKind: 'api',
|
|
185
|
+
endpoint: 'loopress/v1/api-files',
|
|
186
|
+
resource: 'api',
|
|
187
|
+
title: 'API routes',
|
|
188
|
+
});
|
|
189
|
+
const hookProvider = phpFilesProvider({
|
|
190
|
+
dirKind: 'hooks',
|
|
191
|
+
endpoint: 'loopress/v1/hook-files',
|
|
192
|
+
resource: 'hook',
|
|
193
|
+
title: 'Hooks',
|
|
194
|
+
});
|
|
195
|
+
// ---- SEO --------------------------------------------------------------------------------
|
|
196
|
+
function canonicalRedirect(redirect) {
|
|
197
|
+
return omit(redirect, REDIRECT_VOLATILE_KEYS);
|
|
198
|
+
}
|
|
199
|
+
const seoProvider = {
|
|
200
|
+
dirKind: 'seo',
|
|
201
|
+
async local(dir, onWarn) {
|
|
202
|
+
const state = new Map();
|
|
203
|
+
// The provider settings blob (Yoast / RankMath) is compared whole. If a real payload turns
|
|
204
|
+
// out to carry install-specific noise (a plugin version, a license hash), add those keys
|
|
205
|
+
// to an omit() list here, the e2e round-trip is what will surface them.
|
|
206
|
+
const settingsPath = join(dir, 'settings.json');
|
|
207
|
+
try {
|
|
208
|
+
const settings = readJson(await readFile(settingsPath, 'utf8'));
|
|
209
|
+
state.set('settings', settings);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
if (error.code !== 'ENOENT') {
|
|
213
|
+
onWarn(`Skipping "${settingsPath}": ${error.message}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
for (const postType of DEFAULT_POST_TYPES) {
|
|
217
|
+
const metaDir = join(dir, 'post-meta', postType);
|
|
218
|
+
const entries = await loadFiles(metaDir, {
|
|
219
|
+
extension: '.json',
|
|
220
|
+
onSkip: onWarn,
|
|
221
|
+
parse: (raw, filePath) => ({ file: basename(filePath, '.json'), value: readJson(raw) }),
|
|
222
|
+
});
|
|
223
|
+
for (const { file, value } of entries) {
|
|
224
|
+
const slug = typeof value.slug === 'string' && value.slug !== '' ? value.slug : `local:${file}`;
|
|
225
|
+
state.set(`post-meta/${postType}/${slug}`, value);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const redirects = await loadFiles(join(dir, 'redirects'), {
|
|
229
|
+
extension: '.json',
|
|
230
|
+
onSkip: onWarn,
|
|
231
|
+
parse: readJson,
|
|
232
|
+
});
|
|
233
|
+
for (const redirect of redirects) {
|
|
234
|
+
const id = Number(redirect.id);
|
|
235
|
+
if (Number.isSafeInteger(id) && id > 0)
|
|
236
|
+
state.set(`redirects/${id}`, canonicalRedirect(redirect));
|
|
237
|
+
}
|
|
238
|
+
return state;
|
|
239
|
+
},
|
|
240
|
+
async remote(wp, onWarn) {
|
|
241
|
+
const state = new Map();
|
|
242
|
+
const settings = await wp.get(SEO_SETTINGS_ENDPOINT);
|
|
243
|
+
state.set('settings', settings);
|
|
244
|
+
for (const postType of DEFAULT_POST_TYPES) {
|
|
245
|
+
const posts = await wp.get(seoPostMetaEndpoint(postType));
|
|
246
|
+
for (const post of posts)
|
|
247
|
+
state.set(`post-meta/${postType}/${post.slug}`, post);
|
|
248
|
+
}
|
|
249
|
+
// Redirects are a RankMath-only feature; on Yoast the endpoint 404s, same graceful skip
|
|
250
|
+
// as `seo pull`.
|
|
251
|
+
try {
|
|
252
|
+
const redirects = await wp.get(SEO_REDIRECTS_ENDPOINT);
|
|
253
|
+
for (const redirect of redirects) {
|
|
254
|
+
state.set(`redirects/${redirect.id}`, canonicalRedirect(redirect));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
if (!isNotFoundError(error))
|
|
259
|
+
throw error;
|
|
260
|
+
onWarn(`Skipping redirects: not supported by the active SEO plugin`);
|
|
261
|
+
}
|
|
262
|
+
return state;
|
|
263
|
+
},
|
|
264
|
+
resource: 'seo',
|
|
265
|
+
title: 'SEO',
|
|
266
|
+
};
|
|
267
|
+
// ---- Options ------------------------------------------------------------------------------
|
|
268
|
+
// Unlike every provider above, remote() has no "everything" endpoint to read: GET /options only
|
|
269
|
+
// lists names, never values (by design, see OptionsController), so both sides compare exactly
|
|
270
|
+
// the locally tracked set, read from `dir` on the remote side too (see ResourceStateProvider's
|
|
271
|
+
// docstring). `readonly` is a local policy flag with no WordPress counterpart, left out of both
|
|
272
|
+
// sides so it can never itself show up as drift.
|
|
273
|
+
const optionsProvider = {
|
|
274
|
+
dirKind: 'options',
|
|
275
|
+
async local(dir, onWarn) {
|
|
276
|
+
const entries = await loadFiles(dir, {
|
|
277
|
+
extension: '.json',
|
|
278
|
+
onSkip: onWarn,
|
|
279
|
+
parse(raw) {
|
|
280
|
+
const option = parseLocalOption(raw);
|
|
281
|
+
return { autoload: option.autoload, name: option.name, value: option.value };
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
const state = new Map();
|
|
285
|
+
for (const { autoload, name, value } of entries)
|
|
286
|
+
state.set(name, { autoload, value });
|
|
287
|
+
return state;
|
|
288
|
+
},
|
|
289
|
+
async remote(wp, onWarn, dir) {
|
|
290
|
+
const tracked = await loadFiles(dir, {
|
|
291
|
+
extension: '.json',
|
|
292
|
+
onSkip: onWarn,
|
|
293
|
+
parse: (raw) => parseLocalOption(raw).name,
|
|
294
|
+
});
|
|
295
|
+
const state = new Map();
|
|
296
|
+
for (const name of tracked) {
|
|
297
|
+
try {
|
|
298
|
+
const option = await wp.get(optionEndpoint(name));
|
|
299
|
+
state.set(name, { autoload: option.autoload, value: option.value });
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
if (!isNotFoundError(error))
|
|
303
|
+
throw error;
|
|
304
|
+
// Absent on this environment (never pushed here, or deleted there): left out of the
|
|
305
|
+
// map, which reads as "removed" on this side, exactly the right signal.
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return state;
|
|
309
|
+
},
|
|
310
|
+
resource: 'option',
|
|
311
|
+
title: 'Options',
|
|
312
|
+
};
|
|
313
|
+
export const RESOURCE_STATE_PROVIDERS = [
|
|
314
|
+
snippetProvider,
|
|
315
|
+
formProvider,
|
|
316
|
+
acfProvider,
|
|
317
|
+
apiProvider,
|
|
318
|
+
hookProvider,
|
|
319
|
+
seoProvider,
|
|
320
|
+
optionsProvider,
|
|
321
|
+
];
|
|
322
|
+
export function getResourceStateProvider(resource) {
|
|
323
|
+
const provider = RESOURCE_STATE_PROVIDERS.find((candidate) => candidate.resource === resource);
|
|
324
|
+
if (!provider)
|
|
325
|
+
throw new Error(`No resource-state provider for "${resource}"`);
|
|
326
|
+
return provider;
|
|
327
|
+
}
|
|
328
|
+
// ---- Composer ---------------------------------------------------------------------------
|
|
329
|
+
// Composer isn't a directory of items but two files at the project root, so it sits outside
|
|
330
|
+
// RESOURCE_STATE_PROVIDERS. The compared value is each file's text.
|
|
331
|
+
const COMPOSER_JSON_ENDPOINT = 'loopress/v1/composer/json';
|
|
332
|
+
const COMPOSER_LOCK_ENDPOINT = 'loopress/v1/composer/lock';
|
|
333
|
+
export async function composerRemoteState(wp) {
|
|
334
|
+
const state = new Map();
|
|
335
|
+
const { composerJson } = await wp.get(COMPOSER_JSON_ENDPOINT);
|
|
336
|
+
state.set('composer.json', composerJson);
|
|
337
|
+
try {
|
|
338
|
+
const { composerLock } = await wp.get(COMPOSER_LOCK_ENDPOINT);
|
|
339
|
+
state.set('composer.lock', composerLock);
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
// A site that never pushed Composer dependencies has no lock yet, the same tolerated 404
|
|
343
|
+
// as `composer pull`.
|
|
344
|
+
if (!isApplicative404(error, 'composer.lock not found'))
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
return state;
|
|
348
|
+
}
|
|
349
|
+
export async function composerLocalState(rootDir) {
|
|
350
|
+
const state = new Map();
|
|
351
|
+
for (const file of ['composer.json', 'composer.lock']) {
|
|
352
|
+
try {
|
|
353
|
+
state.set(file, await readFile(join(rootDir, file), 'utf8'));
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
if (error.code !== 'ENOENT')
|
|
357
|
+
throw error;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return state;
|
|
361
|
+
}
|
package/dist/lib/sentry.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ErrorEvent } from '@sentry/node';
|
|
1
2
|
export declare const SENTRY_DSN = "https://a08dd56bfffc2a45d5b8f665e4cb8b7d@o4511586904309760.ingest.de.sentry.io/4511673275973712";
|
|
2
3
|
export declare function isTelemetryDisabled(): boolean;
|
|
3
4
|
export declare function resolveEnvironment(): string;
|
|
@@ -6,3 +7,5 @@ export declare function runtimeContext(): {
|
|
|
6
7
|
os: string;
|
|
7
8
|
};
|
|
8
9
|
export declare function redactArgv(argv: string[]): string[];
|
|
10
|
+
export declare function scrubErrorMessage(value: string): string;
|
|
11
|
+
export declare function scrubEvent(event: ErrorEvent): ErrorEvent;
|
package/dist/lib/sentry.js
CHANGED
|
@@ -31,3 +31,33 @@ export function redactArgv(argv) {
|
|
|
31
31
|
return eqIndex === -1 ? arg : arg.slice(0, eqIndex);
|
|
32
32
|
});
|
|
33
33
|
}
|
|
34
|
+
// The site origin in front of a `/wp-json/` REST path: collapse it but keep the path, which is
|
|
35
|
+
// Loopress's own API surface (identical on every site, no user data) and useful for triage.
|
|
36
|
+
const SITE_PREFIX = /\bhttps?:\/\/[^\s"'<>)]*?\/wp-json\//gi;
|
|
37
|
+
// Any other absolute URL (bare site root, a package URL on the summary line): blank it whole.
|
|
38
|
+
const ANY_URL = /\bhttps?:\/\/[^\s"'<>)]+/gi;
|
|
39
|
+
// A WordPress request failure (`formatWpError`) puts the site URL on the first line and, for a
|
|
40
|
+
// 4xx/5xx, appends the server's raw response body after a newline: a Composer trace, a WP fatal,
|
|
41
|
+
// sometimes absolute server paths, package URLs or a connection string. None of that should
|
|
42
|
+
// leave the user's machine. Keep the first line (the "which endpoint, which status" summary)
|
|
43
|
+
// with the site host blanked, and drop everything after it. The terminal still prints the
|
|
44
|
+
// full, unmodified message.
|
|
45
|
+
export function scrubErrorMessage(value) {
|
|
46
|
+
const firstLine = value.split('\n', 1)[0] ?? '';
|
|
47
|
+
return firstLine.replaceAll(SITE_PREFIX, '<site>/wp-json/').replaceAll(ANY_URL, '<site>').slice(0, 300);
|
|
48
|
+
}
|
|
49
|
+
// Sentry `beforeSend`: runs on every event before it is sent. Scrubs the exception message(s)
|
|
50
|
+
// (including chained `cause` errors, which the linkedErrors integration copies in) and drops
|
|
51
|
+
// any request context. `extra.argv` is already redacted upstream by `redactArgv`.
|
|
52
|
+
export function scrubEvent(event) {
|
|
53
|
+
if (typeof event.message === 'string') {
|
|
54
|
+
event.message = scrubErrorMessage(event.message);
|
|
55
|
+
}
|
|
56
|
+
for (const exception of event.exception?.values ?? []) {
|
|
57
|
+
if (typeof exception.value === 'string') {
|
|
58
|
+
exception.value = scrubErrorMessage(exception.value);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
delete event.request;
|
|
62
|
+
return event;
|
|
63
|
+
}
|
|
@@ -6,12 +6,12 @@ import { loadSnippets } from './load-snippets.js';
|
|
|
6
6
|
import { readdirTolerant } from './readdir-tolerant.js';
|
|
7
7
|
// Directories holding one JSON document per resource. Snippets (code file plus JSON sidecar)
|
|
8
8
|
// and api routes (`.php`, not JSON) are checked separately.
|
|
9
|
-
const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', '
|
|
9
|
+
const JSON_RESOURCE_DIRS = ['acfDir', 'formDir', 'seoDir'];
|
|
10
10
|
const DEFAULT_DIR = {
|
|
11
11
|
acfDir: 'acf',
|
|
12
12
|
apiDir: 'api',
|
|
13
13
|
formDir: 'forms',
|
|
14
|
-
|
|
14
|
+
hooksDir: 'hooks',
|
|
15
15
|
seoDir: 'seo',
|
|
16
16
|
snippetsDir: 'snippets',
|
|
17
17
|
};
|
|
@@ -35,20 +35,28 @@ export async function validateLocal(cwd) {
|
|
|
35
35
|
checked += await checkJsonDir(resolve(key), problems);
|
|
36
36
|
}
|
|
37
37
|
checked += await checkSnippets(resolve('snippetsDir'), problems);
|
|
38
|
-
checked += await
|
|
38
|
+
checked += await checkPhpDir(resolve('apiDir'), problems, 'API route file is empty');
|
|
39
|
+
checked += await checkPhpDir(resolve('hooksDir'), problems, 'Hook file is empty');
|
|
39
40
|
return { checked, problems, valid: problems.length === 0 };
|
|
40
41
|
}
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
// Recursive: both api/ and hooks/ files can live in a nested path-segment directory
|
|
43
|
+
// (invoice-pdf/[order_id].php, content/filters.php), a non-recursive scan would silently
|
|
44
|
+
// never check those.
|
|
45
|
+
async function checkPhpDir(dir, problems, emptyMessage) {
|
|
46
|
+
const entries = await readdirTolerant(dir, { recursive: true, withFileTypes: true });
|
|
43
47
|
let checked = 0;
|
|
44
48
|
for (const entry of entries) {
|
|
45
49
|
if (!entry.isFile() || !entry.name.endsWith('.php'))
|
|
46
50
|
continue;
|
|
47
51
|
checked += 1;
|
|
48
|
-
|
|
52
|
+
// parentPath is the entry's actual containing directory, which only equals `dir` itself
|
|
53
|
+
// for a top-level file: recursive readdir() nests entry.name to the basename alone, so
|
|
54
|
+
// join(dir, entry.name) would silently point at the wrong (top-level) path for anything
|
|
55
|
+
// one level deep or more.
|
|
56
|
+
const filePath = join(entry.parentPath, entry.name);
|
|
49
57
|
const content = await readFile(filePath, 'utf8');
|
|
50
58
|
if (content.trim() === '')
|
|
51
|
-
problems.push({ file: filePath, message:
|
|
59
|
+
problems.push({ file: filePath, message: emptyMessage });
|
|
52
60
|
}
|
|
53
61
|
return checked;
|
|
54
62
|
}
|
|
@@ -18,5 +18,9 @@ export type AuthorizeResult = {
|
|
|
18
18
|
* 5. The API callback returns an HTML page that POSTs a form with the
|
|
19
19
|
* Application Password to the local callback server.
|
|
20
20
|
* 6. The local server extracts the credentials and resolves the promise.
|
|
21
|
+
*
|
|
22
|
+
* The `state` value is threaded through `callbackUrl` (the relay copies it verbatim into the
|
|
23
|
+
* form's POST target), so it comes back as a query param on the credential POST and
|
|
24
|
+
* `waitForLocalCallback` can match it before this handler runs.
|
|
21
25
|
*/
|
|
22
26
|
export declare function authorizeWithBrowser(siteUrl: string, log: (message: string) => void): Promise<AuthorizeResult>;
|
|
@@ -15,13 +15,18 @@ import { renderResultPage, waitForLocalCallback } from './local-callback-server.
|
|
|
15
15
|
* 5. The API callback returns an HTML page that POSTs a form with the
|
|
16
16
|
* Application Password to the local callback server.
|
|
17
17
|
* 6. The local server extracts the credentials and resolves the promise.
|
|
18
|
+
*
|
|
19
|
+
* The `state` value is threaded through `callbackUrl` (the relay copies it verbatim into the
|
|
20
|
+
* form's POST target), so it comes back as a query param on the credential POST and
|
|
21
|
+
* `waitForLocalCallback` can match it before this handler runs.
|
|
18
22
|
*/
|
|
19
23
|
export async function authorizeWithBrowser(siteUrl, log) {
|
|
20
24
|
return waitForLocalCallback({
|
|
21
|
-
|
|
25
|
+
allowedOrigins: ['https://api.loopress.dev'],
|
|
26
|
+
buildUrl(callbackBaseUrl, state) {
|
|
22
27
|
const relayUrl = 'https://api.loopress.dev/auth/wp-authorize';
|
|
23
28
|
const params = new URLSearchParams({
|
|
24
|
-
callbackUrl: callbackBaseUrl
|
|
29
|
+
callbackUrl: `${callbackBaseUrl}?state=${state}`,
|
|
25
30
|
wpUrl: siteUrl,
|
|
26
31
|
});
|
|
27
32
|
return `${relayUrl}?${params}`;
|
|
@@ -31,12 +36,9 @@ export async function authorizeWithBrowser(siteUrl, log) {
|
|
|
31
36
|
rejectWithPage(REJECTED_PAGE, new Error('Authorization rejected in WordPress.'));
|
|
32
37
|
return;
|
|
33
38
|
}
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
const password = body.password || url.searchParams.get('password') || '';
|
|
38
|
-
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
|
39
|
-
const userLogin = body.user_login || url.searchParams.get('user_login') || '';
|
|
39
|
+
// Credentials come from the relay's form POST only. Reading them from the query string
|
|
40
|
+
// would let them land in shell history, proxy logs and the browser address bar (F24).
|
|
41
|
+
const { password, user_login: userLogin } = body;
|
|
40
42
|
if (!password || !userLogin) {
|
|
41
43
|
respondBadRequest('Missing password or user_login');
|
|
42
44
|
return;
|
package/dist/lib/wp-client.d.ts
CHANGED
|
@@ -9,6 +9,9 @@ export declare class WpClient {
|
|
|
9
9
|
constructor(siteUrl: string, token: string);
|
|
10
10
|
delete<T = unknown>(path: string, options?: RequestOptions): Promise<T>;
|
|
11
11
|
get<T>(path: string, options?: RequestOptions): Promise<T>;
|
|
12
|
+
getAll<T>(path: string, options?: RequestOptions & {
|
|
13
|
+
perPage?: number;
|
|
14
|
+
}): Promise<T[]>;
|
|
12
15
|
post<T = unknown>(path: string, json?: Record<string, unknown>, options?: RequestOptions): Promise<T>;
|
|
13
16
|
put<T = unknown>(path: string, json?: Record<string, unknown>, options?: RequestOptions): Promise<T>;
|
|
14
17
|
private request;
|
|
@@ -17,6 +20,8 @@ type RequestOptions = {
|
|
|
17
20
|
timeoutMs?: number;
|
|
18
21
|
};
|
|
19
22
|
export declare function isNotFoundError(error: unknown): boolean;
|
|
23
|
+
export declare function isApplicative404(error: unknown, marker: string): boolean;
|
|
20
24
|
export declare function isTimeoutError(error: unknown): boolean;
|
|
25
|
+
export declare function isInvalidPageNumber(error: unknown): boolean;
|
|
21
26
|
export declare function formatWpError(error: unknown, url: string, timeoutMs?: number): string;
|
|
22
27
|
export {};
|
package/dist/lib/wp-client.js
CHANGED
|
@@ -22,6 +22,32 @@ export class WpClient {
|
|
|
22
22
|
async get(path, options) {
|
|
23
23
|
return this.request('get', path, undefined, options);
|
|
24
24
|
}
|
|
25
|
+
// Fetches every page of a paginated WP REST collection by walking `?page=N`, stopping on the
|
|
26
|
+
// first short page or on WordPress core's 400 "rest_post_invalid_page_number" past the last
|
|
27
|
+
// one. `path` may already carry query params. Use for `wp/v2/*` list endpoints, which cap at
|
|
28
|
+
// 100 items per request.
|
|
29
|
+
async getAll(path, options) {
|
|
30
|
+
const perPage = options?.perPage ?? 100;
|
|
31
|
+
const separator = path.includes('?') ? '&' : '?';
|
|
32
|
+
const all = [];
|
|
33
|
+
for (let page = 1;; page += 1) {
|
|
34
|
+
let batch;
|
|
35
|
+
try {
|
|
36
|
+
batch = await this.get(`${path}${separator}per_page=${perPage}&page=${page}`, options);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (isInvalidPageNumber(error))
|
|
40
|
+
break;
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
if (!Array.isArray(batch) || batch.length === 0)
|
|
44
|
+
break;
|
|
45
|
+
all.push(...batch);
|
|
46
|
+
if (batch.length < perPage)
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
return all;
|
|
50
|
+
}
|
|
25
51
|
async post(path, json, options) {
|
|
26
52
|
return this.request('post', path, json, options);
|
|
27
53
|
}
|
|
@@ -43,10 +69,40 @@ export function isNotFoundError(error) {
|
|
|
43
69
|
const cause = error?.cause;
|
|
44
70
|
return cause?.response?.statusCode === 404;
|
|
45
71
|
}
|
|
72
|
+
// A 404 a Loopress controller raises on purpose, identified by its JSON body's `error` field
|
|
73
|
+
// (e.g. `{"error": "composer.lock not found"}` from ComposerController). Narrower than
|
|
74
|
+
// isNotFoundError: a bare 404 also covers the route being absent because the plugin isn't
|
|
75
|
+
// installed or predates the feature, which callers must still surface normally.
|
|
76
|
+
export function isApplicative404(error, marker) {
|
|
77
|
+
if (!isNotFoundError(error))
|
|
78
|
+
return false;
|
|
79
|
+
const body = error.cause?.response?.body;
|
|
80
|
+
if (!body)
|
|
81
|
+
return false;
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(body).error === marker;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
46
89
|
export function isTimeoutError(error) {
|
|
47
90
|
const cause = error?.cause;
|
|
48
91
|
return cause?.name === 'TimeoutError';
|
|
49
92
|
}
|
|
93
|
+
// WordPress core answers 400 with this `code` when `?page=N` is past the last page of a
|
|
94
|
+
// collection, which is how `WpClient.getAll` knows it has walked them all.
|
|
95
|
+
export function isInvalidPageNumber(error) {
|
|
96
|
+
const body = error.cause?.response?.body;
|
|
97
|
+
if (!body)
|
|
98
|
+
return false;
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(body).code === 'rest_post_invalid_page_number';
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
50
106
|
export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
51
107
|
const err = error;
|
|
52
108
|
const status = err.response?.statusCode;
|
|
@@ -23,18 +23,22 @@ export interface LoopressProjectConfiguration {
|
|
|
23
23
|
* Directory where forms (WPForms today) are read from and written to, relative to rootDir.
|
|
24
24
|
*/
|
|
25
25
|
formDir?: string;
|
|
26
|
-
/**
|
|
27
|
-
* Directory where WordPress pages are read from and written to, relative to rootDir.
|
|
28
|
-
*/
|
|
29
|
-
pageDir?: string;
|
|
30
26
|
/**
|
|
31
27
|
* Directory where custom API route files are read from and written to, relative to rootDir.
|
|
32
28
|
*/
|
|
33
29
|
apiDir?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Directory where hook files (WordPress actions, filters, and scheduled tasks) are read from and written to, relative to rootDir.
|
|
32
|
+
*/
|
|
33
|
+
hooksDir?: string;
|
|
34
34
|
/**
|
|
35
35
|
* Directory where single-page app bundles are read from and written to, relative to rootDir. Each subdirectory is one app: a loopress.app.json plus a built dist/ folder.
|
|
36
36
|
*/
|
|
37
37
|
appsDir?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Directory where explicitly tracked WordPress options (wp_options rows) are read from and written to, relative to rootDir. Only options added via `lps option add` live here, this is never the whole options table.
|
|
40
|
+
*/
|
|
41
|
+
optionsDir?: string;
|
|
38
42
|
/**
|
|
39
43
|
* Project identifier from the global Loopress config. When set, takes precedence over the currently active project in the global config.
|
|
40
44
|
*/
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export declare const OPTIONS_ENDPOINT = "loopress/v1/options";
|
|
2
|
+
export declare function optionEndpoint(name: string): string;
|
|
3
|
+
export declare const RESERVED_OPTION_NAMES: readonly ["active_plugins", "stylesheet", "template"];
|
|
4
|
+
export declare const READONLY_BY_DEFAULT_OPTION_NAMES: readonly ["siteurl", "home", "db_version", "initial_db_version", "cron", "rewrite_rules", "WPLANG", "default_role", "users_can_register", "uninstall_plugins", "mailserver_url", "mailserver_login", "mailserver_pass", "mailserver_port"];
|
|
5
|
+
export type LocalOption = {
|
|
6
|
+
autoload: string;
|
|
7
|
+
name: string;
|
|
8
|
+
readonly?: boolean;
|
|
9
|
+
value: unknown;
|
|
10
|
+
};
|
|
11
|
+
export type RemoteOption = {
|
|
12
|
+
autoload: string;
|
|
13
|
+
name: string;
|
|
14
|
+
value: unknown;
|
|
15
|
+
};
|
|
16
|
+
export type ListedOption = {
|
|
17
|
+
autoload: string;
|
|
18
|
+
confirmed: boolean;
|
|
19
|
+
core: boolean;
|
|
20
|
+
guess: null | string;
|
|
21
|
+
name: string;
|
|
22
|
+
pluginName: null | string;
|
|
23
|
+
};
|
|
24
|
+
export declare function isReservedOptionName(name: string): boolean;
|
|
25
|
+
export declare function defaultReadonlyFor(name: string): boolean;
|
|
26
|
+
export declare function optionFileName(name: string): string;
|
|
27
|
+
export declare function partitionByReadonly(tracked: LocalOption[]): {
|
|
28
|
+
skipped: LocalOption[];
|
|
29
|
+
writable: LocalOption[];
|
|
30
|
+
};
|
|
31
|
+
export declare function parseLocalOption(raw: string): LocalOption;
|