@loopress/cli 0.24.1 → 0.26.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 +721 -90
- package/dist/commands/acf/push.d.ts +1 -0
- package/dist/commands/acf/push.js +25 -2
- package/dist/commands/acf/rollback.d.ts +2 -0
- package/dist/commands/acf/rollback.js +5 -0
- package/dist/commands/api/rollback.d.ts +2 -0
- package/dist/commands/api/rollback.js +5 -0
- package/dist/commands/diff.js +1 -1
- package/dist/commands/doctor.d.ts +17 -1
- package/dist/commands/doctor.js +55 -38
- package/dist/commands/form/pull.js +4 -1
- package/dist/commands/form/push.d.ts +1 -0
- package/dist/commands/form/push.js +29 -3
- package/dist/commands/form/rollback.d.ts +2 -0
- package/dist/commands/form/rollback.js +5 -0
- package/dist/commands/hook/rollback.d.ts +2 -0
- package/dist/commands/hook/rollback.js +5 -0
- package/dist/commands/init.js +2 -0
- package/dist/commands/menu/diff.d.ts +2 -0
- package/dist/commands/menu/diff.js +5 -0
- package/dist/commands/menu/list.d.ts +13 -0
- package/dist/commands/menu/list.js +32 -0
- package/dist/commands/menu/pull.d.ts +15 -0
- package/dist/commands/menu/pull.js +72 -0
- package/dist/commands/menu/push.d.ts +17 -0
- package/dist/commands/menu/push.js +120 -0
- package/dist/commands/menu/rollback.d.ts +2 -0
- package/dist/commands/menu/rollback.js +5 -0
- package/dist/commands/option/add.js +1 -1
- package/dist/commands/option/push.d.ts +1 -0
- package/dist/commands/option/push.js +26 -1
- package/dist/commands/option/rollback.d.ts +2 -0
- package/dist/commands/option/rollback.js +5 -0
- package/dist/commands/page/diff.d.ts +2 -0
- package/dist/commands/page/diff.js +5 -0
- package/dist/commands/page/list.d.ts +8 -0
- package/dist/commands/page/list.js +22 -0
- package/dist/commands/page/push.d.ts +20 -0
- package/dist/commands/page/push.js +60 -0
- package/dist/commands/plugin/push.d.ts +1 -0
- package/dist/commands/plugin/push.js +14 -0
- package/dist/commands/promote.d.ts +8 -1
- package/dist/commands/promote.js +2 -0
- package/dist/commands/pull.d.ts +11 -1
- package/dist/commands/pull.js +9 -2
- package/dist/commands/push.d.ts +11 -1
- package/dist/commands/push.js +10 -2
- package/dist/commands/seo/pull.js +6 -2
- package/dist/commands/seo/push.d.ts +1 -0
- package/dist/commands/seo/push.js +33 -3
- package/dist/commands/seo/rollback.d.ts +2 -0
- package/dist/commands/seo/rollback.js +5 -0
- package/dist/commands/snippet/push.d.ts +1 -0
- package/dist/commands/snippet/push.js +31 -1
- package/dist/commands/snippet/rollback.d.ts +2 -0
- package/dist/commands/snippet/rollback.js +5 -0
- package/dist/commands/theme-styles/diff.d.ts +2 -0
- package/dist/commands/theme-styles/diff.js +5 -0
- package/dist/commands/theme-styles/pull.d.ts +12 -0
- package/dist/commands/theme-styles/pull.js +31 -0
- package/dist/commands/theme-styles/push.d.ts +12 -0
- package/dist/commands/theme-styles/push.js +45 -0
- package/dist/commands/theme-styles/rollback.d.ts +2 -0
- package/dist/commands/theme-styles/rollback.js +5 -0
- package/dist/lib/app-manifest.js +4 -27
- package/dist/lib/base.d.ts +2 -0
- package/dist/lib/base.js +6 -0
- package/dist/lib/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +2 -1
- package/dist/lib/find-orphaned-files.js +5 -47
- package/dist/lib/load-files.js +4 -42
- package/dist/lib/php-files-command.d.ts +1 -0
- package/dist/lib/php-files-command.js +84 -47
- package/dist/lib/push-command.d.ts +4 -0
- package/dist/lib/push-command.js +49 -0
- package/dist/lib/readdir-tolerant.d.ts +1 -0
- package/dist/lib/readdir-tolerant.js +12 -0
- package/dist/lib/resource-state.js +116 -3
- package/dist/lib/rollback-command.d.ts +21 -0
- package/dist/lib/rollback-command.js +180 -0
- package/dist/lib/rollback-materialize.d.ts +1 -0
- package/dist/lib/rollback-materialize.js +121 -0
- package/dist/lib/snapshot-store.d.ts +30 -0
- package/dist/lib/snapshot-store.js +133 -0
- package/dist/lib/validate-local.js +7 -1
- package/dist/types/project-config.generated.d.ts +12 -0
- package/dist/utils/acf-format.d.ts +1 -0
- package/dist/utils/acf-format.js +6 -0
- package/dist/utils/form-format.d.ts +4 -0
- package/dist/utils/form-format.js +3 -7
- package/dist/utils/menu-format.d.ts +24 -0
- package/dist/utils/menu-format.js +8 -0
- package/dist/utils/option-format.d.ts +1 -0
- package/dist/utils/page-format.d.ts +27 -0
- package/dist/utils/page-format.js +111 -0
- package/dist/utils/resource-dirs.d.ts +3 -0
- package/dist/utils/resource-dirs.js +6 -0
- package/dist/utils/seo-format.d.ts +8 -0
- package/dist/utils/seo-format.js +3 -0
- package/dist/utils/snippet-format.d.ts +1 -0
- package/dist/utils/snippet-format.js +1 -0
- package/dist/utils/theme-styles-format.d.ts +26 -0
- package/dist/utils/theme-styles-format.js +55 -0
- package/oclif.manifest.json +1633 -168
- package/package.json +4 -2
- package/schemas/project-config.schema.json +15 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { confirm } from '@inquirer/prompts';
|
|
2
2
|
import { Args, Flags } from '@oclif/core';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
3
4
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
4
5
|
import { dirname, join, relative, sep } from 'node:path';
|
|
5
6
|
import { authManager } from '../config/auth.manager.js';
|
|
@@ -11,12 +12,26 @@ import { basenameKey, findOrphanedFiles } from './find-orphaned-files.js';
|
|
|
11
12
|
import { isInteractive } from './interactive.js';
|
|
12
13
|
import { loadFiles as loadDirectoryFiles } from './load-files.js';
|
|
13
14
|
import { PushCommand } from './push-command.js';
|
|
15
|
+
import { getResourceStateProvider } from './resource-state.js';
|
|
14
16
|
import { isApplicative404 } from './wp-client.js';
|
|
15
17
|
// Mirrors wordpress-plugin FileWriter::DECLARE_PATTERN / withGuard(): the server rejects both
|
|
16
18
|
// an absent declare(strict_types=1); and one that appears more than once (it needs a single
|
|
17
19
|
// unambiguous insertion point for the ABSPATH guard). Matching "exactly once" here, not just
|
|
18
20
|
// presence, so a file that would still fail server-side doesn't falsely pass this check.
|
|
19
21
|
const DECLARE_PATTERN = /declare\s*\(\s*strict_types\s*=\s*1\s*\)\s*;/g;
|
|
22
|
+
// A file's expected revision (#234), computed from `beforeState`: the remote listing already
|
|
23
|
+
// read by `captureBeforePushState` right before this push, for the rollback snapshot. Its value
|
|
24
|
+
// for a PHP file is exactly the file's own raw content string (see phpFilesProvider in
|
|
25
|
+
// resource-state.ts), the same string AbstractFilesController::push_file() hashes server-side to
|
|
26
|
+
// produce the `revision` it returns, so hashing it here the same way (sha256, hex) reproduces
|
|
27
|
+
// that server revision with no extra network round trip and no need for the server to echo a
|
|
28
|
+
// `revision` field back through the list endpoint for the CLI to consume. A filename absent from
|
|
29
|
+
// `beforeState` (a new file, or `beforeState` itself unavailable, e.g. a dry run or a failed
|
|
30
|
+
// remote read) has nothing to condition on: pushFile falls back to today's unconditional create.
|
|
31
|
+
function expectedRevisionFor(beforeState, filename) {
|
|
32
|
+
const content = beforeState?.get(filename);
|
|
33
|
+
return typeof content === 'string' ? createHash('sha256').update(content, 'utf8').digest('hex') : undefined;
|
|
34
|
+
}
|
|
20
35
|
function pathArg(pathNoun) {
|
|
21
36
|
return { path: Args.string({ description: `Path to ${pathNoun} (overrides project config)` }) };
|
|
22
37
|
}
|
|
@@ -57,25 +72,34 @@ export function resourcePushCommand(spec) {
|
|
|
57
72
|
const path = resolveResourceDir(spec.dirKind, this.localConfig, args.path);
|
|
58
73
|
this.log(`Pushing ${spec.label} to ${url}`);
|
|
59
74
|
this.log(`${spec.pathLabel} path: ${path}`);
|
|
75
|
+
const provider = getResourceStateProvider(spec.cliName);
|
|
76
|
+
const beforeState = await this.captureBeforePushState(provider, path);
|
|
60
77
|
const files = await this.loadFiles(path);
|
|
61
78
|
this.log(`Found ${pluralize(files.length, spec.noun)} to push`);
|
|
62
|
-
|
|
63
|
-
|
|
79
|
+
// Local, network-free checks only (filename shape, the declare(strict_types=1) guard):
|
|
80
|
+
// catches a bad file before anything is staged on WordPress, same as before #236. The
|
|
81
|
+
// actual write is no longer per-file here, it's the single atomic batch request below.
|
|
64
82
|
await this.runPushTasks(files, (file) => file.filename, async (file, task) => {
|
|
65
|
-
|
|
66
|
-
pushed.push(file.filename);
|
|
67
|
-
if (result?.public)
|
|
68
|
-
publicRoutes.push(file.filename);
|
|
83
|
+
this.validateFileLocally(file, task);
|
|
69
84
|
});
|
|
70
85
|
if (this.failedCount > 0) {
|
|
71
86
|
this.error(`${pluralize(this.failedCount, spec.noun)} failed to push.`);
|
|
72
87
|
}
|
|
73
|
-
|
|
74
|
-
|
|
88
|
+
// Resolved before the batch request: which files to prune is a local decision (a GET plus
|
|
89
|
+
// confirmation), but their removal itself happens inside the same atomic swap as the push
|
|
90
|
+
// below, not as separate DELETE calls, so a mid-push failure can't remove server-side
|
|
91
|
+
// files without also landing the new ones.
|
|
92
|
+
const pruneList = flags.prune ? await this.resolvePruneList(new Set(files.map((file) => file.filename))) : [];
|
|
93
|
+
if (this.dryRun) {
|
|
94
|
+
for (const file of files)
|
|
95
|
+
this.log(`[dry-run] Would push: ${file.filename}`);
|
|
96
|
+
await this.writeAfterPushSnapshot(provider, path, beforeState);
|
|
97
|
+
return { pruned: pruneList, pushed: files.map((file) => file.filename), status: 'dry-run' };
|
|
75
98
|
}
|
|
76
|
-
const pruned =
|
|
77
|
-
|
|
78
|
-
|
|
99
|
+
const { pruned, pushed } = files.length === 0 && pruneList.length === 0
|
|
100
|
+
? { pruned: [], pushed: [] }
|
|
101
|
+
: await this.pushBatch(files, pruneList, beforeState);
|
|
102
|
+
await this.writeAfterPushSnapshot(provider, path, beforeState);
|
|
79
103
|
await this.recordSuccess();
|
|
80
104
|
await spec.afterPush?.({ filenames: pushed, siteConfig: this.siteConfig });
|
|
81
105
|
this.log(`All ${spec.label} pushed.`);
|
|
@@ -84,12 +108,52 @@ export function resourcePushCommand(spec) {
|
|
|
84
108
|
async loadFiles(path) {
|
|
85
109
|
return loadPhpFiles(this, path);
|
|
86
110
|
}
|
|
111
|
+
// One request stages every file and every pruned filename, then swaps them all into place
|
|
112
|
+
// together (#236): a failure anywhere in the batch (a stale expectedRevision, a class
|
|
113
|
+
// collision, a syntax error) leaves WordPress completely unchanged, not partially pushed.
|
|
114
|
+
async pushBatch(files, pruneList, beforeState) {
|
|
115
|
+
const body = {
|
|
116
|
+
files: files.map((file) => {
|
|
117
|
+
const entry = { content: file.content, filename: file.filename };
|
|
118
|
+
// `beforeState` was read once, at the start of this push run (see `expectedRevisionFor`
|
|
119
|
+
// above): there is no per-file GET endpoint to re-read it right before this batch the
|
|
120
|
+
// way `option push` does. Sent back as `expectedRevision`, WordPress still refuses the
|
|
121
|
+
// whole batch (412) if something changed the file in between, instead of silently
|
|
122
|
+
// overwriting it (#234); the vulnerable window is this run's own duration rather than a
|
|
123
|
+
// single request's, wider than option's but still far smaller than not checking at all.
|
|
124
|
+
const expectedRevision = expectedRevisionFor(beforeState, file.filename);
|
|
125
|
+
if (expectedRevision !== undefined)
|
|
126
|
+
entry.expectedRevision = expectedRevision;
|
|
127
|
+
return entry;
|
|
128
|
+
}),
|
|
129
|
+
prune: pruneList,
|
|
130
|
+
};
|
|
131
|
+
let result;
|
|
132
|
+
try {
|
|
133
|
+
result = await this.wp.post(`${spec.endpoint}/batch`, body);
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
this.error(`Push failed, nothing was changed on ${this.siteConfig.url}: ${error.message}`);
|
|
137
|
+
}
|
|
138
|
+
for (const file of result.files) {
|
|
139
|
+
this.log(file.syntax_check === 'skipped'
|
|
140
|
+
? `Pushed: ${file.filename} (syntax check skipped, unavailable on this host)`
|
|
141
|
+
: `Pushed: ${file.filename}`);
|
|
142
|
+
if (file.public) {
|
|
143
|
+
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`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
for (const filename of result.pruned)
|
|
147
|
+
this.log(`Pruned: ${filename}`);
|
|
148
|
+
return { pruned: result.pruned, pushed: result.files.map((file) => file.filename) };
|
|
149
|
+
}
|
|
87
150
|
// Mirror of `pull`'s local orphan cleanup, aimed at the server: any file on WordPress with
|
|
88
|
-
// no local counterpart is
|
|
89
|
-
// site. In a non-TTY without `--yes` it refuses rather than prunes: a stray
|
|
90
|
-
// CI must not silently wipe production routes, and unlike `pull`'s local
|
|
91
|
-
// are not recoverable from the repo.
|
|
92
|
-
|
|
151
|
+
// no local counterpart is a candidate for removal. Opt-in (`--prune`) because it removes
|
|
152
|
+
// code from a live site. In a non-TTY without `--yes` it refuses rather than prunes: a stray
|
|
153
|
+
// local path in CI must not silently wipe production routes, and unlike `pull`'s local
|
|
154
|
+
// deletions these are not recoverable from the repo. Only decides *which* filenames to
|
|
155
|
+
// prune; pushBatch() is what actually removes them, atomically alongside the push.
|
|
156
|
+
async resolvePruneList(localFilenames) {
|
|
93
157
|
const remote = await this.wp.get(spec.endpoint);
|
|
94
158
|
const orphans = remote.map((file) => file.filename).filter((name) => !localFilenames.has(name));
|
|
95
159
|
if (orphans.length === 0)
|
|
@@ -110,17 +174,9 @@ export function resourcePushCommand(spec) {
|
|
|
110
174
|
return [];
|
|
111
175
|
}
|
|
112
176
|
}
|
|
113
|
-
|
|
114
|
-
// writes serial.
|
|
115
|
-
const deleted = [];
|
|
116
|
-
for (const filename of orphans) {
|
|
117
|
-
await this.wp.delete(`${spec.endpoint}?filename=${encodeURIComponent(filename)}`);
|
|
118
|
-
this.log(`Pruned: ${filename}`);
|
|
119
|
-
deleted.push(filename);
|
|
120
|
-
}
|
|
121
|
-
return deleted;
|
|
177
|
+
return orphans;
|
|
122
178
|
}
|
|
123
|
-
|
|
179
|
+
validateFileLocally(file, task) {
|
|
124
180
|
if (!spec.filenamePattern.test(file.filename)) {
|
|
125
181
|
const message = `Invalid filename "${file.filename}": ${spec.invalidFilenameHint}`;
|
|
126
182
|
this.reportTaskFailure(message, new Error(message), task);
|
|
@@ -131,27 +187,8 @@ export function resourcePushCommand(spec) {
|
|
|
131
187
|
const message = `${file.filename}.php: "declare(strict_types=1);" ${reason}, it must appear exactly once as the first statement`;
|
|
132
188
|
this.reportTaskFailure(message, new Error(message), task);
|
|
133
189
|
}
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
task.output = `[dry-run] Would push: ${file.filename}`;
|
|
137
|
-
return undefined;
|
|
138
|
-
}
|
|
139
|
-
try {
|
|
140
|
-
const result = await this.wp.put(spec.endpoint, {
|
|
141
|
-
content: file.content,
|
|
142
|
-
filename: file.filename,
|
|
143
|
-
});
|
|
144
|
-
if (task) {
|
|
145
|
-
task.output =
|
|
146
|
-
result.syntax_check === 'skipped'
|
|
147
|
-
? `Pushed: ${file.filename} (syntax check skipped, unavailable on this host)`
|
|
148
|
-
: `Pushed: ${file.filename}`;
|
|
149
|
-
}
|
|
150
|
-
return result;
|
|
151
|
-
}
|
|
152
|
-
catch (error) {
|
|
153
|
-
this.reportTaskFailure(`Failed to push ${file.filename}: ${error.message}`, error, task);
|
|
154
|
-
}
|
|
190
|
+
if (task)
|
|
191
|
+
task.output = `Validated: ${file.filename}`;
|
|
155
192
|
}
|
|
156
193
|
}
|
|
157
194
|
return ResourcePush;
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { LoopressCommand } from './base.js';
|
|
2
|
+
import { type ResourceState } from './diff-state.js';
|
|
3
|
+
import { type ResourceStateProvider } from './resource-state.js';
|
|
2
4
|
export declare abstract class PushCommand extends LoopressCommand {
|
|
3
5
|
protected failedCount: number;
|
|
4
6
|
private refusedByGuard;
|
|
7
|
+
protected captureBeforePushState(provider: ResourceStateProvider, dir: string): Promise<ResourceState | undefined>;
|
|
5
8
|
catch(err: Error): Promise<void>;
|
|
6
9
|
protected guardProductionPush(): Promise<void>;
|
|
7
10
|
init(): Promise<void>;
|
|
@@ -13,4 +16,5 @@ export declare abstract class PushCommand extends LoopressCommand {
|
|
|
13
16
|
protected runPushTasks<T>(items: T[], label: (item: T) => string, task: (item: T, task?: {
|
|
14
17
|
output: string;
|
|
15
18
|
}) => Promise<void>): Promise<void>;
|
|
19
|
+
protected writeAfterPushSnapshot(provider: ResourceStateProvider, dir: string, beforeState: ResourceState | undefined): Promise<void>;
|
|
16
20
|
}
|
package/dist/lib/push-command.js
CHANGED
|
@@ -1,14 +1,35 @@
|
|
|
1
1
|
import got from 'got';
|
|
2
2
|
import { Listr } from 'listr2';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
3
4
|
import { authManager } from '../config/auth.manager.js';
|
|
4
5
|
import { API_URL } from './api-client.js';
|
|
5
6
|
import { LoopressCommand } from './base.js';
|
|
6
7
|
import { guardProductionPush } from './guard-production-push.js';
|
|
8
|
+
import { writeSnapshot } from './snapshot-store.js';
|
|
7
9
|
export class PushCommand extends LoopressCommand {
|
|
8
10
|
failedCount = 0;
|
|
9
11
|
// Refusals of the production guard never reach the site, so they must not pollute the
|
|
10
12
|
// deployment history with failure records.
|
|
11
13
|
refusedByGuard = false;
|
|
14
|
+
// Reads `provider`'s remote state right before a real push, for `writeAfterPushSnapshot` to
|
|
15
|
+
// pair with the actual post-push remote state once the push completes. A dry run changes
|
|
16
|
+
// nothing, so there is nothing to snapshot. Best-effort: a read failure (the site briefly
|
|
17
|
+
// unreachable) must never block the push itself, the same "never interrupt the push flow"
|
|
18
|
+
// stance recordDeployment takes; `undefined` tells `writeAfterPushSnapshot` there is no
|
|
19
|
+
// restore point to complete.
|
|
20
|
+
async captureBeforePushState(provider, dir) {
|
|
21
|
+
if (this.dryRun)
|
|
22
|
+
return undefined;
|
|
23
|
+
try {
|
|
24
|
+
return await provider.remote(this.wp, (message) => {
|
|
25
|
+
this.warn(message);
|
|
26
|
+
}, dir);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
this.warn(`Could not read remote state for a rollback snapshot: ${error.message}`);
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
12
33
|
async catch(err) {
|
|
13
34
|
if (!this.dryRun && !this.refusedByGuard && this.siteConfig) {
|
|
14
35
|
await this.recordDeployment('failure');
|
|
@@ -73,4 +94,32 @@ export class PushCommand extends LoopressCommand {
|
|
|
73
94
|
title: `Push ${label(item)}`,
|
|
74
95
|
})), { concurrent: false, exitOnError: false, renderer: this.jsonEnabled() ? 'silent' : 'default' }).run();
|
|
75
96
|
}
|
|
97
|
+
// Pairs `beforeState` (from captureBeforePushState, read before this push touched anything)
|
|
98
|
+
// with a fresh remote read taken right after the push, and writes both as one rollback
|
|
99
|
+
// snapshot. Reading the after-state from WordPress itself, rather than assuming it matches
|
|
100
|
+
// the local files that were pushed, is what keeps this correct for every resource's own
|
|
101
|
+
// partial-push semantics: a readonly option skipped by `option push`, a snippet or SEO
|
|
102
|
+
// redirect that gets a server-assigned id on create, a theme-styles push that only touches the
|
|
103
|
+
// active stylesheet, an API/hook push that leaves server-side files alone unless `--prune`.
|
|
104
|
+
// `lps <resource> rollback`'s drift check compares the environment's state at rollback time
|
|
105
|
+
// against exactly this after-state, so it has to be what actually landed, not what was sent.
|
|
106
|
+
async writeAfterPushSnapshot(provider, dir, beforeState) {
|
|
107
|
+
if (!beforeState || this.dryRun)
|
|
108
|
+
return;
|
|
109
|
+
try {
|
|
110
|
+
const afterState = await provider.remote(this.wp, (message) => {
|
|
111
|
+
this.warn(message);
|
|
112
|
+
}, dir);
|
|
113
|
+
await writeSnapshot({
|
|
114
|
+
afterState,
|
|
115
|
+
beforeState,
|
|
116
|
+
environment: this.siteConfig.name,
|
|
117
|
+
resource: provider.resource,
|
|
118
|
+
rootDir: resolve(process.cwd(), this.rootDir),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
this.warn(`Could not save a rollback snapshot after this push: ${error.message}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
76
125
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { join, relative, sep } from 'node:path';
|
|
2
3
|
export async function readdirTolerant(dir, options) {
|
|
3
4
|
try {
|
|
4
5
|
return options?.withFileTypes
|
|
@@ -11,3 +12,14 @@ export async function readdirTolerant(dir, options) {
|
|
|
11
12
|
throw error;
|
|
12
13
|
}
|
|
13
14
|
}
|
|
15
|
+
// Every file under `dir`, at any depth, as a path relative to `dir`. Always '/'-joined,
|
|
16
|
+
// never the OS separator: callers match these identities against server-sent filenames
|
|
17
|
+
// (which are always '/'-joined) or embed them in an API payload, so a Windows run must
|
|
18
|
+
// produce the exact same strings as a POSIX one. ENOENT on `dir` itself yields no files,
|
|
19
|
+
// same tolerance as readdirTolerant.
|
|
20
|
+
export async function walkFiles(dir) {
|
|
21
|
+
const entries = await readdirTolerant(dir, { recursive: true, withFileTypes: true });
|
|
22
|
+
return entries
|
|
23
|
+
.filter((entry) => entry.isFile())
|
|
24
|
+
.map((entry) => relative(dir, join(entry.parentPath, entry.name)).split(sep).join('/'));
|
|
25
|
+
}
|
|
@@ -2,9 +2,12 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { basename, join, relative, sep } from 'node:path';
|
|
3
3
|
import { ACF_OBJECT_TYPES, acfEndpoint, getAcfKey } from '../utils/acf-format.js';
|
|
4
4
|
import { FORM_ENDPOINT, getFormId } from '../utils/form-format.js';
|
|
5
|
+
import { getMenuSlug, MENU_ENDPOINT, MENU_LOCATIONS_ENDPOINT } from '../utils/menu-format.js';
|
|
5
6
|
import { optionEndpoint, parseLocalOption } from '../utils/option-format.js';
|
|
7
|
+
import { formatPageProblems, PAGES_ENDPOINT, readLocalPages } from '../utils/page-format.js';
|
|
6
8
|
import { DEFAULT_POST_TYPES, SEO_REDIRECTS_ENDPOINT, SEO_SETTINGS_ENDPOINT, seoPostMetaEndpoint, } from '../utils/seo-format.js';
|
|
7
9
|
import { normalizeSnippet, SNIPPETS_ENDPOINT, stripPhpOpeningTag } from '../utils/snippet-format.js';
|
|
10
|
+
import { canonicalGlobalStyles, getActiveThemeGlobalStyles, globalStylesEndpoint, THEME_STYLES_FILE_SUFFIX, } from '../utils/theme-styles-format.js';
|
|
8
11
|
import { loadFiles } from './load-files.js';
|
|
9
12
|
import { loadSnippets } from './load-snippets.js';
|
|
10
13
|
import { isApplicative404, isNotFoundError } from './wp-client.js';
|
|
@@ -192,6 +195,28 @@ const hookProvider = phpFilesProvider({
|
|
|
192
195
|
resource: 'hook',
|
|
193
196
|
title: 'Hooks',
|
|
194
197
|
});
|
|
198
|
+
// ---- Pages ------------------------------------------------------------------------------
|
|
199
|
+
// Keyed by slug (the page's identity on both sides). `link` is server-derived and never
|
|
200
|
+
// compared. A local problem (a `.php` in pages/, a bad header) fails the whole resource rather
|
|
201
|
+
// than hiding that page, the same stop-everything stance as `page push`.
|
|
202
|
+
function canonicalPage(page) {
|
|
203
|
+
return { html: page.html, status: page.status, title: page.title };
|
|
204
|
+
}
|
|
205
|
+
const pageProvider = {
|
|
206
|
+
dirKind: 'page',
|
|
207
|
+
async local(dir) {
|
|
208
|
+
const { pages, problems } = await readLocalPages(dir);
|
|
209
|
+
if (problems.length > 0)
|
|
210
|
+
throw new Error(formatPageProblems(problems));
|
|
211
|
+
return new Map(pages.map((page) => [page.slug, canonicalPage(page)]));
|
|
212
|
+
},
|
|
213
|
+
async remote(wp) {
|
|
214
|
+
const pages = await wp.get(PAGES_ENDPOINT);
|
|
215
|
+
return new Map(pages.map((page) => [page.slug, canonicalPage(page)]));
|
|
216
|
+
},
|
|
217
|
+
resource: 'page',
|
|
218
|
+
title: 'Pages',
|
|
219
|
+
};
|
|
195
220
|
// ---- SEO --------------------------------------------------------------------------------
|
|
196
221
|
function canonicalRedirect(redirect) {
|
|
197
222
|
return omit(redirect, REDIRECT_VOLATILE_KEYS);
|
|
@@ -239,12 +264,16 @@ const seoProvider = {
|
|
|
239
264
|
},
|
|
240
265
|
async remote(wp, onWarn) {
|
|
241
266
|
const state = new Map();
|
|
242
|
-
|
|
267
|
+
// `revision` (#234) is dropped from both, same as optionsProvider.remote() does for
|
|
268
|
+
// RemoteOption.revision below: it's bookkeeping for the conditional-write precondition, not
|
|
269
|
+
// configuration, and comparing it here would flag drift whenever the mere act of reading
|
|
270
|
+
// changed nothing meaningful.
|
|
271
|
+
const { settings } = await wp.get(SEO_SETTINGS_ENDPOINT);
|
|
243
272
|
state.set('settings', settings);
|
|
244
273
|
for (const postType of DEFAULT_POST_TYPES) {
|
|
245
274
|
const posts = await wp.get(seoPostMetaEndpoint(postType));
|
|
246
|
-
for (const
|
|
247
|
-
state.set(`post-meta/${postType}/${
|
|
275
|
+
for (const { meta, slug, title } of posts)
|
|
276
|
+
state.set(`post-meta/${postType}/${slug}`, { meta, slug, title });
|
|
248
277
|
}
|
|
249
278
|
// Redirects are a RankMath-only feature; on Yoast the endpoint 404s, same graceful skip
|
|
250
279
|
// as `seo pull`.
|
|
@@ -264,6 +293,55 @@ const seoProvider = {
|
|
|
264
293
|
resource: 'seo',
|
|
265
294
|
title: 'SEO',
|
|
266
295
|
};
|
|
296
|
+
// ---- Menus --------------------------------------------------------------------------------
|
|
297
|
+
// Every item's post_type/taxonomy target is already resolved to `object`/`objectSlug` by the
|
|
298
|
+
// REST layer (MenuService), never a raw `_menu_item_object_id`, so the exported tree is already
|
|
299
|
+
// portable and compared as-is. `warnings` is diagnostic (a dangling item, an off-environment
|
|
300
|
+
// custom URL, ...), not tracked configuration, so it's dropped before comparing, the same
|
|
301
|
+
// "volatile key" treatment ACF/forms/redirects give their own server-computed fields. `revision`
|
|
302
|
+
// (#234) is bookkeeping for menu push's conditional-write precondition, not tracked configuration
|
|
303
|
+
// either: it's a pure hash of name + items, so it never disagrees when they don't, but a local
|
|
304
|
+
// file pulled before this field existed simply wouldn't have it, which must never show up as
|
|
305
|
+
// drift on its own.
|
|
306
|
+
const MENU_VOLATILE_KEYS = ['revision', 'warnings'];
|
|
307
|
+
// The reserved local filename for menu locations, see commands/menu/pull.ts.
|
|
308
|
+
const MENU_LOCATIONS_FILE = 'menu-locations';
|
|
309
|
+
function canonicalMenu(menu) {
|
|
310
|
+
return omit(menu, MENU_VOLATILE_KEYS);
|
|
311
|
+
}
|
|
312
|
+
const menuProvider = {
|
|
313
|
+
dirKind: 'menu',
|
|
314
|
+
async local(dir, onWarn) {
|
|
315
|
+
const state = new Map();
|
|
316
|
+
const entries = await loadFiles(dir, {
|
|
317
|
+
extension: '.json',
|
|
318
|
+
onSkip: onWarn,
|
|
319
|
+
parse: (raw, filePath) => ({ file: basename(filePath, '.json'), value: readJson(raw) }),
|
|
320
|
+
});
|
|
321
|
+
for (const { file, value } of entries) {
|
|
322
|
+
if (file === MENU_LOCATIONS_FILE) {
|
|
323
|
+
state.set(MENU_LOCATIONS_FILE, value);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const slug = getMenuSlug(value) ?? `local:${file}`;
|
|
327
|
+
state.set(`menu/${slug}`, canonicalMenu(value));
|
|
328
|
+
}
|
|
329
|
+
return state;
|
|
330
|
+
},
|
|
331
|
+
async remote(wp) {
|
|
332
|
+
const state = new Map();
|
|
333
|
+
const menus = await wp.get(MENU_ENDPOINT);
|
|
334
|
+
for (const menu of menus) {
|
|
335
|
+
const slug = getMenuSlug(menu);
|
|
336
|
+
if (slug !== null)
|
|
337
|
+
state.set(`menu/${slug}`, canonicalMenu(menu));
|
|
338
|
+
}
|
|
339
|
+
state.set(MENU_LOCATIONS_FILE, await wp.get(MENU_LOCATIONS_ENDPOINT));
|
|
340
|
+
return state;
|
|
341
|
+
},
|
|
342
|
+
resource: 'menu',
|
|
343
|
+
title: 'Menus',
|
|
344
|
+
};
|
|
267
345
|
// ---- Options ------------------------------------------------------------------------------
|
|
268
346
|
// Unlike every provider above, remote() has no "everything" endpoint to read: GET /options only
|
|
269
347
|
// lists names, never values (by design, see OptionsController), so both sides compare exactly
|
|
@@ -310,14 +388,49 @@ const optionsProvider = {
|
|
|
310
388
|
resource: 'option',
|
|
311
389
|
title: 'Options',
|
|
312
390
|
};
|
|
391
|
+
// ---- Theme styles (Global Styles) ----------------------------------------------------------
|
|
392
|
+
// Local files are named `<stylesheet>-global-styles.json`, keyed by stylesheet so a directory
|
|
393
|
+
// that has accumulated files from more than one previously-active theme still compares each one
|
|
394
|
+
// individually rather than colliding on a single key.
|
|
395
|
+
const themeStylesProvider = {
|
|
396
|
+
dirKind: 'themeStyles',
|
|
397
|
+
async local(dir, onWarn) {
|
|
398
|
+
const entries = await loadFiles(dir, {
|
|
399
|
+
extension: '.json',
|
|
400
|
+
onSkip: onWarn,
|
|
401
|
+
parse(raw, filePath) {
|
|
402
|
+
const name = basename(filePath);
|
|
403
|
+
const stylesheet = name.endsWith(THEME_STYLES_FILE_SUFFIX) ? name.slice(0, -THEME_STYLES_FILE_SUFFIX.length) : basename(filePath, '.json');
|
|
404
|
+
return { stylesheet, value: readJson(raw) };
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
const state = new Map();
|
|
408
|
+
for (const { stylesheet, value } of entries)
|
|
409
|
+
state.set(stylesheet, canonicalGlobalStyles(value));
|
|
410
|
+
return state;
|
|
411
|
+
},
|
|
412
|
+
// Only the active theme's Global Styles are compared (see theme-styles-format.ts): a classic
|
|
413
|
+
// active theme has no Styles screen at all, surfaced here as a normal provider error, the same
|
|
414
|
+
// "inconclusive for this resource" treatment DiffCommand gives any other fetch failure.
|
|
415
|
+
async remote(wp) {
|
|
416
|
+
const { id, stylesheet } = await getActiveThemeGlobalStyles(wp);
|
|
417
|
+
const item = await wp.get(globalStylesEndpoint(id));
|
|
418
|
+
return new Map([[stylesheet, canonicalGlobalStyles(item)]]);
|
|
419
|
+
},
|
|
420
|
+
resource: 'theme-styles',
|
|
421
|
+
title: 'Theme styles',
|
|
422
|
+
};
|
|
313
423
|
export const RESOURCE_STATE_PROVIDERS = [
|
|
314
424
|
snippetProvider,
|
|
315
425
|
formProvider,
|
|
316
426
|
acfProvider,
|
|
317
427
|
apiProvider,
|
|
318
428
|
hookProvider,
|
|
429
|
+
pageProvider,
|
|
319
430
|
seoProvider,
|
|
431
|
+
menuProvider,
|
|
320
432
|
optionsProvider,
|
|
433
|
+
themeStylesProvider,
|
|
321
434
|
];
|
|
322
435
|
export function getResourceStateProvider(resource) {
|
|
323
436
|
const provider = RESOURCE_STATE_PROVIDERS.find((candidate) => candidate.resource === resource);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { LoopressCommand } from './base.js';
|
|
2
|
+
import { type SnapshotSummary } from './snapshot-store.js';
|
|
3
|
+
export type RollbackResult = {
|
|
4
|
+
drift?: {
|
|
5
|
+
added: string[];
|
|
6
|
+
changed: string[];
|
|
7
|
+
removed: string[];
|
|
8
|
+
};
|
|
9
|
+
id?: string;
|
|
10
|
+
restored?: {
|
|
11
|
+
added: string[];
|
|
12
|
+
changed: string[];
|
|
13
|
+
removed: string[];
|
|
14
|
+
};
|
|
15
|
+
snapshots?: SnapshotSummary[];
|
|
16
|
+
status: 'dry-run' | 'listed' | 'no-op' | 'success';
|
|
17
|
+
};
|
|
18
|
+
export declare function resourceRollbackCommand(resource: string, options: {
|
|
19
|
+
description: string;
|
|
20
|
+
pathNoun: string;
|
|
21
|
+
}): typeof LoopressCommand;
|