@loopress/cli 0.24.0 → 0.25.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 +716 -98
- 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/rm.d.ts +2 -0
- package/dist/commands/api/rm.js +2 -0
- package/dist/commands/api/rollback.d.ts +2 -0
- package/dist/commands/api/rollback.js +5 -0
- package/dist/commands/composer/push.d.ts +3 -0
- package/dist/commands/composer/push.js +24 -7
- 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 +3 -0
- package/dist/commands/form/push.js +39 -6
- package/dist/commands/form/rollback.d.ts +2 -0
- package/dist/commands/form/rollback.js +5 -0
- package/dist/commands/hook/rm.d.ts +2 -0
- package/dist/commands/hook/rm.js +2 -0
- package/dist/commands/hook/rollback.d.ts +2 -0
- package/dist/commands/hook/rollback.js +5 -0
- package/dist/commands/init.js +1 -0
- package/dist/commands/login.js +3 -2
- 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/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 +9 -2
- package/dist/commands/seo/pull.js +6 -2
- package/dist/commands/seo/push.d.ts +3 -0
- package/dist/commands/seo/push.js +49 -7
- 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/hooks/finally.js +4 -1
- 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/find-orphaned-files.js +5 -47
- package/dist/lib/load-files.js +4 -42
- package/dist/lib/local-callback-server.d.ts +14 -1
- package/dist/lib/local-callback-server.js +40 -1
- package/dist/lib/php-files-command.d.ts +17 -3
- package/dist/lib/php-files-command.js +196 -37
- 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 +92 -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/sentry.d.ts +3 -0
- package/dist/lib/sentry.js +30 -0
- package/dist/lib/snapshot-store.d.ts +30 -0
- package/dist/lib/snapshot-store.js +133 -0
- package/dist/lib/validate-local.js +2 -1
- package/dist/lib/wp-authorize-flow.d.ts +4 -0
- package/dist/lib/wp-authorize-flow.js +10 -8
- package/dist/types/project-config.generated.d.ts +8 -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 +2 -1
- package/dist/utils/option-format.js +22 -4
- package/dist/utils/plugin-sync.d.ts +6 -0
- package/dist/utils/resource-dirs.d.ts +2 -0
- package/dist/utils/resource-dirs.js +4 -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 +1837 -386
- package/package.json +5 -3
- package/schemas/project-config.schema.json +10 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
3
|
import { createServer } from 'node:http';
|
|
3
4
|
import { openBrowser } from './open-browser.js';
|
|
4
5
|
async function readBody(req) {
|
|
@@ -10,12 +11,31 @@ async function readBody(req) {
|
|
|
10
11
|
function parseFormData(body) {
|
|
11
12
|
return Object.fromEntries(new URLSearchParams(body));
|
|
12
13
|
}
|
|
14
|
+
/** Constant-time compare that also tolerates a length mismatch without throwing. */
|
|
15
|
+
function safeEqual(a, b) {
|
|
16
|
+
const bufferA = Buffer.from(a);
|
|
17
|
+
const bufferB = Buffer.from(b);
|
|
18
|
+
return bufferA.length === bufferB.length && timingSafeEqual(bufferA, bufferB);
|
|
19
|
+
}
|
|
13
20
|
/**
|
|
14
21
|
* Sends the user to a URL in their browser and catches the resulting redirect on a short-lived
|
|
15
22
|
* local server; this factors out the server setup, timeout, and browser-opening boilerplate.
|
|
23
|
+
*
|
|
24
|
+
* The loopback server is unauthenticated, so any local process or web page open during the wait
|
|
25
|
+
* could otherwise POST forged credentials to it (RFC 8252 section 8.9). Two guards close that:
|
|
26
|
+
*
|
|
27
|
+
* - a 32-byte `state` generated here, threaded into the authorize URL by `buildUrl`, and required
|
|
28
|
+
* back (constant-time compare) on any request that carries credentials;
|
|
29
|
+
* - an `Origin` allowlist: a browser sends `Origin` on the relay's cross-site form POST, so a
|
|
30
|
+
* stray page's POST (carrying its own origin) is rejected. Top-level navigations send no
|
|
31
|
+
* `Origin` and are allowed, which is why `state` is the primary control.
|
|
32
|
+
*
|
|
33
|
+
* The first request that looks like a callback (valid or not) shuts the server down, so a
|
|
34
|
+
* failed guess gets no second try within the window.
|
|
16
35
|
*/
|
|
17
36
|
export async function waitForLocalCallback(options) {
|
|
18
37
|
const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
|
|
38
|
+
const state = randomBytes(32).toString('hex');
|
|
19
39
|
return new Promise((resolve, reject) => {
|
|
20
40
|
function finish(res, page) {
|
|
21
41
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
@@ -23,10 +43,29 @@ export async function waitForLocalCallback(options) {
|
|
|
23
43
|
clearTimeout(timer);
|
|
24
44
|
server.close();
|
|
25
45
|
}
|
|
46
|
+
function rejectRequest(res, message) {
|
|
47
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
48
|
+
res.end(message);
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
server.close();
|
|
51
|
+
reject(new Error(message));
|
|
52
|
+
}
|
|
26
53
|
async function handleIncoming(req, res) {
|
|
27
54
|
try {
|
|
28
55
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
29
56
|
const body = req.method === 'POST' ? parseFormData(await readBody(req)) : {};
|
|
57
|
+
const looksLikeCallback = url.searchParams.has('state') || Object.keys(body).length > 0;
|
|
58
|
+
if (looksLikeCallback) {
|
|
59
|
+
const { origin } = req.headers;
|
|
60
|
+
if (origin !== undefined && !options.allowedOrigins.includes(origin)) {
|
|
61
|
+
rejectRequest(res, 'Rejected a cross-origin request to the login callback server.');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!safeEqual(url.searchParams.get('state') ?? '', state)) {
|
|
65
|
+
rejectRequest(res, 'Rejected a login callback with a missing or invalid state value.');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
30
69
|
options.handleRequest(url, {
|
|
31
70
|
rejectWithPage(page, error) {
|
|
32
71
|
finish(res, page);
|
|
@@ -61,7 +100,7 @@ export async function waitForLocalCallback(options) {
|
|
|
61
100
|
}, timeoutMs);
|
|
62
101
|
server.listen(0, '127.0.0.1', () => {
|
|
63
102
|
const { port } = server.address();
|
|
64
|
-
const targetUrl = options.buildUrl(`http://localhost:${port}
|
|
103
|
+
const targetUrl = options.buildUrl(`http://localhost:${port}`, state);
|
|
65
104
|
options.log(options.openingMessage);
|
|
66
105
|
options.log(`\nIf it doesn't open automatically, visit:\n${targetUrl}\n`);
|
|
67
106
|
openBrowser(targetUrl);
|
|
@@ -22,12 +22,17 @@ export type PhpFilesResource = {
|
|
|
22
22
|
pathNoun: string;
|
|
23
23
|
pullDescription: string;
|
|
24
24
|
pushDescription: string;
|
|
25
|
+
rmDescription: string;
|
|
25
26
|
};
|
|
26
|
-
type
|
|
27
|
-
content
|
|
27
|
+
type RemotePhpFile = {
|
|
28
|
+
content?: string;
|
|
29
|
+
error?: string;
|
|
28
30
|
filename: string;
|
|
31
|
+
public?: boolean;
|
|
32
|
+
revision?: string;
|
|
29
33
|
};
|
|
30
34
|
type PushResult = {
|
|
35
|
+
pruned: string[];
|
|
31
36
|
pushed: string[];
|
|
32
37
|
status: 'dry-run' | 'success';
|
|
33
38
|
};
|
|
@@ -36,6 +41,11 @@ type PullResult = {
|
|
|
36
41
|
pulled: string[];
|
|
37
42
|
status: 'dry-run' | 'success';
|
|
38
43
|
};
|
|
44
|
+
type RmResult = {
|
|
45
|
+
filename: string;
|
|
46
|
+
removed: boolean;
|
|
47
|
+
status: 'aborted' | 'dry-run' | 'success';
|
|
48
|
+
};
|
|
39
49
|
export type CommandClass<TInstance> = new (argv: string[], config: Config) => TInstance;
|
|
40
50
|
export type PushFilesCommand = Omit<PushCommand, 'run'> & {
|
|
41
51
|
run(): Promise<PushResult>;
|
|
@@ -44,11 +54,15 @@ export type PullFilesCommand = Omit<LoopressCommand, 'run'> & {
|
|
|
44
54
|
run(): Promise<PullResult>;
|
|
45
55
|
};
|
|
46
56
|
export type ListFilesCommand = Omit<LoopressCommand, 'run'> & {
|
|
47
|
-
run(): Promise<
|
|
57
|
+
run(): Promise<RemotePhpFile[]>;
|
|
58
|
+
};
|
|
59
|
+
export type RmFilesCommand = Omit<LoopressCommand, 'run'> & {
|
|
60
|
+
run(): Promise<RmResult>;
|
|
48
61
|
};
|
|
49
62
|
export declare function resourcePushCommand(spec: PhpFilesResource): CommandClass<PushFilesCommand>;
|
|
50
63
|
export declare function resourcePullCommand(spec: PhpFilesResource): CommandClass<PullFilesCommand>;
|
|
51
64
|
export declare function resourceListCommand(spec: PhpFilesResource): CommandClass<ListFilesCommand>;
|
|
65
|
+
export declare function resourceRmCommand(spec: PhpFilesResource): CommandClass<RmFilesCommand>;
|
|
52
66
|
export declare const API_FILES_RESOURCE: PhpFilesResource;
|
|
53
67
|
export declare const HOOK_FILES_RESOURCE: PhpFilesResource;
|
|
54
68
|
export {};
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
2
|
+
import { Args, Flags } from '@oclif/core';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
2
4
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
5
|
import { dirname, join, relative, sep } from 'node:path';
|
|
4
6
|
import { authManager } from '../config/auth.manager.js';
|
|
@@ -7,13 +9,29 @@ import { resolveResourceDir, RESOURCE_DIR_DEFAULTS } from '../utils/resource-dir
|
|
|
7
9
|
import { ApiClient } from './api-client.js';
|
|
8
10
|
import { LoopressCommand } from './base.js';
|
|
9
11
|
import { basenameKey, findOrphanedFiles } from './find-orphaned-files.js';
|
|
12
|
+
import { isInteractive } from './interactive.js';
|
|
10
13
|
import { loadFiles as loadDirectoryFiles } from './load-files.js';
|
|
11
14
|
import { PushCommand } from './push-command.js';
|
|
15
|
+
import { getResourceStateProvider } from './resource-state.js';
|
|
16
|
+
import { isApplicative404 } from './wp-client.js';
|
|
12
17
|
// Mirrors wordpress-plugin FileWriter::DECLARE_PATTERN / withGuard(): the server rejects both
|
|
13
18
|
// an absent declare(strict_types=1); and one that appears more than once (it needs a single
|
|
14
19
|
// unambiguous insertion point for the ABSPATH guard). Matching "exactly once" here, not just
|
|
15
20
|
// presence, so a file that would still fail server-side doesn't falsely pass this check.
|
|
16
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
|
+
}
|
|
17
35
|
function pathArg(pathNoun) {
|
|
18
36
|
return { path: Args.string({ description: `Path to ${pathNoun} (overrides project config)` }) };
|
|
19
37
|
}
|
|
@@ -35,35 +53,130 @@ export function resourcePushCommand(spec) {
|
|
|
35
53
|
static args = pathArg(spec.pathNoun);
|
|
36
54
|
static description = spec.pushDescription;
|
|
37
55
|
static enableJsonFlag = true;
|
|
38
|
-
static examples = [
|
|
39
|
-
|
|
56
|
+
static examples = [
|
|
57
|
+
`$ lps ${spec.cliName} push`,
|
|
58
|
+
`$ lps ${spec.cliName} push --path ./${RESOURCE_DIR_DEFAULTS[spec.dirKind]}`,
|
|
59
|
+
`$ lps ${spec.cliName} push --prune`,
|
|
60
|
+
];
|
|
61
|
+
static flags = {
|
|
62
|
+
...PushCommand.dryRunFlag,
|
|
63
|
+
...PushCommand.yesFlag,
|
|
64
|
+
prune: Flags.boolean({
|
|
65
|
+
default: false,
|
|
66
|
+
description: `Delete server-side ${spec.noun}s not present locally after pushing`,
|
|
67
|
+
}),
|
|
68
|
+
};
|
|
40
69
|
async run() {
|
|
41
|
-
const { args } = await this.parse(ResourcePush);
|
|
70
|
+
const { args, flags } = await this.parse(ResourcePush);
|
|
42
71
|
const { url } = this.siteConfig;
|
|
43
72
|
const path = resolveResourceDir(spec.dirKind, this.localConfig, args.path);
|
|
44
73
|
this.log(`Pushing ${spec.label} to ${url}`);
|
|
45
74
|
this.log(`${spec.pathLabel} path: ${path}`);
|
|
75
|
+
const provider = getResourceStateProvider(spec.cliName);
|
|
76
|
+
const beforeState = await this.captureBeforePushState(provider, path);
|
|
46
77
|
const files = await this.loadFiles(path);
|
|
47
78
|
this.log(`Found ${pluralize(files.length, spec.noun)} to push`);
|
|
48
|
-
|
|
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.
|
|
49
82
|
await this.runPushTasks(files, (file) => file.filename, async (file, task) => {
|
|
50
|
-
|
|
51
|
-
pushed.push(file.filename);
|
|
83
|
+
this.validateFileLocally(file, task);
|
|
52
84
|
});
|
|
53
85
|
if (this.failedCount > 0) {
|
|
54
86
|
this.error(`${pluralize(this.failedCount, spec.noun)} failed to push.`);
|
|
55
87
|
}
|
|
56
|
-
|
|
57
|
-
|
|
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' };
|
|
98
|
+
}
|
|
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);
|
|
58
103
|
await this.recordSuccess();
|
|
59
104
|
await spec.afterPush?.({ filenames: pushed, siteConfig: this.siteConfig });
|
|
60
105
|
this.log(`All ${spec.label} pushed.`);
|
|
61
|
-
return { pushed, status: 'success' };
|
|
106
|
+
return { pruned, pushed, status: 'success' };
|
|
62
107
|
}
|
|
63
108
|
async loadFiles(path) {
|
|
64
109
|
return loadPhpFiles(this, path);
|
|
65
110
|
}
|
|
66
|
-
|
|
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
|
+
}
|
|
150
|
+
// Mirror of `pull`'s local orphan cleanup, aimed at the server: any file on WordPress with
|
|
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) {
|
|
157
|
+
const remote = await this.wp.get(spec.endpoint);
|
|
158
|
+
const orphans = remote.map((file) => file.filename).filter((name) => !localFilenames.has(name));
|
|
159
|
+
if (orphans.length === 0)
|
|
160
|
+
return [];
|
|
161
|
+
const summary = `${pluralize(orphans.length, spec.noun)} on ${this.siteConfig.url} not present locally: ${orphans.join(', ')}`;
|
|
162
|
+
if (this.dryRun) {
|
|
163
|
+
this.log(`[dry-run] Would prune ${summary}`);
|
|
164
|
+
return orphans;
|
|
165
|
+
}
|
|
166
|
+
if (!this.yes) {
|
|
167
|
+
if (!isInteractive()) {
|
|
168
|
+
const count = pluralize(orphans.length, `server-side ${spec.noun}`);
|
|
169
|
+
this.error(`--prune would delete ${count} but stdin is not a TTY. Re-run with --yes to confirm, or without --prune.`);
|
|
170
|
+
}
|
|
171
|
+
const ok = await confirm({ default: false, message: `Prune ${summary}?` });
|
|
172
|
+
if (!ok) {
|
|
173
|
+
this.log('Kept the server-side files, nothing pruned.');
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return orphans;
|
|
178
|
+
}
|
|
179
|
+
validateFileLocally(file, task) {
|
|
67
180
|
if (!spec.filenamePattern.test(file.filename)) {
|
|
68
181
|
const message = `Invalid filename "${file.filename}": ${spec.invalidFilenameHint}`;
|
|
69
182
|
this.reportTaskFailure(message, new Error(message), task);
|
|
@@ -74,26 +187,8 @@ export function resourcePushCommand(spec) {
|
|
|
74
187
|
const message = `${file.filename}.php: "declare(strict_types=1);" ${reason}, it must appear exactly once as the first statement`;
|
|
75
188
|
this.reportTaskFailure(message, new Error(message), task);
|
|
76
189
|
}
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
task.output = `[dry-run] Would push: ${file.filename}`;
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
try {
|
|
83
|
-
const result = await this.wp.put(spec.endpoint, {
|
|
84
|
-
content: file.content,
|
|
85
|
-
filename: file.filename,
|
|
86
|
-
});
|
|
87
|
-
if (task) {
|
|
88
|
-
task.output =
|
|
89
|
-
result.syntax_check === 'skipped'
|
|
90
|
-
? `Pushed: ${file.filename} (syntax check skipped, unavailable on this host)`
|
|
91
|
-
: `Pushed: ${file.filename}`;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
catch (error) {
|
|
95
|
-
this.reportTaskFailure(`Failed to push ${file.filename}: ${error.message}`, error, task);
|
|
96
|
-
}
|
|
190
|
+
if (task)
|
|
191
|
+
task.output = `Validated: ${file.filename}`;
|
|
97
192
|
}
|
|
98
193
|
}
|
|
99
194
|
return ResourcePush;
|
|
@@ -112,19 +207,28 @@ export function resourcePullCommand(spec) {
|
|
|
112
207
|
this.log(`Pulling ${spec.label} from ${url}`);
|
|
113
208
|
this.log(`${spec.pathLabel} path: ${path}`);
|
|
114
209
|
const files = await this.wp.get(spec.endpoint);
|
|
210
|
+
// A file the server would not read back (over the size limit, see LP-SEC-02) arrives
|
|
211
|
+
// with no content: report it and leave any local copy untouched rather than overwriting
|
|
212
|
+
// it with nothing.
|
|
213
|
+
const unreadable = files.filter((file) => typeof file.content !== 'string');
|
|
214
|
+
for (const file of unreadable) {
|
|
215
|
+
this.warn(`Skipped ${file.filename}: ${file.error ?? 'the server did not return its content'}`);
|
|
216
|
+
}
|
|
217
|
+
const readable = files.filter((file) => typeof file.content === 'string');
|
|
115
218
|
// A `<filename>.php` no longer present remotely was deleted on WordPress (push stays
|
|
116
|
-
// additive-only, but pull already cleans up locally, same as `snippet pull`).
|
|
219
|
+
// additive-only, but pull already cleans up locally, same as `snippet pull`). The
|
|
220
|
+
// unreadable ones still exist remotely, so keep them out of the orphan set.
|
|
117
221
|
const orphans = await findOrphanedFiles(path, new Set(files.map((file) => file.filename)), {
|
|
118
222
|
extensions: ['.php'],
|
|
119
223
|
key: basenameKey,
|
|
120
224
|
recursive: true,
|
|
121
225
|
});
|
|
122
|
-
const pulled =
|
|
123
|
-
await this.pullDirectory(path,
|
|
226
|
+
const pulled = readable.map((file) => file.filename);
|
|
227
|
+
await this.pullDirectory(path, readable, orphans, {
|
|
124
228
|
alwaysCreateDir: true,
|
|
125
|
-
dryRunMessage: `Would pull ${pluralize(
|
|
229
|
+
dryRunMessage: `Would pull ${pluralize(readable.length, spec.noun)} to ${path}`,
|
|
126
230
|
orphanReason: spec.orphanReason,
|
|
127
|
-
pulledMessage: `Pulled ${pluralize(
|
|
231
|
+
pulledMessage: `Pulled ${pluralize(readable.length, spec.noun)} to ${path}`,
|
|
128
232
|
title: (file) => file.filename,
|
|
129
233
|
async write(file, writeDir) {
|
|
130
234
|
const filePath = join(writeDir, `${file.filename}.php`);
|
|
@@ -156,13 +260,66 @@ export function resourceListCommand(spec) {
|
|
|
156
260
|
this.log(`Found ${pluralize(files.length, spec.noun)}:`);
|
|
157
261
|
this.log('');
|
|
158
262
|
for (const file of files) {
|
|
159
|
-
|
|
263
|
+
const badges = [file.public ? '[PUBLIC]' : '', file.error ? `(${file.error})` : ''].filter(Boolean).join(' ');
|
|
264
|
+
this.log(badges ? ` ${file.filename} ${badges}` : ` ${file.filename}`);
|
|
265
|
+
}
|
|
266
|
+
if (files.some((file) => file.public)) {
|
|
267
|
+
this.log('');
|
|
268
|
+
this.warn('[PUBLIC] routes run for anyone, with no authentication. Review them.');
|
|
160
269
|
}
|
|
161
270
|
return files;
|
|
162
271
|
}
|
|
163
272
|
}
|
|
164
273
|
return ResourceList;
|
|
165
274
|
}
|
|
275
|
+
export function resourceRmCommand(spec) {
|
|
276
|
+
class ResourceRm extends LoopressCommand {
|
|
277
|
+
static args = {
|
|
278
|
+
filename: Args.string({
|
|
279
|
+
description: `The ${spec.noun} to remove, its slug without the .php extension (e.g. "hello" or "invoice-pdf/[order_id]")`,
|
|
280
|
+
required: true,
|
|
281
|
+
}),
|
|
282
|
+
};
|
|
283
|
+
static description = spec.rmDescription;
|
|
284
|
+
static enableJsonFlag = true;
|
|
285
|
+
static examples = [`$ lps ${spec.cliName} rm hello`, `$ lps ${spec.cliName} rm hello --yes`];
|
|
286
|
+
static flags = { ...LoopressCommand.dryRunFlag, ...LoopressCommand.yesFlag };
|
|
287
|
+
async run() {
|
|
288
|
+
const { args } = await this.parse(ResourceRm);
|
|
289
|
+
const { filename } = args;
|
|
290
|
+
const { url } = this.siteConfig;
|
|
291
|
+
if (!spec.filenamePattern.test(filename)) {
|
|
292
|
+
this.error(`Invalid filename "${filename}": ${spec.invalidFilenameHint}`);
|
|
293
|
+
}
|
|
294
|
+
if (this.dryRun) {
|
|
295
|
+
this.log(`[dry-run] Would remove ${filename} from ${url}`);
|
|
296
|
+
return { filename, removed: false, status: 'dry-run' };
|
|
297
|
+
}
|
|
298
|
+
if (!this.yes) {
|
|
299
|
+
if (!isInteractive()) {
|
|
300
|
+
this.error(`Removing ${filename} needs confirmation. Re-run with --yes.`);
|
|
301
|
+
}
|
|
302
|
+
const ok = await confirm({ default: false, message: `Remove ${filename} from ${url}?` });
|
|
303
|
+
if (!ok) {
|
|
304
|
+
this.log('Aborted.');
|
|
305
|
+
return { filename, removed: false, status: 'aborted' };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
await this.wp.delete(`${spec.endpoint}?filename=${encodeURIComponent(filename)}`);
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
if (isApplicative404(error, 'File not found')) {
|
|
313
|
+
this.error(`${filename} is not on ${url}.`);
|
|
314
|
+
}
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
this.log(`Removed ${filename} from ${url}`);
|
|
318
|
+
return { filename, removed: true, status: 'success' };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return ResourceRm;
|
|
322
|
+
}
|
|
166
323
|
export const API_FILES_RESOURCE = {
|
|
167
324
|
// Best-effort report of the current route list to the Loopress cloud, purely for console
|
|
168
325
|
// visibility (US-18): the routes are already live on WordPress by this point, so this can
|
|
@@ -195,6 +352,7 @@ export const API_FILES_RESOURCE = {
|
|
|
195
352
|
pathNoun: 'api directory',
|
|
196
353
|
pullDescription: 'Pull custom API route files from WordPress',
|
|
197
354
|
pushDescription: 'Push custom API route files to WordPress',
|
|
355
|
+
rmDescription: 'Remove a custom API route file from WordPress',
|
|
198
356
|
};
|
|
199
357
|
export const HOOK_FILES_RESOURCE = {
|
|
200
358
|
cliName: 'hook',
|
|
@@ -213,4 +371,5 @@ export const HOOK_FILES_RESOURCE = {
|
|
|
213
371
|
pathNoun: 'hooks directory',
|
|
214
372
|
pullDescription: 'Pull hook files (actions, filters, cron) from WordPress',
|
|
215
373
|
pushDescription: 'Push hook files (actions, filters, cron) to WordPress',
|
|
374
|
+
rmDescription: 'Remove a hook file (action, filter, cron) from WordPress',
|
|
216
375
|
};
|
|
@@ -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
|
+
}
|