@loopress/cli 0.32.2 → 0.34.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 +549 -179
- 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 +7 -5
- 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 +53 -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.d.ts +2 -2
- package/dist/commands/dev.js +13 -18
- package/dist/commands/diff.d.ts +6 -3
- package/dist/commands/diff.js +63 -13
- package/dist/commands/doctor.d.ts +4 -2
- package/dist/commands/doctor.js +49 -25
- 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 +3 -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 +3 -1
- package/dist/commands/plugin/add.d.ts +1 -0
- package/dist/commands/plugin/add.js +50 -9
- 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 +41 -6
- package/dist/commands/plugin/status.js +3 -1
- package/dist/commands/project/config.js +7 -7
- 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.d.ts +1 -0
- package/dist/commands/promote.js +15 -6
- package/dist/commands/pull.d.ts +7 -0
- package/dist/commands/pull.js +67 -18
- package/dist/commands/push.d.ts +11 -1
- package/dist/commands/push.js +157 -48
- 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/diff.js +1 -1
- package/dist/commands/theme/style/push.js +8 -3
- package/dist/commands/theme/style/rollback.js +1 -1
- package/dist/commands/theme/template/push.d.ts +2 -0
- package/dist/commands/theme/template/push.js +66 -18
- package/dist/commands/validate.js +3 -0
- 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 +8 -1
- package/dist/lib/base.js +43 -9
- 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-plugins.d.ts +9 -0
- package/dist/lib/declared-plugins.js +21 -0
- package/dist/lib/declared-type-command.js +7 -2
- package/dist/lib/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +13 -15
- package/dist/lib/diff-command.d.ts +19 -3
- package/dist/lib/diff-command.js +132 -14
- package/dist/lib/diff-state.js +2 -3
- package/dist/lib/environment-urls.d.ts +6 -0
- package/dist/lib/environment-urls.js +59 -0
- package/dist/lib/exclude.d.ts +16 -0
- package/dist/lib/exclude.js +102 -0
- package/dist/lib/load-files.js +2 -1
- package/dist/lib/load-snippets.js +39 -43
- 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 +13 -8
- package/dist/lib/plugin-detection.d.ts +0 -1
- package/dist/lib/plugin-detection.js +2 -4
- package/dist/lib/push-command.d.ts +5 -1
- package/dist/lib/push-command.js +48 -7
- package/dist/lib/put-or-create.d.ts +2 -2
- package/dist/lib/readdir-tolerant.js +9 -4
- package/dist/lib/resource-selection.d.ts +9 -0
- package/dist/lib/resource-selection.js +49 -0
- package/dist/lib/resource-state.d.ts +2 -0
- package/dist/lib/resource-state.js +60 -14
- package/dist/lib/rollback-command.d.ts +1 -1
- package/dist/lib/rollback-command.js +4 -3
- package/dist/lib/rollback-materialize.js +29 -9
- 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.d.ts +1 -0
- package/dist/lib/validate-local.js +102 -28
- package/dist/lib/wp-client.d.ts +7 -1
- package/dist/lib/wp-client.js +60 -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 +24 -5
- 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 +29 -0
- package/dist/utils/block-format.js +109 -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 +9 -7
- 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 +13 -5
- 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 +2661 -1235
- package/package.json +45 -20
- package/schemas/global-config.schema.json +1 -1
- package/schemas/project-config.schema.json +32 -5
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,97 @@ 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
|
-
|
|
101
|
+
return responseOf(causeOf(error)).statusCode === 403;
|
|
102
|
+
}
|
|
103
|
+
// WordPress core answers the authenticated request with this `code` when Application
|
|
104
|
+
// Passwords are turned off for the user (a security plugin, `wp_is_application_passwords_available`
|
|
105
|
+
// filter or the user's role), as opposed to `incorrect_password`/`invalid_username` for bad credentials.
|
|
106
|
+
export function isApplicationPasswordsDisabled(error) {
|
|
107
|
+
return parseBody(responseOf(causeOf(error)).body).code === 'application_passwords_disabled_for_user';
|
|
101
108
|
}
|
|
102
109
|
// A 404 a Loopress controller raises on purpose, identified by its JSON body's `error` field
|
|
103
110
|
// (e.g. `{"error": "composer.lock not found"}` from ComposerController). Narrower than
|
|
104
111
|
// isNotFoundError: a bare 404 also covers the route being absent because the plugin isn't
|
|
105
112
|
// installed or predates the feature, which callers must still surface normally.
|
|
106
113
|
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
|
-
}
|
|
114
|
+
return isNotFoundError(error) && parseBody(responseOf(causeOf(error)).body).error === marker;
|
|
118
115
|
}
|
|
119
116
|
// A 409 a Loopress controller tags with a `code`, for a refusal the CLI can act on (e.g.
|
|
120
117
|
// `loopress_page_not_managed`, which `lps page push` offers to take over).
|
|
121
118
|
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
|
-
}
|
|
119
|
+
const { body, statusCode } = responseOf(causeOf(error));
|
|
120
|
+
return statusCode === 409 && parseBody(body).code === code;
|
|
131
121
|
}
|
|
132
122
|
export function isTimeoutError(error) {
|
|
133
|
-
const cause = error
|
|
134
|
-
return cause
|
|
123
|
+
const cause = causeOf(error);
|
|
124
|
+
return isRecord(cause) && cause.name === 'TimeoutError';
|
|
135
125
|
}
|
|
136
126
|
// WordPress core answers 400 with this `code` when `?page=N` is past the last page of a
|
|
137
127
|
// collection, which is how `WpClient.getAll` knows it has walked them all.
|
|
138
128
|
export function isInvalidPageNumber(error) {
|
|
139
|
-
|
|
129
|
+
return parseBody(responseOf(causeOf(error)).body).code === 'rest_post_invalid_page_number';
|
|
130
|
+
}
|
|
131
|
+
// The HTTP response a got error carries, read structurally so a mocked error reads the same.
|
|
132
|
+
export function responseOf(error) {
|
|
133
|
+
const response = isRecord(error) ? error.response : undefined;
|
|
134
|
+
if (!isRecord(response))
|
|
135
|
+
return {};
|
|
136
|
+
return {
|
|
137
|
+
body: typeof response.body === 'string' ? response.body : undefined,
|
|
138
|
+
statusCode: typeof response.statusCode === 'number' ? response.statusCode : undefined,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
// A response body's top-level JSON fields, or none when it isn't JSON.
|
|
142
|
+
export function parseBody(body) {
|
|
140
143
|
if (!body)
|
|
141
|
-
return
|
|
144
|
+
return {};
|
|
142
145
|
try {
|
|
143
|
-
|
|
146
|
+
const parsed = JSON.parse(body);
|
|
147
|
+
return isRecord(parsed) ? parsed : {};
|
|
144
148
|
}
|
|
145
149
|
catch {
|
|
146
|
-
return
|
|
150
|
+
return {};
|
|
147
151
|
}
|
|
148
152
|
}
|
|
149
153
|
export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
150
|
-
const
|
|
151
|
-
const status = err.response?.statusCode;
|
|
154
|
+
const { body, statusCode: status } = responseOf(error);
|
|
152
155
|
if (status === 401 || status === 403) {
|
|
153
156
|
// A Loopress controller's own `{"error": ...}` on a 403 is a deliberate refusal of this one
|
|
154
157
|
// request (e.g. OptionsController protecting a secret-looking option), not bad credentials:
|
|
155
158
|
// pointing at `lps project config` there sends the user the wrong way. WordPress core's own
|
|
156
159
|
// auth failures use `{code, message}` instead and keep the credentials hint.
|
|
157
|
-
const refusal = extractLoopressError(
|
|
160
|
+
const refusal = extractLoopressError(body);
|
|
158
161
|
if (refusal)
|
|
159
162
|
return `Request refused (${status}) on ${url}: ${refusal}`;
|
|
160
163
|
return `Authentication failed (${status}) on ${url}. Check your credentials with \`lps project config\`.`;
|
|
161
164
|
}
|
|
162
165
|
if (status === 404) {
|
|
163
|
-
const reason = extractServerErrorMessage(
|
|
166
|
+
const reason = extractServerErrorMessage(body);
|
|
164
167
|
return reason
|
|
165
168
|
? `Request failed (404) on ${url}: ${reason}`
|
|
166
169
|
: `Endpoint not found (404) on ${url}. Is the required plugin installed and up to date on the site?`;
|
|
167
170
|
}
|
|
168
171
|
if (status !== undefined) {
|
|
169
|
-
const reason = extractServerErrorMessage(
|
|
172
|
+
const reason = extractServerErrorMessage(body);
|
|
170
173
|
return reason ? `Request failed (${status}) on ${url}: ${reason}` : `Request failed (${status}) on ${url}.`;
|
|
171
174
|
}
|
|
172
|
-
if (
|
|
175
|
+
if (isRecord(error) && error.name === 'TimeoutError') {
|
|
173
176
|
return `Request timed out after ${timeoutMs / 1000}s on ${url}. Is the site reachable?`;
|
|
174
177
|
}
|
|
175
|
-
const
|
|
178
|
+
const code = errorCode(error);
|
|
179
|
+
const reason = code ? NETWORK_ERROR_HINTS[code] : undefined;
|
|
176
180
|
// The site, not the endpoint: every resource fails the same way when the site is down, so
|
|
177
181
|
// `lps diff` can print it once (see DiffCommand.report).
|
|
178
182
|
if (reason)
|
|
179
183
|
return `Could not reach ${new URL(url).origin}: ${reason}. Run \`lps doctor\` to check the environment.`;
|
|
180
184
|
// Node leaves `message` empty on some network errors (an AggregateError over IPv4 + IPv6):
|
|
181
185
|
// fall back to the error code so the reason is never blank.
|
|
182
|
-
|
|
186
|
+
const message = isRecord(error) && typeof error.message === 'string' ? error.message : '';
|
|
187
|
+
return `Request to ${url} failed: ${message || (code ?? String(error))}`;
|
|
183
188
|
}
|
|
184
189
|
const NETWORK_ERROR_HINTS = {
|
|
185
190
|
ECONNREFUSED: 'connection refused, the site may be down',
|
|
@@ -188,15 +193,8 @@ const NETWORK_ERROR_HINTS = {
|
|
|
188
193
|
ETIMEDOUT: 'connection timed out',
|
|
189
194
|
};
|
|
190
195
|
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
|
-
}
|
|
196
|
+
const { error } = parseBody(body);
|
|
197
|
+
return typeof error === 'string' && error.trim() ? decodeEntities(error) : undefined;
|
|
200
198
|
}
|
|
201
199
|
// Plugin exception messages go through esc_html() (WordPress coding standards require it for
|
|
202
200
|
// anything thrown), so quotes and ampersands reach the JSON body as entities. They're meant for
|
|
@@ -219,24 +217,17 @@ function decodeEntities(text) {
|
|
|
219
217
|
// specifically so a caller that only reads `error` doesn't see it, which is exactly what this
|
|
220
218
|
// function used to do, hiding the one piece of text that actually explains the failure.
|
|
221
219
|
function extractServerErrorMessage(body) {
|
|
222
|
-
|
|
220
|
+
const parsed = parseBody(body);
|
|
221
|
+
// WP core's own generic body for a route it never registered (e.g. a plugin too old to
|
|
222
|
+
// expose this endpoint yet) is never more useful than the "is the plugin up to date?"
|
|
223
|
+
// fallback below; surfacing it verbatim buries a version-skew mismatch behind text that
|
|
224
|
+
// reads like the endpoint doesn't exist at all, pointing the user in the wrong direction.
|
|
225
|
+
if (parsed.code === 'rest_no_route')
|
|
223
226
|
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
|
-
}
|
|
227
|
+
const reason = parsed.error ?? parsed.message;
|
|
228
|
+
const summary = typeof reason === 'string' && reason.trim() ? decodeEntities(reason) : undefined;
|
|
229
|
+
const detail = typeof parsed.output === 'string' && parsed.output.trim() ? parsed.output.trim() : undefined;
|
|
230
|
+
if (summary && detail)
|
|
231
|
+
return `${summary}\n${detail}`;
|
|
232
|
+
return summary ?? detail;
|
|
242
233
|
}
|
|
@@ -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,14 +23,18 @@ 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
|
*/
|
|
29
|
-
|
|
33
|
+
hookDir?: string;
|
|
30
34
|
/**
|
|
31
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.
|
|
32
36
|
*/
|
|
33
|
-
|
|
37
|
+
appDir?: string;
|
|
34
38
|
/**
|
|
35
39
|
* Directory where static HTML pages (one <slug>.html per WordPress page, managed by Loopress) are read from, relative to rootDir.
|
|
36
40
|
*/
|
|
@@ -54,7 +58,7 @@ export interface LoopressProjectConfiguration {
|
|
|
54
58
|
/**
|
|
55
59
|
* 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.
|
|
56
60
|
*/
|
|
57
|
-
|
|
61
|
+
optionDir?: string;
|
|
58
62
|
/**
|
|
59
63
|
* Directory where custom post types (one JSON file of register_post_type() arguments per post type, named <slug>.json) are read from and written to, relative to rootDir.
|
|
60
64
|
*/
|
|
@@ -82,7 +86,7 @@ export interface LoopressProjectConfiguration {
|
|
|
82
86
|
/**
|
|
83
87
|
* Directory where the active block theme's Global Styles customizations (Site Editor > Styles: colors, typography, spacing) are read from and written to, relative to rootDir. Classic (non-block) themes are not supported.
|
|
84
88
|
*/
|
|
85
|
-
|
|
89
|
+
themeStyleDir?: string;
|
|
86
90
|
/**
|
|
87
91
|
* Project identifier from the global Loopress config. When set, takes precedence over the currently active project in the global config.
|
|
88
92
|
*/
|
|
@@ -103,7 +107,22 @@ export interface LoopressProjectConfiguration {
|
|
|
103
107
|
};
|
|
104
108
|
};
|
|
105
109
|
/**
|
|
106
|
-
*
|
|
110
|
+
* Per-environment settings, keyed by environment name (as in `lps project config`).
|
|
111
|
+
*/
|
|
112
|
+
environments?: {
|
|
113
|
+
[k: string]: {
|
|
114
|
+
/**
|
|
115
|
+
* Globs relative to rootDir (`*`, `**`, `?`) of files this environment never receives. `lps push`, `lps diff` and `lps promote` leave them alone on it: never pushed, never pruned or mirrored away, reported as held back instead of drift. A glob matching a directory covers everything inside. `lps pull` and `lps dev` ignore it.
|
|
116
|
+
*/
|
|
117
|
+
exclude?: string[];
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* 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.
|
|
122
|
+
*/
|
|
123
|
+
activateChildTheme?: boolean;
|
|
124
|
+
/**
|
|
125
|
+
* 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
126
|
*/
|
|
108
127
|
themes?: {
|
|
109
128
|
[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))
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export declare const BLOCKS_ENDPOINT = "loopress/v1/block-files";
|
|
2
|
+
export declare const BLOCK_FILES: readonly ["block.json", "render.php", "style.css", "view.js"];
|
|
3
|
+
export declare const BLOCK_SLUG: RegExp;
|
|
4
|
+
export type BlockFiles = Record<string, string>;
|
|
5
|
+
export type LocalBlock = {
|
|
6
|
+
files: BlockFiles;
|
|
7
|
+
slug: string;
|
|
8
|
+
};
|
|
9
|
+
export type BlockProblem = {
|
|
10
|
+
file: string;
|
|
11
|
+
message: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function blockProblems(slug: string, files: BlockFiles): string[];
|
|
14
|
+
export declare function readLocalBlocks(dir: string): Promise<{
|
|
15
|
+
blocks: LocalBlock[];
|
|
16
|
+
problems: BlockProblem[];
|
|
17
|
+
}>;
|
|
18
|
+
export declare function excludedBlockFiles(dir: string, slug: string): string[];
|
|
19
|
+
export declare function blockFilesToPush(dir: string, block: LocalBlock, site: BlockFiles | undefined): BlockFiles;
|
|
20
|
+
export declare function formatBlockProblems(problems: BlockProblem[]): string;
|
|
21
|
+
export declare function assertSafeBlock(block: LocalBlock): void;
|
|
22
|
+
export declare function writeBlock(dir: string, block: LocalBlock): Promise<void>;
|
|
23
|
+
export type RemoteBlock = LocalBlock & {
|
|
24
|
+
error?: string;
|
|
25
|
+
runtimeError?: {
|
|
26
|
+
at: string;
|
|
27
|
+
message: string;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isExcluded, withoutExcluded } from '../lib/exclude.js';
|
|
4
|
+
import { errorCode, isRecord } from '../lib/narrow.js';
|
|
5
|
+
// A dynamic block: blocks/<slug>/ in WordPress's own block format, registered on the site as
|
|
6
|
+
// loopress/<slug> (the plugin's BlocksDirectory and BlockFilesController).
|
|
7
|
+
export const BLOCKS_ENDPOINT = 'loopress/v1/block-files';
|
|
8
|
+
// The only files a block folder may hold, mirrors BlocksDirectory::FILES.
|
|
9
|
+
export const BLOCK_FILES = ['block.json', 'render.php', 'style.css', 'view.js'];
|
|
10
|
+
// WordPress's block name segment rule, capped like the plugin's slug pattern.
|
|
11
|
+
export const BLOCK_SLUG = /^[a-z][a-z0-9-]{0,63}$/;
|
|
12
|
+
// Every `file:./x` path a block.json field points at (style, viewScriptModule, ...), top level
|
|
13
|
+
// only, as a string or an array of strings, like WordPress reads them.
|
|
14
|
+
function referencedFiles(metadata) {
|
|
15
|
+
return Object.values(metadata)
|
|
16
|
+
.flatMap((value) => (Array.isArray(value) ? value : [value]))
|
|
17
|
+
.filter((value) => typeof value === 'string' && value.startsWith('file:'))
|
|
18
|
+
.map((value) => value.slice('file:'.length).replace(/^\.\//, ''));
|
|
19
|
+
}
|
|
20
|
+
// Local checks `lps block push` and `lps validate` share. The site checks the same again,
|
|
21
|
+
// plus render.php's syntax.
|
|
22
|
+
export function blockProblems(slug, files) {
|
|
23
|
+
if (!BLOCK_SLUG.test(slug))
|
|
24
|
+
return ['the folder name must be lowercase letters, digits and hyphens, starting with a letter'];
|
|
25
|
+
const problems = [];
|
|
26
|
+
for (const name of Object.keys(files)) {
|
|
27
|
+
if (!BLOCK_FILES.includes(name))
|
|
28
|
+
problems.push(`${name} is not a block file, a block folder holds only ${BLOCK_FILES.join(', ')}`);
|
|
29
|
+
}
|
|
30
|
+
if (files['render.php'] === undefined)
|
|
31
|
+
problems.push('render.php is missing');
|
|
32
|
+
const raw = files['block.json'];
|
|
33
|
+
if (raw === undefined)
|
|
34
|
+
return [...problems, 'block.json is missing'];
|
|
35
|
+
let metadata;
|
|
36
|
+
try {
|
|
37
|
+
metadata = JSON.parse(raw);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return [...problems, 'block.json is not valid JSON'];
|
|
41
|
+
}
|
|
42
|
+
if (!isRecord(metadata) || Array.isArray(metadata))
|
|
43
|
+
return [...problems, 'block.json is not a JSON object'];
|
|
44
|
+
if (metadata.name !== `loopress/${slug}`)
|
|
45
|
+
problems.push(`block.json "name" must be "loopress/${slug}"`);
|
|
46
|
+
for (const file of referencedFiles(metadata)) {
|
|
47
|
+
if (files[file] === undefined)
|
|
48
|
+
problems.push(`block.json references file:./${file}, which is not in the folder`);
|
|
49
|
+
}
|
|
50
|
+
return problems;
|
|
51
|
+
}
|
|
52
|
+
// Every blocks/<slug>/ folder with its files. Unknown files are read too, so blockProblems()
|
|
53
|
+
// reports them instead of push silently dropping them.
|
|
54
|
+
export async function readLocalBlocks(dir) {
|
|
55
|
+
let entries;
|
|
56
|
+
try {
|
|
57
|
+
entries = withoutExcluded(dir, await readdir(dir, { withFileTypes: true }), (entry) => entry.name);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (errorCode(error) === 'ENOENT')
|
|
61
|
+
return { blocks: [], problems: [] };
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
const blocks = [];
|
|
65
|
+
const problems = [];
|
|
66
|
+
for (const entry of entries.filter((candidate) => candidate.isDirectory()).toSorted((a, b) => a.name.localeCompare(b.name))) {
|
|
67
|
+
const slug = entry.name;
|
|
68
|
+
const files = {};
|
|
69
|
+
for (const file of await readdir(join(dir, slug), { withFileTypes: true })) {
|
|
70
|
+
if (file.isFile() && !file.name.startsWith('.'))
|
|
71
|
+
files[file.name] = await readFile(join(dir, slug, file.name), 'utf8');
|
|
72
|
+
}
|
|
73
|
+
// Checked whole, then the environment's excluded files are dropped: a push keeps the site's
|
|
74
|
+
// copy of those (see blockFilesToPush), a diff holds the block back.
|
|
75
|
+
for (const message of blockProblems(slug, files))
|
|
76
|
+
problems.push({ file: join(dir, slug), message });
|
|
77
|
+
const kept = new Set(withoutExcluded(join(dir, slug), Object.keys(files), (name) => name));
|
|
78
|
+
blocks.push({ files: Object.fromEntries(Object.entries(files).filter(([name]) => kept.has(name))), slug });
|
|
79
|
+
}
|
|
80
|
+
return { blocks, problems };
|
|
81
|
+
}
|
|
82
|
+
// Block files the environment excludes: never sent, so the site's copy of each one goes back in
|
|
83
|
+
// the PUT (a block is replaced as a whole).
|
|
84
|
+
export function excludedBlockFiles(dir, slug) {
|
|
85
|
+
return BLOCK_FILES.filter((name) => isExcluded(join(dir, slug, name)));
|
|
86
|
+
}
|
|
87
|
+
// What a push sends for `block`: its local files plus the site's copy of its excluded ones.
|
|
88
|
+
export function blockFilesToPush(dir, block, site) {
|
|
89
|
+
const excluded = new Set(excludedBlockFiles(dir, block.slug));
|
|
90
|
+
const kept = Object.entries(block.files).filter(([name]) => !excluded.has(name));
|
|
91
|
+
const fromSite = Object.entries(site ?? {}).filter(([name]) => excluded.has(name));
|
|
92
|
+
return Object.fromEntries([...kept, ...fromSite]);
|
|
93
|
+
}
|
|
94
|
+
export function formatBlockProblems(problems) {
|
|
95
|
+
return problems.map((problem) => `${problem.file}: ${problem.message}`).join('\n');
|
|
96
|
+
}
|
|
97
|
+
// A block from the site or a snapshot names a folder and files on disk: anything but a valid
|
|
98
|
+
// slug and the known file names could write or delete outside blocks/.
|
|
99
|
+
export function assertSafeBlock(block) {
|
|
100
|
+
const unsafe = !BLOCK_SLUG.test(block.slug) || Object.keys(block.files).some((name) => !BLOCK_FILES.includes(name));
|
|
101
|
+
if (unsafe)
|
|
102
|
+
throw new Error(`Refusing block "${block.slug}": invalid slug or file name (${Object.keys(block.files).join(', ')})`);
|
|
103
|
+
}
|
|
104
|
+
export async function writeBlock(dir, block) {
|
|
105
|
+
assertSafeBlock(block);
|
|
106
|
+
await mkdir(join(dir, block.slug), { recursive: true });
|
|
107
|
+
for (const [name, content] of Object.entries(block.files))
|
|
108
|
+
await writeFile(join(dir, block.slug, name), content);
|
|
109
|
+
}
|
package/dist/utils/composer.d.ts
CHANGED
|
@@ -1,21 +1,13 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
1
2
|
import { type SyncIntent } from './plugin-sync.js';
|
|
2
3
|
import { type MergeResult } from './plugins.js';
|
|
3
4
|
export declare const NOT_ON_WORDPRESS_ORG = "not on WordPress.org";
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
};
|
|
11
|
-
name?: string;
|
|
12
|
-
repositories?: Array<{
|
|
13
|
-
type: string;
|
|
14
|
-
url: string;
|
|
15
|
-
}>;
|
|
16
|
-
require?: Record<string, string>;
|
|
17
|
-
'require-dev'?: Record<string, string>;
|
|
18
|
-
};
|
|
5
|
+
export declare const COMPOSER_JSON: z.ZodObject<{
|
|
6
|
+
require: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
7
|
+
'require-dev': z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
8
|
+
}, z.core.$loose>;
|
|
9
|
+
export type ComposerJson = z.infer<typeof COMPOSER_JSON>;
|
|
10
|
+
export declare function parseComposerJson(raw: string): ComposerJson;
|
|
19
11
|
export declare function readComposerJson(): Promise<ComposerJson | null>;
|
|
20
12
|
export declare function readComposerLock(): Promise<null | string>;
|
|
21
13
|
export declare function getComposerManagedSlugs(composerJson: ComposerJson): string[];
|