@loopress/cli 0.32.1 → 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 +19 -7
- package/dist/lib/put-or-create.d.ts +2 -2
- package/dist/lib/readdir-tolerant.js +3 -1
- package/dist/lib/resource-state.d.ts +1 -1
- package/dist/lib/resource-state.js +58 -19
- 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.d.ts +1 -0
- package/dist/utils/acf-format.js +11 -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 +44 -6
- package/dist/utils/version.js +1 -1
- package/oclif.manifest.json +1588 -1043
- package/package.json +42 -17
- package/schemas/global-config.schema.json +1 -1
- package/schemas/project-config.schema.json +11 -1
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { readdir, readFile } from 'node:fs/promises';
|
|
2
2
|
import { basename, extname, join } from 'node:path';
|
|
3
|
+
import {} from '../types/snippet.js';
|
|
3
4
|
import { defaultLocationForType, parseInsertMethod, parseLocation, parseType, } from '../utils/snippet-format.js';
|
|
5
|
+
import { errorCode, errorMessage, isRecord } from './narrow.js';
|
|
4
6
|
const TYPE_BY_EXTENSION = {
|
|
5
7
|
'.css': 'css',
|
|
6
8
|
'.html': 'html',
|
|
@@ -21,58 +23,51 @@ async function loadSnippet(path, file, onSkip) {
|
|
|
21
23
|
content = await readFile(filePath, 'utf8');
|
|
22
24
|
}
|
|
23
25
|
catch (error) {
|
|
24
|
-
onSkip?.(`Skipping "${filePath}": ${error
|
|
26
|
+
onSkip?.(`Skipping "${filePath}": ${errorMessage(error)}`);
|
|
25
27
|
return undefined;
|
|
26
28
|
}
|
|
27
|
-
let
|
|
28
|
-
let name;
|
|
29
|
-
let description;
|
|
30
|
-
let type;
|
|
31
|
-
let isActive = false;
|
|
32
|
-
let tags = [];
|
|
33
|
-
let location = null;
|
|
34
|
-
let insertMethod = null;
|
|
35
|
-
let priority = 10;
|
|
36
|
-
let shortcodeAttributes = [];
|
|
29
|
+
let meta;
|
|
37
30
|
try {
|
|
38
|
-
|
|
39
|
-
const meta = JSON.parse(metaContent);
|
|
40
|
-
id = meta.id;
|
|
41
|
-
// `||`, not `??`: an empty-string name in the sidecar JSON counts as absent too.
|
|
42
|
-
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
|
43
|
-
name = meta.name || undefined;
|
|
44
|
-
// Kept even when empty: a description cleared on purpose, not one to replace with "Imported from <path>".
|
|
45
|
-
description = meta.description;
|
|
46
|
-
type = parseType(meta.type) ?? undefined;
|
|
47
|
-
isActive = Boolean(meta.active);
|
|
48
|
-
tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
|
|
49
|
-
location = parseLocation(meta.location);
|
|
50
|
-
insertMethod = parseInsertMethod(meta.insertMethod);
|
|
51
|
-
priority = meta.priority ?? 10;
|
|
52
|
-
shortcodeAttributes = Array.isArray(meta.shortcodeAttributes) ? meta.shortcodeAttributes.map(String) : [];
|
|
31
|
+
meta = await readSidecar(metaPath);
|
|
53
32
|
}
|
|
54
33
|
catch (error) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return undefined;
|
|
58
|
-
}
|
|
34
|
+
onSkip?.(`Skipping "${metaPath}": ${errorMessage(error)}`);
|
|
35
|
+
return undefined;
|
|
59
36
|
}
|
|
60
|
-
const
|
|
37
|
+
const type = parseType(meta.type) ?? TYPE_BY_EXTENSION[ext] ?? 'php';
|
|
61
38
|
return {
|
|
62
|
-
active:
|
|
39
|
+
active: Boolean(meta.active),
|
|
63
40
|
code: content,
|
|
64
|
-
description,
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
41
|
+
// Kept even when empty: a description cleared on purpose, not one to replace with "Imported from <path>".
|
|
42
|
+
description: typeof meta.description === 'string' ? meta.description : undefined,
|
|
43
|
+
id: typeof meta.id === 'number' ? meta.id : undefined,
|
|
44
|
+
insertMethod: parseInsertMethod(meta.insertMethod) ?? 'auto',
|
|
45
|
+
location: parseLocation(meta.location) ?? defaultLocationForType(type),
|
|
46
|
+
// An empty-string name in the sidecar JSON counts as absent too.
|
|
47
|
+
name: typeof meta.name === 'string' && meta.name !== '' ? meta.name : basename(file, ext),
|
|
69
48
|
path: filePath,
|
|
70
|
-
priority,
|
|
71
|
-
shortcodeAttributes,
|
|
72
|
-
tags,
|
|
73
|
-
type
|
|
49
|
+
priority: typeof meta.priority === 'number' ? meta.priority : 10,
|
|
50
|
+
shortcodeAttributes: Array.isArray(meta.shortcodeAttributes) ? meta.shortcodeAttributes.map(String) : [],
|
|
51
|
+
tags: Array.isArray(meta.tags) ? meta.tags.map(String) : [],
|
|
52
|
+
type,
|
|
74
53
|
};
|
|
75
54
|
}
|
|
55
|
+
// The sidecar's JSON object, or no fields when the snippet has no sidecar (yet).
|
|
56
|
+
async function readSidecar(metaPath) {
|
|
57
|
+
let raw;
|
|
58
|
+
try {
|
|
59
|
+
raw = await readFile(metaPath, 'utf8');
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (errorCode(error) === 'ENOENT')
|
|
63
|
+
return {};
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
const meta = JSON.parse(raw);
|
|
67
|
+
if (!isRecord(meta))
|
|
68
|
+
throw new Error('not a JSON object');
|
|
69
|
+
return meta;
|
|
70
|
+
}
|
|
76
71
|
// Shared by `snippet push` (pushes to the project's own WordPress site) and `snippet publish`
|
|
77
72
|
// (publishes to the Loopress api as a shared source): both read the same local `snippets/`
|
|
78
73
|
// directory the same way. `onSkip` is optional so callers that don't care about per-file
|
|
@@ -83,7 +78,7 @@ export async function loadSnippets(path, onSkip) {
|
|
|
83
78
|
files = await readdir(path);
|
|
84
79
|
}
|
|
85
80
|
catch (error) {
|
|
86
|
-
throw new Error(`Error loading snippets: ${error
|
|
81
|
+
throw new Error(`Error loading snippets: ${errorMessage(error)}`, { cause: error });
|
|
87
82
|
}
|
|
88
83
|
const snippets = [];
|
|
89
84
|
for (const file of files) {
|
|
@@ -94,7 +94,13 @@ export async function waitForLocalCallback(options) {
|
|
|
94
94
|
reject(new Error(options.timeoutMessage));
|
|
95
95
|
}, timeoutMs);
|
|
96
96
|
server.listen(0, '127.0.0.1', () => {
|
|
97
|
-
const
|
|
97
|
+
const address = server.address();
|
|
98
|
+
// Only a pipe or a closed server has no AddressInfo, never a TCP listen that just succeeded.
|
|
99
|
+
if (address === null || typeof address === 'string') {
|
|
100
|
+
reject(new Error('The local callback server has no TCP port'));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const { port } = address;
|
|
98
104
|
const targetUrl = options.buildUrl(`http://${options.callbackHost ?? 'localhost'}:${port}`, state);
|
|
99
105
|
options.log(options.openingMessage);
|
|
100
106
|
options.log(`\nIf it doesn't open automatically, visit:\n${targetUrl}\n`);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
|
2
|
+
export declare function isOneOf<T extends string>(values: Iterable<T>, value: unknown): value is T;
|
|
3
|
+
export declare function errorMessage(error: unknown): string;
|
|
4
|
+
export declare function causeOf(error: unknown): unknown;
|
|
5
|
+
export declare function errorCode(error: unknown): string | undefined;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Type guards for values that arrive as `unknown`: a caught error, a parsed JSON document.
|
|
2
|
+
// Arrays pass isRecord, same as a bare `typeof value === 'object' && value !== null` check.
|
|
3
|
+
export function isRecord(value) {
|
|
4
|
+
return typeof value === 'object' && value !== null;
|
|
5
|
+
}
|
|
6
|
+
// Membership in a closed list of literals, narrowing `value` to that union.
|
|
7
|
+
export function isOneOf(values, value) {
|
|
8
|
+
for (const candidate of values)
|
|
9
|
+
if (candidate === value)
|
|
10
|
+
return true;
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
// `.message` of anything thrown: an Error, or an object shaped like one (got and oclif errors
|
|
14
|
+
// are both). Anything else is stringified rather than read as `undefined`.
|
|
15
|
+
export function errorMessage(error) {
|
|
16
|
+
return isRecord(error) && typeof error.message === 'string' ? error.message : String(error);
|
|
17
|
+
}
|
|
18
|
+
// The error a rethrown one wraps (`new Error(message, {cause})`), e.g. the got error behind
|
|
19
|
+
// WpClient's formatted one.
|
|
20
|
+
export function causeOf(error) {
|
|
21
|
+
return isRecord(error) ? error.cause : undefined;
|
|
22
|
+
}
|
|
23
|
+
// Node's errno code ('ENOENT', 'EEXIST', ...) when the thrown value carries one.
|
|
24
|
+
export function errorCode(error) {
|
|
25
|
+
return isRecord(error) && typeof error.code === 'string' ? error.code : undefined;
|
|
26
|
+
}
|
|
@@ -4,14 +4,17 @@ import { createHash } from 'node:crypto';
|
|
|
4
4
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { dirname, join, relative, sep } from 'node:path';
|
|
6
6
|
import { authManager } from '../config/auth.manager.js';
|
|
7
|
+
import {} from '../types/config.js';
|
|
7
8
|
import { firstTopLevelSideEffect, startsWithStrictTypes } from '../utils/php-top-level.js';
|
|
8
9
|
import { pluralize } from '../utils/pluralize.js';
|
|
9
10
|
import { resolveResourceDir, RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
|
|
10
11
|
import { ApiClient } from './api-client.js';
|
|
11
12
|
import { LoopressCommand } from './base.js';
|
|
13
|
+
import {} from './diff-state.js';
|
|
12
14
|
import { basenameKey, findOrphanedFiles } from './find-orphaned-files.js';
|
|
13
15
|
import { isInteractive } from './interactive.js';
|
|
14
16
|
import { loadFiles as loadDirectoryFiles } from './load-files.js';
|
|
17
|
+
import { errorMessage } from './narrow.js';
|
|
15
18
|
import { PushCommand } from './push-command.js';
|
|
16
19
|
import { getResourceStateProvider } from './resource-state.js';
|
|
17
20
|
import { isApplicative404 } from './wp-client.js';
|
|
@@ -82,7 +85,7 @@ export function resourcePushCommand(spec) {
|
|
|
82
85
|
static enableJsonFlag = true;
|
|
83
86
|
static examples = [
|
|
84
87
|
`$ lps ${spec.cliName} push`,
|
|
85
|
-
`$ lps ${spec.cliName} push
|
|
88
|
+
`$ lps ${spec.cliName} push ./${RESOURCE_DIR_DEFAULTS[spec.dirKind]}`,
|
|
86
89
|
`$ lps ${spec.cliName} push --prune`,
|
|
87
90
|
];
|
|
88
91
|
static flags = {
|
|
@@ -158,14 +161,14 @@ export function resourcePushCommand(spec) {
|
|
|
158
161
|
result = await this.wp.post(`${spec.endpoint}/batch`, body);
|
|
159
162
|
}
|
|
160
163
|
catch (error) {
|
|
161
|
-
this.error(`Push failed, nothing was changed on ${this.siteConfig.url}: ${error
|
|
164
|
+
this.error(`Push failed, nothing was changed on ${this.siteConfig.url}: ${errorMessage(error)}`);
|
|
162
165
|
}
|
|
163
166
|
for (const file of result.files) {
|
|
164
167
|
this.log(file.syntax_check === 'skipped'
|
|
165
168
|
? `Pushed: ${file.filename} (syntax check skipped, unavailable on this host)`
|
|
166
169
|
: `Pushed: ${file.filename}`);
|
|
167
170
|
if (file.public) {
|
|
168
|
-
this.warn(`${file.filename}: PUBLIC route, no authentication required. Anyone on the internet can call it. See https://loopress.dev/api/routes/#authentication-and-permissions`);
|
|
171
|
+
this.warn(`${file.filename}: PUBLIC route, no authentication required. Anyone on the internet can call it. See https://docs.loopress.dev/api/routes/#authentication-and-permissions`);
|
|
169
172
|
}
|
|
170
173
|
}
|
|
171
174
|
for (const filename of result.pruned)
|
|
@@ -216,7 +219,7 @@ export function resourcePullCommand(spec) {
|
|
|
216
219
|
static args = pathArg(spec.pathNoun);
|
|
217
220
|
static description = spec.pullDescription;
|
|
218
221
|
static enableJsonFlag = true;
|
|
219
|
-
static examples = [`$ lps ${spec.cliName} pull`, `$ lps ${spec.cliName} pull
|
|
222
|
+
static examples = [`$ lps ${spec.cliName} pull`, `$ lps ${spec.cliName} pull ./${RESOURCE_DIR_DEFAULTS[spec.dirKind]}`];
|
|
220
223
|
static flags = { ...LoopressCommand.dryRunFlag, ...LoopressCommand.yesFlag };
|
|
221
224
|
async run() {
|
|
222
225
|
const { args } = await this.parse(ResourcePull);
|
|
@@ -294,7 +297,7 @@ export function resourceListCommand(spec) {
|
|
|
294
297
|
}
|
|
295
298
|
export function resourceRmCommand(spec) {
|
|
296
299
|
class ResourceRm extends LoopressCommand {
|
|
297
|
-
// `
|
|
300
|
+
// `rm` is canonical everywhere, `remove` stays an alias for existing scripts.
|
|
298
301
|
static aliases = [`${spec.cliName}:remove`];
|
|
299
302
|
static args = {
|
|
300
303
|
filename: Args.string({
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {} from '../types/plugin.js';
|
|
2
|
+
import {} from './wp-client.js';
|
|
1
3
|
const FULL_PLUGIN_PREFIX = 'loopress-full/';
|
|
2
4
|
/**
|
|
3
5
|
* Checks `wp/v2/plugins` directly rather than through `parseInstalledPlugins`
|
|
@@ -9,7 +11,3 @@ export async function isLoopressFullActive(wp) {
|
|
|
9
11
|
const plugins = await wp.get('wp/v2/plugins');
|
|
10
12
|
return plugins.some((plugin) => plugin.plugin.startsWith(FULL_PLUGIN_PREFIX) && plugin.status === 'active');
|
|
11
13
|
}
|
|
12
|
-
// How the Loopress plugin reports a resource's integration missing: "ACF is not active", "No
|
|
13
|
-
// supported form plugin is active", "No supported snippet plugin is active". Global Styles say a
|
|
14
|
-
// classic theme is active. The aggregate commands skip such a resource the project doesn't use.
|
|
15
|
-
export const INTEGRATION_MISSING = /\bis not active\b|\bNo supported .+ plugin is active\b|\bis a classic theme\b/;
|
|
@@ -7,7 +7,7 @@ export type PushChange = {
|
|
|
7
7
|
};
|
|
8
8
|
export declare function pushChanges(local: ResourceState, remote: ResourceState, options?: {
|
|
9
9
|
mirror?: boolean;
|
|
10
|
-
writes?: (id: string) => boolean;
|
|
10
|
+
writes?: ((id: string) => boolean) | undefined;
|
|
11
11
|
}): PushChange[];
|
|
12
12
|
export declare function logChanges(log: (message: string) => void, changes: Array<{
|
|
13
13
|
id: string;
|
package/dist/lib/push-command.js
CHANGED
|
@@ -5,10 +5,14 @@ import { Listr } from 'listr2';
|
|
|
5
5
|
import { resolve } from 'node:path';
|
|
6
6
|
import { isDeepStrictEqual } from 'node:util';
|
|
7
7
|
import { authManager } from '../config/auth.manager.js';
|
|
8
|
+
import {} from '../types/config.js';
|
|
8
9
|
import { API_URL } from './api-client.js';
|
|
9
10
|
import { LoopressCommand } from './base.js';
|
|
11
|
+
import {} from './diff-state.js';
|
|
10
12
|
import { guardProductionPush } from './guard-production-push.js';
|
|
11
13
|
import { isInteractive } from './interactive.js';
|
|
14
|
+
import { errorMessage } from './narrow.js';
|
|
15
|
+
import {} from './resource-state.js';
|
|
12
16
|
import { readSnapshot, recordToState, writeSnapshot } from './snapshot-store.js';
|
|
13
17
|
import { withoutSpinner, withSpinner } from './spinner.js';
|
|
14
18
|
export function pushChanges(local, remote, options = {}) {
|
|
@@ -65,14 +69,14 @@ export class PushCommand extends LoopressCommand {
|
|
|
65
69
|
const remote = await this.previewRemote(provider, dir);
|
|
66
70
|
await this.guardOutsideEdits(provider, dir, remote, writes);
|
|
67
71
|
try {
|
|
68
|
-
const local = await provider.local(dir, () => { });
|
|
72
|
+
const local = await provider.local(dir, () => { }, this.wp);
|
|
69
73
|
this.previewStates = { local, remote };
|
|
70
74
|
this.previewChanges = pushChanges(local, remote, { writes });
|
|
71
75
|
}
|
|
72
76
|
catch (error) {
|
|
73
77
|
// The push reports the unreadable files itself, but an empty `changes` must not read as
|
|
74
78
|
// "nothing changes".
|
|
75
|
-
this.warn(`Could not list what this push would change: ${error
|
|
79
|
+
this.warn(`Could not list what this push would change: ${errorMessage(error)}`);
|
|
76
80
|
}
|
|
77
81
|
logChanges((message) => { this.log(message); }, this.previewChanges);
|
|
78
82
|
return undefined;
|
|
@@ -84,13 +88,14 @@ export class PushCommand extends LoopressCommand {
|
|
|
84
88
|
}, dir);
|
|
85
89
|
}
|
|
86
90
|
catch (error) {
|
|
87
|
-
this.warn(`Could not read remote state for a rollback snapshot: ${error
|
|
91
|
+
this.warn(`Could not read remote state for a rollback snapshot: ${errorMessage(error)}`);
|
|
88
92
|
return undefined;
|
|
89
93
|
}
|
|
90
94
|
await this.guardOutsideEdits(provider, dir, remote, writes);
|
|
91
95
|
return remote;
|
|
92
96
|
}
|
|
93
97
|
async catch(err) {
|
|
98
|
+
// Unset when init() failed before resolving the environment, despite its declared type.
|
|
94
99
|
if (!this.dryRun && !this.refusedByGuard && this.siteConfig) {
|
|
95
100
|
await this.recordDeployment('failure');
|
|
96
101
|
}
|
|
@@ -120,7 +125,7 @@ export class PushCommand extends LoopressCommand {
|
|
|
120
125
|
return await provider.remote(this.wp, () => { }, dir);
|
|
121
126
|
}
|
|
122
127
|
catch (error) {
|
|
123
|
-
this.error(`The site would refuse this push: ${error
|
|
128
|
+
this.error(`The site would refuse this push: ${errorMessage(error)}`);
|
|
124
129
|
}
|
|
125
130
|
}
|
|
126
131
|
async recordDeployment(status) {
|
|
@@ -211,7 +216,7 @@ export class PushCommand extends LoopressCommand {
|
|
|
211
216
|
});
|
|
212
217
|
}
|
|
213
218
|
catch (error) {
|
|
214
|
-
this.warn(`Could not save a rollback snapshot after this push: ${error
|
|
219
|
+
this.warn(`Could not save a rollback snapshot after this push: ${errorMessage(error)}`);
|
|
215
220
|
}
|
|
216
221
|
}
|
|
217
222
|
// Refuses to silently overwrite a change made on the site outside Loopress (wp-admin, another
|
|
@@ -236,12 +241,19 @@ export class PushCommand extends LoopressCommand {
|
|
|
236
241
|
const snapshot = await readSnapshot(resolve(process.cwd(), this.rootDir), provider.resource, this.siteConfig.name);
|
|
237
242
|
expected = recordToState(snapshot.afterState);
|
|
238
243
|
pushedAt = snapshot.createdAt;
|
|
239
|
-
local = await provider.local(dir, () => { });
|
|
240
244
|
}
|
|
241
245
|
catch {
|
|
242
|
-
// No earlier push from this machine
|
|
246
|
+
// No earlier push from this machine.
|
|
243
247
|
return;
|
|
244
248
|
}
|
|
249
|
+
// Unreadable files are skipped with a warning, so a failure here is the site's (ACF's
|
|
250
|
+
// normalize route): the check can't run, and a push must not go on unchecked.
|
|
251
|
+
try {
|
|
252
|
+
local = await provider.local(dir, () => { }, this.wp);
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
this.error(`Could not check for changes made on ${this.siteConfig.url} outside Loopress: ${errorMessage(error)}. Retry, or pass --overwrite.`);
|
|
256
|
+
}
|
|
245
257
|
const overwritten = [...local.keys()].filter((id) => (writes?.(id) ?? true) &&
|
|
246
258
|
remote.get(id) !== undefined &&
|
|
247
259
|
!sameEntry(remote, expected, id) &&
|
|
@@ -6,8 +6,8 @@ type PutOrCreateOptions = {
|
|
|
6
6
|
postPayload?: Record<string, unknown>;
|
|
7
7
|
putEndpoint: (id: number) => string;
|
|
8
8
|
};
|
|
9
|
-
export declare function putOrCreate
|
|
10
|
-
body:
|
|
9
|
+
export declare function putOrCreate(wp: WpClient, options: PutOrCreateOptions): Promise<{
|
|
10
|
+
body: Record<string, unknown>;
|
|
11
11
|
created: boolean;
|
|
12
12
|
}>;
|
|
13
13
|
export {};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import {} from 'node:fs';
|
|
1
2
|
import { readdir, stat } from 'node:fs/promises';
|
|
2
3
|
import { join, relative, sep } from 'node:path';
|
|
4
|
+
import { errorCode } from './narrow.js';
|
|
3
5
|
export async function readdirTolerant(dir, options) {
|
|
4
6
|
try {
|
|
5
7
|
return options?.withFileTypes
|
|
@@ -7,7 +9,7 @@ export async function readdirTolerant(dir, options) {
|
|
|
7
9
|
: await readdir(dir, { recursive: options?.recursive });
|
|
8
10
|
}
|
|
9
11
|
catch (error) {
|
|
10
|
-
if (error
|
|
12
|
+
if (errorCode(error) === 'ENOENT')
|
|
11
13
|
return [];
|
|
12
14
|
throw error;
|
|
13
15
|
}
|
|
@@ -3,7 +3,7 @@ import { type ResourceState } from './diff-state.js';
|
|
|
3
3
|
import { type WpClient } from './wp-client.js';
|
|
4
4
|
export type ResourceStateProvider = {
|
|
5
5
|
dirKind: ResourceDirKind;
|
|
6
|
-
local(dir: string, onWarn: (message: string) => void): Promise<ResourceState>;
|
|
6
|
+
local(dir: string, onWarn: (message: string) => void, wp?: WpClient): Promise<ResourceState>;
|
|
7
7
|
remote(wp: WpClient, onWarn: (message: string) => void, dir: string): Promise<ResourceState>;
|
|
8
8
|
resource: string;
|
|
9
9
|
title: string;
|
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { basename, join, relative, sep } from 'node:path';
|
|
3
|
-
import { ACF_OBJECT_TYPES, acfEndpoint, getAcfKey } from '../utils/acf-format.js';
|
|
3
|
+
import { ACF_OBJECT_TYPES, acfEndpoint, acfNormalizeEndpoint, getAcfKey } from '../utils/acf-format.js';
|
|
4
|
+
import { BLOCKS_ENDPOINT, formatBlockProblems, readLocalBlocks } from '../utils/block-format.js';
|
|
4
5
|
import { CPT_SPEC, fileExtension, META_SPEC, parseDeclaredFile, PATTERN_SPEC, ROLE_SPEC, TAXONOMY_SPEC, } from '../utils/declared-type-format.js';
|
|
5
6
|
import { FORM_ENDPOINT, getFormId } from '../utils/form-format.js';
|
|
6
7
|
import { getMenuSlug, MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT } from '../utils/menu-format.js';
|
|
7
8
|
import { optionEndpoint, parseLocalOption } from '../utils/option-format.js';
|
|
8
9
|
import { formatPageProblems, PAGES_ENDPOINT, readLocalHtmlFiles, readLocalPages } from '../utils/page-format.js';
|
|
10
|
+
import {} from '../utils/resource-dirs.js';
|
|
9
11
|
import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../utils/snippet-format.js';
|
|
10
12
|
import { CHILD_THEME_ENDPOINT, parsePartFile, parseTemplateFile, TEMPLATE_SLUG_RULE } from '../utils/template-format.js';
|
|
11
13
|
import { canonicalGlobalStyles, getActiveThemeGlobalStyles, globalStylesEndpoint, THEME_STYLES_FILE_SUFFIX, } from '../utils/theme-styles-format.js';
|
|
14
|
+
import {} from './diff-state.js';
|
|
12
15
|
import { loadFiles } from './load-files.js';
|
|
13
16
|
import { loadSnippets } from './load-snippets.js';
|
|
17
|
+
import { causeOf, errorCode, isRecord } from './narrow.js';
|
|
14
18
|
import { isApplicative404, isNotFoundError } from './wp-client.js';
|
|
15
19
|
function readJson(raw) {
|
|
16
20
|
const parsed = JSON.parse(raw);
|
|
17
|
-
if (
|
|
21
|
+
if (!isRecord(parsed) || Array.isArray(parsed)) {
|
|
18
22
|
throw new Error('not a JSON object');
|
|
19
23
|
}
|
|
20
24
|
return parsed;
|
|
@@ -59,7 +63,7 @@ const snippetProvider = {
|
|
|
59
63
|
catch (error) {
|
|
60
64
|
// A directory that was never created is "nothing tracked locally", the same tolerance
|
|
61
65
|
// loadFiles/readdirTolerant already have; loadSnippets wraps the ENOENT in its own Error.
|
|
62
|
-
if (error
|
|
66
|
+
if (errorCode(causeOf(error)) === 'ENOENT')
|
|
63
67
|
return state;
|
|
64
68
|
throw error;
|
|
65
69
|
}
|
|
@@ -115,19 +119,33 @@ const formProvider = {
|
|
|
115
119
|
};
|
|
116
120
|
// ---- ACF --------------------------------------------------------------------------------
|
|
117
121
|
// ACF's export JSON is large, deeply nested, and versioned by ACF itself; `key` is the stable
|
|
118
|
-
// identity and the whole object (minus the `modified` timestamp)
|
|
119
|
-
//
|
|
120
|
-
//
|
|
122
|
+
// identity and the whole object (minus the `modified` timestamp) is compared. Ids are
|
|
123
|
+
// namespaced by object type (`field-groups/group_x`) since keys are only unique within a type.
|
|
124
|
+
// The site stores every setting, defaults included, while a hand- or agent-written file holds
|
|
125
|
+
// only what its author wrote: given the site, the local side is what the site would store
|
|
126
|
+
// (ACF's own normalization, nothing saved), or every such file would read as drift forever.
|
|
127
|
+
async function normalizeAcf(wp, type, objects) {
|
|
128
|
+
try {
|
|
129
|
+
return await wp.post(acfNormalizeEndpoint(type), { objects });
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
// A plugin older than the route: compared as written, as before it existed.
|
|
133
|
+
if (isNotFoundError(error))
|
|
134
|
+
return objects;
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
121
138
|
const acfProvider = {
|
|
122
139
|
dirKind: 'acf',
|
|
123
|
-
async local(dir, onWarn) {
|
|
140
|
+
async local(dir, onWarn, wp) {
|
|
124
141
|
const state = new Map();
|
|
125
142
|
for (const type of ACF_OBJECT_TYPES) {
|
|
126
|
-
const
|
|
143
|
+
const files = await loadFiles(join(dir, type), {
|
|
127
144
|
extension: '.json',
|
|
128
145
|
onSkip: onWarn,
|
|
129
146
|
parse: readJson,
|
|
130
147
|
});
|
|
148
|
+
const objects = wp && files.length > 0 ? await normalizeAcf(wp, type, files) : files;
|
|
131
149
|
for (const object of objects) {
|
|
132
150
|
const key = getAcfKey(object);
|
|
133
151
|
if (key !== null)
|
|
@@ -152,20 +170,20 @@ const acfProvider = {
|
|
|
152
170
|
title: 'ACF',
|
|
153
171
|
};
|
|
154
172
|
// ---- PHP file directories (API routes, hooks) -----------------------------------------
|
|
173
|
+
function filesToState(files) {
|
|
174
|
+
const state = new Map();
|
|
175
|
+
for (const file of files)
|
|
176
|
+
state.set(file.filename, file.content);
|
|
177
|
+
return state;
|
|
178
|
+
}
|
|
155
179
|
// api/ and hooks/ are both just a recursive directory of PHP files whose text is compared
|
|
156
180
|
// verbatim (line diff on change), no server-side normalization to undo. Only the directory
|
|
157
181
|
// kind, the endpoint and the labels differ.
|
|
158
182
|
function phpFilesProvider(spec) {
|
|
159
|
-
const toState = (files) => {
|
|
160
|
-
const state = new Map();
|
|
161
|
-
for (const file of files)
|
|
162
|
-
state.set(file.filename, file.content);
|
|
163
|
-
return state;
|
|
164
|
-
};
|
|
165
183
|
return {
|
|
166
184
|
dirKind: spec.dirKind,
|
|
167
185
|
async local(dir, onWarn) {
|
|
168
|
-
return
|
|
186
|
+
return filesToState(await loadFiles(dir, {
|
|
169
187
|
extension: '.php',
|
|
170
188
|
onSkip: onWarn,
|
|
171
189
|
parse: (raw, filePath) => ({
|
|
@@ -176,7 +194,7 @@ function phpFilesProvider(spec) {
|
|
|
176
194
|
}));
|
|
177
195
|
},
|
|
178
196
|
async remote(wp) {
|
|
179
|
-
return
|
|
197
|
+
return filesToState(await wp.get(spec.endpoint));
|
|
180
198
|
},
|
|
181
199
|
resource: spec.resource,
|
|
182
200
|
title: spec.title,
|
|
@@ -194,6 +212,24 @@ const hookProvider = phpFilesProvider({
|
|
|
194
212
|
resource: 'hook',
|
|
195
213
|
title: 'Hooks',
|
|
196
214
|
});
|
|
215
|
+
// ---- Dynamic blocks -------------------------------------------------------------------
|
|
216
|
+
// Keyed by slug, compared on the whole folder (file name => text). A local problem fails the
|
|
217
|
+
// resource, the same stance as pages: pushing would stop on it anyway.
|
|
218
|
+
const blockProvider = {
|
|
219
|
+
dirKind: 'block',
|
|
220
|
+
async local(dir) {
|
|
221
|
+
const { blocks, problems } = await readLocalBlocks(dir);
|
|
222
|
+
if (problems.length > 0)
|
|
223
|
+
throw new Error(formatBlockProblems(problems));
|
|
224
|
+
return new Map(blocks.map((block) => [block.slug, block.files]));
|
|
225
|
+
},
|
|
226
|
+
async remote(wp) {
|
|
227
|
+
const blocks = await wp.get(BLOCKS_ENDPOINT);
|
|
228
|
+
return new Map(blocks.map((block) => [block.slug, block.files]));
|
|
229
|
+
},
|
|
230
|
+
resource: 'block',
|
|
231
|
+
title: 'Dynamic blocks',
|
|
232
|
+
};
|
|
197
233
|
// ---- Pages ------------------------------------------------------------------------------
|
|
198
234
|
// Keyed by slug (the page's identity on both sides). `link` is server-derived and never
|
|
199
235
|
// compared. A local problem (a `.php` in pages/, a bad header) fails the whole resource rather
|
|
@@ -226,8 +262,10 @@ async function childThemeState(wp, kind) {
|
|
|
226
262
|
const state = new Map(files.map(({ slug, ...rest }) => [slug, rest]));
|
|
227
263
|
for (const entry of child.customized) {
|
|
228
264
|
const [dir, slug] = entry.split('/', 2);
|
|
229
|
-
if (dir === kind)
|
|
230
|
-
|
|
265
|
+
if (dir === kind && slug !== undefined) {
|
|
266
|
+
const existing = state.get(slug);
|
|
267
|
+
state.set(slug, { ...(isRecord(existing) && existing), siteEditor: 'customized' });
|
|
268
|
+
}
|
|
231
269
|
}
|
|
232
270
|
return state;
|
|
233
271
|
}
|
|
@@ -416,6 +454,7 @@ export const RESOURCE_STATE_PROVIDERS = [
|
|
|
416
454
|
acfProvider,
|
|
417
455
|
apiProvider,
|
|
418
456
|
hookProvider,
|
|
457
|
+
blockProvider,
|
|
419
458
|
templateProvider,
|
|
420
459
|
partProvider,
|
|
421
460
|
pageProvider,
|
|
@@ -462,7 +501,7 @@ export async function composerLocalState(rootDir) {
|
|
|
462
501
|
state.set(file, await readFile(join(rootDir, file), 'utf8'));
|
|
463
502
|
}
|
|
464
503
|
catch (error) {
|
|
465
|
-
if (error
|
|
504
|
+
if (errorCode(error) !== 'ENOENT')
|
|
466
505
|
throw error;
|
|
467
506
|
}
|
|
468
507
|
}
|
|
@@ -9,6 +9,7 @@ import { LoopressCommand } from './base.js';
|
|
|
9
9
|
import { compareStates, isEmptyDiff } from './diff-state.js';
|
|
10
10
|
import { guardProductionPush } from './guard-production-push.js';
|
|
11
11
|
import { isInteractive } from './interactive.js';
|
|
12
|
+
import { errorMessage } from './narrow.js';
|
|
12
13
|
import { getResourceStateProvider } from './resource-state.js';
|
|
13
14
|
import { materializeSnapshot } from './rollback-materialize.js';
|
|
14
15
|
import { listSnapshots, readSnapshot, recordToState } from './snapshot-store.js';
|
|
@@ -69,7 +70,7 @@ export function resourceRollbackCommand(resource, options) {
|
|
|
69
70
|
snapshot = await readSnapshot(rootDir, resource, this.siteConfig.name, flags.to);
|
|
70
71
|
}
|
|
71
72
|
catch (error) {
|
|
72
|
-
this.error(error
|
|
73
|
+
this.error(errorMessage(error));
|
|
73
74
|
}
|
|
74
75
|
await guardProductionPush({
|
|
75
76
|
dryRun: this.dryRun,
|
|
@@ -140,7 +141,7 @@ export function resourceRollbackCommand(resource, options) {
|
|
|
140
141
|
// A cleanup failure here must never hide a real error from the push above: warn about
|
|
141
142
|
// it and let the original error (if any) keep propagating instead of being replaced.
|
|
142
143
|
await rm(tmpDir, { force: true, recursive: true }).catch((error) => {
|
|
143
|
-
this.warn(`Could not remove the temporary directory "${tmpDir}": ${error
|
|
144
|
+
this.warn(`Could not remove the temporary directory "${tmpDir}": ${errorMessage(error)}`);
|
|
144
145
|
});
|
|
145
146
|
}
|
|
146
147
|
this.log(`Rolled back ${provider.title} to the snapshot from ${snapshot.createdAt}.`);
|