@loopress/cli 0.32.2 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +511 -154
- package/dist/commands/acf/list.d.ts +1 -1
- package/dist/commands/acf/list.js +3 -4
- package/dist/commands/acf/pull.d.ts +1 -1
- package/dist/commands/acf/pull.js +9 -6
- package/dist/commands/acf/push.d.ts +1 -1
- package/dist/commands/acf/push.js +5 -4
- package/dist/commands/api/publish.js +2 -1
- package/dist/commands/app/pull.js +2 -1
- package/dist/commands/app/push.js +5 -4
- package/dist/commands/app/{remove.d.ts → rm.d.ts} +1 -0
- package/dist/commands/app/{remove.js → rm.js} +2 -1
- package/dist/commands/block/diff.d.ts +2 -0
- package/dist/commands/block/diff.js +5 -0
- package/dist/commands/block/list.d.ts +8 -0
- package/dist/commands/block/list.js +23 -0
- package/dist/commands/block/pull.d.ts +21 -0
- package/dist/commands/block/pull.js +67 -0
- package/dist/commands/block/push.d.ts +20 -0
- package/dist/commands/block/push.js +48 -0
- package/dist/commands/block/rm.d.ts +23 -0
- package/dist/commands/block/rm.js +69 -0
- package/dist/commands/block/rollback.d.ts +2 -0
- package/dist/commands/block/rollback.js +5 -0
- package/dist/commands/composer/init.js +1 -0
- package/dist/commands/composer/push.js +5 -4
- package/dist/commands/content/copy.d.ts +3 -0
- package/dist/commands/content/copy.js +79 -2
- package/dist/commands/dev.js +4 -3
- package/dist/commands/diff.d.ts +1 -0
- package/dist/commands/diff.js +33 -3
- package/dist/commands/doctor.d.ts +4 -2
- package/dist/commands/doctor.js +37 -23
- package/dist/commands/form/pull.js +7 -5
- package/dist/commands/form/push.js +4 -3
- package/dist/commands/init.js +4 -3
- package/dist/commands/menu/push.js +8 -8
- package/dist/commands/option/list.js +5 -2
- package/dist/commands/option/push.js +2 -1
- package/dist/commands/option/{remove.d.ts → rm.d.ts} +1 -0
- package/dist/commands/option/{remove.js → rm.js} +2 -1
- package/dist/commands/page/push.js +2 -1
- package/dist/commands/plugin/audit.d.ts +4 -4
- package/dist/commands/plugin/audit.js +35 -15
- package/dist/commands/plugin/pull.js +3 -2
- package/dist/commands/plugin/push.js +2 -2
- package/dist/commands/plugin/status.js +3 -1
- package/dist/commands/project/config.js +7 -6
- package/dist/commands/project/pull.js +3 -1
- package/dist/commands/project/push.js +6 -4
- package/dist/commands/project/{remove.d.ts → rm.d.ts} +1 -0
- package/dist/commands/project/{remove.js → rm.js} +3 -2
- package/dist/commands/project/switch.js +6 -4
- package/dist/commands/promote.js +7 -2
- package/dist/commands/pull.d.ts +2 -0
- package/dist/commands/pull.js +42 -18
- package/dist/commands/push.d.ts +2 -1
- package/dist/commands/push.js +69 -25
- package/dist/commands/snippet/publish.js +9 -5
- package/dist/commands/snippet/push.d.ts +1 -1
- package/dist/commands/snippet/push.js +15 -12
- package/dist/commands/status.js +1 -0
- package/dist/commands/theme/activate.d.ts +21 -0
- package/dist/commands/theme/activate.js +44 -0
- package/dist/commands/theme/pull.js +2 -1
- package/dist/commands/theme/push.js +7 -3
- package/dist/commands/theme/style/push.js +7 -2
- package/dist/commands/theme/template/push.js +25 -6
- package/dist/config/auth.manager.d.ts +10 -7
- package/dist/config/auth.manager.js +5 -1
- package/dist/config/json-file.d.ts +1 -1
- package/dist/config/project-config.manager.d.ts +2 -2
- package/dist/config/project-config.manager.js +13 -21
- package/dist/lib/api-client.js +16 -21
- package/dist/lib/app-build.js +6 -2
- package/dist/lib/app-manifest.js +6 -6
- package/dist/lib/base.d.ts +2 -1
- package/dist/lib/base.js +10 -8
- package/dist/lib/bootstrap-full-install.js +8 -6
- package/dist/lib/capabilities.d.ts +44 -0
- package/dist/lib/capabilities.js +85 -0
- package/dist/lib/declared-type-command.js +4 -2
- package/dist/lib/dev-watch.js +5 -5
- package/dist/lib/diff-command.d.ts +2 -3
- package/dist/lib/diff-command.js +9 -7
- package/dist/lib/diff-state.js +2 -3
- package/dist/lib/load-files.js +2 -1
- package/dist/lib/load-snippets.js +37 -42
- package/dist/lib/local-callback-server.js +7 -1
- package/dist/lib/narrow.d.ts +5 -0
- package/dist/lib/narrow.js +26 -0
- package/dist/lib/php-files-command.js +8 -5
- package/dist/lib/plugin-detection.d.ts +0 -1
- package/dist/lib/plugin-detection.js +2 -4
- package/dist/lib/push-command.d.ts +1 -1
- package/dist/lib/push-command.js +10 -5
- package/dist/lib/put-or-create.d.ts +2 -2
- package/dist/lib/readdir-tolerant.js +3 -1
- package/dist/lib/resource-state.js +38 -13
- package/dist/lib/rollback-command.d.ts +1 -1
- package/dist/lib/rollback-command.js +3 -2
- package/dist/lib/rollback-materialize.js +28 -8
- package/dist/lib/rotate-app-password.js +1 -0
- package/dist/lib/sentry.js +2 -2
- package/dist/lib/snapshot-store.d.ts +10 -7
- package/dist/lib/snapshot-store.js +21 -5
- package/dist/lib/spinner.js +4 -0
- package/dist/lib/validate-local.js +35 -20
- package/dist/lib/wp-client.d.ts +6 -1
- package/dist/lib/wp-client.js +54 -69
- package/dist/lib/wp-site-diagnostic.js +3 -5
- package/dist/types/global-config.generated.d.ts +1 -1
- package/dist/types/plugin.d.ts +7 -5
- package/dist/types/plugin.js +6 -1
- package/dist/types/project-config.generated.d.ts +9 -1
- package/dist/types/snippet.d.ts +2 -2
- package/dist/types/snippet.js +1 -1
- package/dist/utils/acf-format.js +6 -3
- package/dist/utils/block-format.d.ts +27 -0
- package/dist/utils/block-format.js +93 -0
- package/dist/utils/composer.d.ts +7 -15
- package/dist/utils/composer.js +43 -16
- package/dist/utils/declared-type-format.js +26 -19
- package/dist/utils/form-format.js +2 -2
- package/dist/utils/loopress-config.js +9 -1
- package/dist/utils/menu-format.d.ts +3 -1
- package/dist/utils/menu-format.js +5 -0
- package/dist/utils/option-format.d.ts +2 -2
- package/dist/utils/option-format.js +15 -25
- package/dist/utils/page-format.js +7 -6
- package/dist/utils/php-top-level.js +15 -4
- package/dist/utils/plugin-sync.d.ts +7 -7
- package/dist/utils/plugin-sync.js +11 -11
- package/dist/utils/plugins.js +9 -6
- package/dist/utils/resource-dirs.d.ts +2 -0
- package/dist/utils/resource-dirs.js +9 -1
- package/dist/utils/snippet-format.d.ts +1 -1
- package/dist/utils/snippet-format.js +5 -3
- package/dist/utils/template-format.js +2 -2
- package/dist/utils/theme-css.js +14 -6
- package/dist/utils/theme-styles-format.d.ts +3 -0
- package/dist/utils/theme-styles-format.js +15 -5
- package/dist/utils/version.js +1 -1
- package/oclif.manifest.json +1938 -1393
- package/package.json +42 -17
- package/schemas/global-config.schema.json +1 -1
- package/schemas/project-config.schema.json +11 -1
|
@@ -1,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 = {}) {
|
|
@@ -72,7 +76,7 @@ export class PushCommand extends LoopressCommand {
|
|
|
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
|
|
@@ -247,7 +252,7 @@ export class PushCommand extends LoopressCommand {
|
|
|
247
252
|
local = await provider.local(dir, () => { }, this.wp);
|
|
248
253
|
}
|
|
249
254
|
catch (error) {
|
|
250
|
-
this.error(`Could not check for changes made on ${this.siteConfig.url} outside Loopress: ${error
|
|
255
|
+
this.error(`Could not check for changes made on ${this.siteConfig.url} outside Loopress: ${errorMessage(error)}. Retry, or pass --overwrite.`);
|
|
251
256
|
}
|
|
252
257
|
const overwritten = [...local.keys()].filter((id) => (writes?.(id) ?? true) &&
|
|
253
258
|
remote.get(id) !== undefined &&
|
|
@@ -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
|
}
|
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { basename, join, relative, sep } from 'node:path';
|
|
3
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
|
}
|
|
@@ -166,20 +170,20 @@ const acfProvider = {
|
|
|
166
170
|
title: 'ACF',
|
|
167
171
|
};
|
|
168
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
|
+
}
|
|
169
179
|
// api/ and hooks/ are both just a recursive directory of PHP files whose text is compared
|
|
170
180
|
// verbatim (line diff on change), no server-side normalization to undo. Only the directory
|
|
171
181
|
// kind, the endpoint and the labels differ.
|
|
172
182
|
function phpFilesProvider(spec) {
|
|
173
|
-
const toState = (files) => {
|
|
174
|
-
const state = new Map();
|
|
175
|
-
for (const file of files)
|
|
176
|
-
state.set(file.filename, file.content);
|
|
177
|
-
return state;
|
|
178
|
-
};
|
|
179
183
|
return {
|
|
180
184
|
dirKind: spec.dirKind,
|
|
181
185
|
async local(dir, onWarn) {
|
|
182
|
-
return
|
|
186
|
+
return filesToState(await loadFiles(dir, {
|
|
183
187
|
extension: '.php',
|
|
184
188
|
onSkip: onWarn,
|
|
185
189
|
parse: (raw, filePath) => ({
|
|
@@ -190,7 +194,7 @@ function phpFilesProvider(spec) {
|
|
|
190
194
|
}));
|
|
191
195
|
},
|
|
192
196
|
async remote(wp) {
|
|
193
|
-
return
|
|
197
|
+
return filesToState(await wp.get(spec.endpoint));
|
|
194
198
|
},
|
|
195
199
|
resource: spec.resource,
|
|
196
200
|
title: spec.title,
|
|
@@ -208,6 +212,24 @@ const hookProvider = phpFilesProvider({
|
|
|
208
212
|
resource: 'hook',
|
|
209
213
|
title: 'Hooks',
|
|
210
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
|
+
};
|
|
211
233
|
// ---- Pages ------------------------------------------------------------------------------
|
|
212
234
|
// Keyed by slug (the page's identity on both sides). `link` is server-derived and never
|
|
213
235
|
// compared. A local problem (a `.php` in pages/, a bad header) fails the whole resource rather
|
|
@@ -240,8 +262,10 @@ async function childThemeState(wp, kind) {
|
|
|
240
262
|
const state = new Map(files.map(({ slug, ...rest }) => [slug, rest]));
|
|
241
263
|
for (const entry of child.customized) {
|
|
242
264
|
const [dir, slug] = entry.split('/', 2);
|
|
243
|
-
if (dir === kind)
|
|
244
|
-
|
|
265
|
+
if (dir === kind && slug !== undefined) {
|
|
266
|
+
const existing = state.get(slug);
|
|
267
|
+
state.set(slug, { ...(isRecord(existing) && existing), siteEditor: 'customized' });
|
|
268
|
+
}
|
|
245
269
|
}
|
|
246
270
|
return state;
|
|
247
271
|
}
|
|
@@ -430,6 +454,7 @@ export const RESOURCE_STATE_PROVIDERS = [
|
|
|
430
454
|
acfProvider,
|
|
431
455
|
apiProvider,
|
|
432
456
|
hookProvider,
|
|
457
|
+
blockProvider,
|
|
433
458
|
templateProvider,
|
|
434
459
|
partProvider,
|
|
435
460
|
pageProvider,
|
|
@@ -476,7 +501,7 @@ export async function composerLocalState(rootDir) {
|
|
|
476
501
|
state.set(file, await readFile(join(rootDir, file), 'utf8'));
|
|
477
502
|
}
|
|
478
503
|
catch (error) {
|
|
479
|
-
if (error
|
|
504
|
+
if (errorCode(error) !== 'ENOENT')
|
|
480
505
|
throw error;
|
|
481
506
|
}
|
|
482
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}.`);
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
3
4
|
import { getAcfKey } from '../utils/acf-format.js';
|
|
5
|
+
import { writeBlock } from '../utils/block-format.js';
|
|
4
6
|
import { CPT_SPEC, fileExtension, formatDeclaredFile, META_SPEC, PATTERN_SPEC, ROLE_SPEC, TAXONOMY_SPEC } from '../utils/declared-type-format.js';
|
|
5
7
|
import { getFormId, getFormTitle } from '../utils/form-format.js';
|
|
6
8
|
import { getMenuSlug } from '../utils/menu-format.js';
|
|
7
9
|
import { defaultReadonlyFor, optionFileName } from '../utils/option-format.js';
|
|
8
|
-
import { buildMetaFile, buildSnippetFile } from '../utils/snippet-format.js';
|
|
10
|
+
import { buildMetaFile, buildSnippetFile, normalizeSnippet } from '../utils/snippet-format.js';
|
|
9
11
|
import { themeStylesFileName } from '../utils/theme-styles-format.js';
|
|
10
12
|
import { toSlug } from '../utils/to-slug.js';
|
|
13
|
+
import { isRecord } from './narrow.js';
|
|
11
14
|
const SNIPPET_EXTENSIONS = { css: 'css', html: 'html', js: 'js', php: 'php', text: 'txt' };
|
|
12
15
|
async function writeJson(path, value) {
|
|
13
16
|
await mkdir(dirname(path), { recursive: true });
|
|
@@ -19,9 +22,20 @@ async function writeJson(path, value) {
|
|
|
19
22
|
// compares (no `id`, no `description`, `code` with its `<?php` tag already stripped), exactly
|
|
20
23
|
// what `normalizeSnippet` + `stripPhpOpeningTag` produce, so it plugs straight into the same
|
|
21
24
|
// `buildSnippetFile`/`buildMetaFile` pair `snippet pull` uses to write files.
|
|
25
|
+
// A snapshot entry that should be an object: anything else is a corrupted snapshot file.
|
|
26
|
+
function snapshotRecord(id, value) {
|
|
27
|
+
if (!isRecord(value))
|
|
28
|
+
throw new Error(`Snapshot entry "${id}" is not an object.`);
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
const SNAPSHOT_OPTION = z.object({ autoload: z.string(), value: z.unknown() });
|
|
22
32
|
async function snippet(id, value, dir) {
|
|
23
|
-
const
|
|
24
|
-
|
|
33
|
+
const data = snapshotRecord(id, value);
|
|
34
|
+
// normalizeSnippet would coerce it, writing a corrupted entry's code over the live snippet.
|
|
35
|
+
if (typeof data.code !== 'string')
|
|
36
|
+
throw new Error(`Snapshot entry "${id}" has a non-string "code".`);
|
|
37
|
+
// normalizeSnippet is idempotent on its own output, so it narrows `data` without changing it.
|
|
38
|
+
const normalized = { ...normalizeSnippet(data), description: '', id: Number(id) };
|
|
25
39
|
const ext = SNIPPET_EXTENSIONS[normalized.type];
|
|
26
40
|
const base = `${normalized.id}-${toSlug(normalized.name)}`;
|
|
27
41
|
await mkdir(dir, { recursive: true });
|
|
@@ -31,7 +45,7 @@ async function snippet(id, value, dir) {
|
|
|
31
45
|
// value is the whole form JSON (minus `modified`/`modified_gmt`), the same shape `form pull`
|
|
32
46
|
// writes verbatim; `form push` re-sends it as-is.
|
|
33
47
|
async function form(id, value, dir) {
|
|
34
|
-
const data = value;
|
|
48
|
+
const data = snapshotRecord(id, value);
|
|
35
49
|
const formId = getFormId(data) ?? Number(id);
|
|
36
50
|
const slug = toSlug(getFormTitle(data), 'untitled');
|
|
37
51
|
await writeJson(join(dir, `${formId}-${slug}.json`), data);
|
|
@@ -41,7 +55,7 @@ async function form(id, value, dir) {
|
|
|
41
55
|
async function acf(id, value, dir) {
|
|
42
56
|
const separator = id.indexOf('/');
|
|
43
57
|
const type = id.slice(0, separator);
|
|
44
|
-
const data = value;
|
|
58
|
+
const data = snapshotRecord(id, value);
|
|
45
59
|
const key = getAcfKey(data) ?? id.slice(separator + 1);
|
|
46
60
|
await writeJson(join(dir, type, `${key}.json`), data);
|
|
47
61
|
}
|
|
@@ -51,13 +65,18 @@ async function phpFile(id, value, dir) {
|
|
|
51
65
|
await mkdir(dirname(filePath), { recursive: true });
|
|
52
66
|
await writeFile(filePath, String(value));
|
|
53
67
|
}
|
|
68
|
+
// block: id is the slug, value the folder's files (file name => text).
|
|
69
|
+
async function block(id, value, dir) {
|
|
70
|
+
const files = snapshotRecord(id, value);
|
|
71
|
+
await writeBlock(dir, { files: Object.fromEntries(Object.entries(files).map(([name, content]) => [name, String(content)])), slug: id });
|
|
72
|
+
}
|
|
54
73
|
// ids are 'menu-locations' or `menu/<slug>` (see resource-state.ts's menuProvider).
|
|
55
74
|
async function menu(id, value, dir) {
|
|
56
75
|
if (id === 'menu-locations') {
|
|
57
76
|
await writeJson(join(dir, 'menu-locations.json'), value);
|
|
58
77
|
return;
|
|
59
78
|
}
|
|
60
|
-
const data = value;
|
|
79
|
+
const data = snapshotRecord(id, value);
|
|
61
80
|
const slug = getMenuSlug(data) ?? id.slice('menu/'.length);
|
|
62
81
|
await writeJson(join(dir, `${slug}.json`), data);
|
|
63
82
|
}
|
|
@@ -67,7 +86,7 @@ async function menu(id, value, dir) {
|
|
|
67
86
|
// dangerous option (siteurl, home, ...); defaulting to the same policy `option add` itself uses
|
|
68
87
|
// keeps a rolled-back option exactly as protected as a freshly tracked one, never less.
|
|
69
88
|
async function option(id, value, dir) {
|
|
70
|
-
const { autoload, value: optionValue } = value;
|
|
89
|
+
const { autoload, value: optionValue } = SNAPSHOT_OPTION.parse(value);
|
|
71
90
|
const local = { autoload, name: id, readonly: defaultReadonlyFor(id), value: optionValue };
|
|
72
91
|
await writeJson(join(dir, optionFileName(id)), local);
|
|
73
92
|
}
|
|
@@ -79,12 +98,13 @@ async function themeStyles(id, value, dir) {
|
|
|
79
98
|
// written in the spec's file format like `<resource> pull` does.
|
|
80
99
|
function declaredType(spec) {
|
|
81
100
|
return async (id, value, dir) => {
|
|
82
|
-
await writeFile(join(dir, `${id}${fileExtension(spec)}`), formatDeclaredFile(spec, value));
|
|
101
|
+
await writeFile(join(dir, `${id}${fileExtension(spec)}`), formatDeclaredFile(spec, snapshotRecord(id, value)));
|
|
83
102
|
};
|
|
84
103
|
}
|
|
85
104
|
const MATERIALIZERS = {
|
|
86
105
|
acf,
|
|
87
106
|
api: phpFile,
|
|
107
|
+
block,
|
|
88
108
|
cpt: declaredType(CPT_SPEC),
|
|
89
109
|
form,
|
|
90
110
|
hook: phpFile,
|
package/dist/lib/sentry.js
CHANGED
|
@@ -20,8 +20,8 @@ export function runtimeContext() {
|
|
|
20
20
|
os: `${platform()} ${release()}`,
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
|
-
// Positional args and flag values
|
|
24
|
-
//
|
|
23
|
+
// Positional args and flag values are user input: environment names, local paths, slugs, and
|
|
24
|
+
// anything else typed after a command, possibly a URL or a credential. Only flag names are
|
|
25
25
|
// safe to report, they help tell which code path crashed without leaking what was passed to it.
|
|
26
26
|
export function redactArgv(argv) {
|
|
27
27
|
return argv.map((arg) => {
|