@loopress/cli 0.30.0 → 0.31.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 +196 -143
- package/dist/commands/acf/push.js +2 -1
- package/dist/commands/composer/push.d.ts +1 -0
- package/dist/commands/composer/push.js +13 -0
- package/dist/commands/content/copy.d.ts +28 -0
- package/dist/commands/content/copy.js +97 -0
- package/dist/commands/doctor.d.ts +1 -0
- package/dist/commands/doctor.js +28 -1
- package/dist/commands/form/push.js +4 -1
- package/dist/commands/option/push.js +4 -2
- package/dist/commands/page/push.js +3 -2
- package/dist/commands/plugin/audit.js +41 -24
- package/dist/commands/promote.d.ts +1 -0
- package/dist/commands/promote.js +7 -3
- package/dist/commands/pull.d.ts +2 -1
- package/dist/commands/pull.js +24 -4
- package/dist/commands/push.d.ts +4 -2
- package/dist/commands/push.js +42 -17
- package/dist/commands/snippet/push.d.ts +1 -0
- package/dist/commands/snippet/push.js +24 -3
- package/dist/commands/theme/version/pull.js +6 -1
- package/dist/lib/base.d.ts +1 -0
- package/dist/lib/base.js +3 -0
- package/dist/lib/diff-command.d.ts +2 -0
- package/dist/lib/diff-command.js +24 -3
- package/dist/lib/load-snippets.js +4 -0
- package/dist/lib/php-files-command.d.ts +8 -0
- package/dist/lib/php-files-command.js +5 -0
- package/dist/lib/push-command.d.ts +4 -2
- package/dist/lib/push-command.js +65 -5
- package/dist/lib/resource-state.js +5 -5
- package/dist/lib/rollback-command.js +9 -2
- package/dist/lib/validate-local.js +9 -3
- package/dist/lib/wp-client.js +14 -1
- package/dist/types/snippet.d.ts +1 -0
- package/oclif.manifest.json +895 -693
- package/package.json +5 -2
package/dist/commands/push.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
4
|
import { LoopressCommand } from '../lib/base.js';
|
|
5
5
|
import { guardProductionPush } from '../lib/guard-production-push.js';
|
|
6
6
|
import { stdoutToStderr } from '../lib/json-delegation.js';
|
|
7
|
+
import { PushCommand } from '../lib/push-command.js';
|
|
7
8
|
import { isLocalEnvironment } from '../lib/wp-client.js';
|
|
8
9
|
import { wpackagistRequire } from '../utils/composer.js';
|
|
9
|
-
import {
|
|
10
|
+
import { resolveResourceDir } from '../utils/resource-dirs.js';
|
|
10
11
|
// Dependency-ish order: code first (plugins, composer), then the ACF definitions content
|
|
11
12
|
// relies on, then content itself.
|
|
12
13
|
const PUSH_TARGETS = [
|
|
@@ -37,18 +38,19 @@ export default class Push extends LoopressCommand {
|
|
|
37
38
|
static flags = {
|
|
38
39
|
...LoopressCommand.dryRunFlag,
|
|
39
40
|
...LoopressCommand.yesFlag,
|
|
41
|
+
overwrite: PushCommand.baseFlags.overwrite,
|
|
40
42
|
};
|
|
41
|
-
failedCount = 0;
|
|
42
43
|
async run() {
|
|
43
44
|
await this.guardProductionPush();
|
|
44
45
|
this.warnRemovedSeoModule();
|
|
45
46
|
const argv = this.buildArgv();
|
|
46
47
|
const results = [];
|
|
47
|
-
const
|
|
48
|
+
const skips = await this.skippedTargets();
|
|
48
49
|
for (const target of PUSH_TARGETS) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
const skip = skips.get(target.commandId);
|
|
51
|
+
if (skip) {
|
|
52
|
+
this.log(`\n→ ${target.label}: ${skip.note}`);
|
|
53
|
+
results.push({ label: target.label, status: skip.status });
|
|
52
54
|
continue;
|
|
53
55
|
}
|
|
54
56
|
this.log(`\n→ Pushing ${target.label}...`);
|
|
@@ -58,20 +60,16 @@ export default class Push extends LoopressCommand {
|
|
|
58
60
|
results.push({ label: target.label, status: 'pushed' });
|
|
59
61
|
}
|
|
60
62
|
catch (error) {
|
|
61
|
-
this.failedCount++;
|
|
62
63
|
const { message } = error;
|
|
63
64
|
this.log(`✗ ${target.label} failed: ${message}`);
|
|
64
65
|
results.push({ error: message, label: target.label, status: 'failed' });
|
|
66
|
+
// Stop here: later targets depend on earlier ones (hooks use what composer installs,
|
|
67
|
+
// pages need their templates), so pushing them anyway can ship a silently broken site.
|
|
68
|
+
const skipped = PUSH_TARGETS.slice(PUSH_TARGETS.indexOf(target) + 1).map((next) => next.label);
|
|
69
|
+
const skippedNote = skipped.length > 0 ? ` Not attempted: ${skipped.join(', ')}.` : '';
|
|
70
|
+
this.error(`${target.label} failed to push: ${message}.${skippedNote}`);
|
|
65
71
|
}
|
|
66
72
|
}
|
|
67
|
-
if (this.failedCount > 0) {
|
|
68
|
-
// Each failure's own reason, not just the count: under --json the per-resource lines above
|
|
69
|
-
// never reach stdout, and this message is all an MCP caller or a script gets to see.
|
|
70
|
-
const failures = results
|
|
71
|
-
.filter((result) => result.status === 'failed')
|
|
72
|
-
.map((result) => `${result.label}: ${result.error}`);
|
|
73
|
-
this.error(`${pluralize(this.failedCount, 'resource')} failed to push. ${failures.join('; ')}`);
|
|
74
|
-
}
|
|
75
73
|
this.log(this.dryRun ? '\nEvery resource would push.' : '\nAll resources pushed.');
|
|
76
74
|
return { results };
|
|
77
75
|
}
|
|
@@ -82,12 +80,15 @@ export default class Push extends LoopressCommand {
|
|
|
82
80
|
const argv = ['--env', this.siteConfig.name, '--yes'];
|
|
83
81
|
if (this.dryRun)
|
|
84
82
|
argv.push('--dry-run');
|
|
83
|
+
// Not implied by --yes: overwriting changes made outside Loopress is its own decision.
|
|
84
|
+
if (this.overwrite)
|
|
85
|
+
argv.push('--overwrite');
|
|
85
86
|
return argv;
|
|
86
87
|
}
|
|
87
88
|
// A composer.json that declares plugins is pushed whole by `plugin:push` (with its activation
|
|
88
89
|
// safety), so `composer:push` would only run Composer on the server a second time.
|
|
89
90
|
async composerDeclaresPlugins() {
|
|
90
|
-
const path =
|
|
91
|
+
const path = this.composerJsonPath();
|
|
91
92
|
if (!existsSync(path))
|
|
92
93
|
return false;
|
|
93
94
|
try {
|
|
@@ -99,6 +100,9 @@ export default class Push extends LoopressCommand {
|
|
|
99
100
|
return false;
|
|
100
101
|
}
|
|
101
102
|
}
|
|
103
|
+
composerJsonPath() {
|
|
104
|
+
return join(process.cwd(), this.rootDir, 'composer.json');
|
|
105
|
+
}
|
|
102
106
|
// Guards once here (shared with PushCommand, lib/push-command.ts) rather than once per
|
|
103
107
|
// delegated command.
|
|
104
108
|
async guardProductionPush() {
|
|
@@ -109,4 +113,25 @@ export default class Push extends LoopressCommand {
|
|
|
109
113
|
yes: this.yes,
|
|
110
114
|
});
|
|
111
115
|
}
|
|
116
|
+
// Unconfigured is not a failure: a project that doesn't manage plugins or composer through
|
|
117
|
+
// Loopress must not stop the run before anything else gets pushed.
|
|
118
|
+
async skippedTargets() {
|
|
119
|
+
const skips = new Map();
|
|
120
|
+
const notConfigured = { note: 'not configured, skipped', status: 'skipped' };
|
|
121
|
+
const hasComposerJson = existsSync(this.composerJsonPath());
|
|
122
|
+
const composerPushedByPlugins = await this.composerDeclaresPlugins();
|
|
123
|
+
if (composerPushedByPlugins)
|
|
124
|
+
skips.set('composer:push', { note: 'already pushed whole by plugins', status: 'pushed' });
|
|
125
|
+
else if (!hasComposerJson)
|
|
126
|
+
skips.set('composer:push', notConfigured);
|
|
127
|
+
// With a composer.json, plugins come from it only; otherwise from loopress.json.
|
|
128
|
+
const pluginsConfigured = hasComposerJson ? composerPushedByPlugins : Object.keys(this.localConfig.plugins ?? {}).length > 0;
|
|
129
|
+
if (!pluginsConfigured)
|
|
130
|
+
skips.set('plugin:push', notConfigured);
|
|
131
|
+
// The only push that fails on a missing directory (the others find 0 files), so a project
|
|
132
|
+
// that never used snippets would otherwise end every `lps push` on an ENOENT.
|
|
133
|
+
if (!existsSync(resolve(resolveResourceDir('snippets', this.localConfig))))
|
|
134
|
+
skips.set('snippet:push', notConfigured);
|
|
135
|
+
return skips;
|
|
136
|
+
}
|
|
112
137
|
}
|
|
@@ -28,7 +28,7 @@ export default class Push extends PushCommand {
|
|
|
28
28
|
this.log(`Snippets path: ${path}`);
|
|
29
29
|
const provider = getResourceStateProvider('snippet');
|
|
30
30
|
const beforeState = await this.captureBeforePushState(provider, path);
|
|
31
|
-
const snippets = await this.loadSnippets(path);
|
|
31
|
+
const snippets = this.withoutDuplicateIds(await this.loadSnippets(path));
|
|
32
32
|
this.log(`Found ${pluralize(snippets.length, 'snippet')} to push`);
|
|
33
33
|
const pushed = [];
|
|
34
34
|
await this.runPushTasks(snippets, (snippet) => snippet.name, async (snippet, task) => {
|
|
@@ -148,8 +148,11 @@ export default class Push extends PushCommand {
|
|
|
148
148
|
toPayload(snippet) {
|
|
149
149
|
return {
|
|
150
150
|
active: snippet.active,
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
// Only PHP files carry an editor-only `<?php` tag: an HTML snippet starting with an inline
|
|
152
|
+
// `<?php ... ?>` block is its real content and must reach WordPress untouched.
|
|
153
|
+
code: snippet.type === 'php' ? stripPhpOpeningTag(snippet.code) : snippet.code,
|
|
154
|
+
// The sidecar's description wins, so a pull then push never erases one written in wp-admin.
|
|
155
|
+
description: snippet.description ?? `Imported from ${snippet.path}`,
|
|
153
156
|
insertMethod: snippet.insertMethod,
|
|
154
157
|
location: snippet.location,
|
|
155
158
|
name: snippet.name,
|
|
@@ -159,4 +162,22 @@ export default class Push extends PushCommand {
|
|
|
159
162
|
type: snippet.type,
|
|
160
163
|
};
|
|
161
164
|
}
|
|
165
|
+
// Two files claiming the same id would each PUT over that id, the last one silently replacing
|
|
166
|
+
// the other on the site (seen after switching snippet provider, whose ids restart). Neither is
|
|
167
|
+
// pushed, and the push fails, until the files are fixed by hand.
|
|
168
|
+
withoutDuplicateIds(snippets) {
|
|
169
|
+
const pathsById = new Map();
|
|
170
|
+
for (const { id, path } of snippets) {
|
|
171
|
+
if (id !== undefined)
|
|
172
|
+
pathsById.set(id, [...(pathsById.get(id) ?? []), path]);
|
|
173
|
+
}
|
|
174
|
+
return snippets.filter((snippet) => {
|
|
175
|
+
const paths = snippet.id === undefined ? [] : pathsById.get(snippet.id);
|
|
176
|
+
if (paths.length < 2)
|
|
177
|
+
return true;
|
|
178
|
+
this.failedCount++;
|
|
179
|
+
this.warn(`Skipping "${snippet.path}": id ${snippet.id} is also used by ${paths.filter((p) => p !== snippet.path).join(', ')}.`);
|
|
180
|
+
return false;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
162
183
|
}
|
|
@@ -16,8 +16,13 @@ export default class Pull extends LoopressCommand {
|
|
|
16
16
|
const { url } = this.siteConfig;
|
|
17
17
|
this.log(`Pulling themes from ${url}`);
|
|
18
18
|
const raw = await this.wp.get('wp/v2/themes');
|
|
19
|
+
// A theme with no Version header can't be pinned, and `theme version push` would then fail on
|
|
20
|
+
// it: the Loopress child theme (`<parent>-loopress`, written by `theme template push`) is one.
|
|
19
21
|
const installed = parseInstalledThemes(raw);
|
|
20
|
-
const
|
|
22
|
+
const unversioned = installed.filter((t) => t.version === '').map((t) => t.slug);
|
|
23
|
+
const incoming = Object.fromEntries(installed.filter((t) => t.version !== '').map((t) => [t.slug, t.version]));
|
|
24
|
+
if (unversioned.length > 0)
|
|
25
|
+
this.log(` - Skipped: ${unversioned.join(', ')} (no version to pin)`);
|
|
21
26
|
// A composer.json is authoritative over loopress.json: pin the live versions there instead.
|
|
22
27
|
const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
|
|
23
28
|
if (existsSync(composerJsonPath)) {
|
package/dist/lib/base.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export declare abstract class LoopressCommand extends Command {
|
|
|
14
14
|
};
|
|
15
15
|
protected dryRun: boolean;
|
|
16
16
|
protected localConfig: LoopressLocalConfig;
|
|
17
|
+
protected overwrite: boolean;
|
|
17
18
|
protected productionConfirmed: boolean;
|
|
18
19
|
protected projectId: string;
|
|
19
20
|
protected siteConfig: EnvironmentConfig;
|
package/dist/lib/base.js
CHANGED
|
@@ -28,6 +28,8 @@ export class LoopressCommand extends Command {
|
|
|
28
28
|
};
|
|
29
29
|
dryRun = false;
|
|
30
30
|
localConfig = {};
|
|
31
|
+
// Set by PushCommand's --overwrite, see PushCommand.guardOutsideEdits.
|
|
32
|
+
overwrite = false;
|
|
31
33
|
// Set by PushCommand's hidden --production-confirmed, see there.
|
|
32
34
|
productionConfirmed = false;
|
|
33
35
|
projectId;
|
|
@@ -58,6 +60,7 @@ export class LoopressCommand extends Command {
|
|
|
58
60
|
}));
|
|
59
61
|
this.dryRun = Boolean(flags['dry-run']);
|
|
60
62
|
this.yes = Boolean(flags.yes);
|
|
63
|
+
this.overwrite = Boolean(flags.overwrite);
|
|
61
64
|
this.productionConfirmed = Boolean(flags['production-confirmed']);
|
|
62
65
|
this.localConfig = await readLocalConfig();
|
|
63
66
|
const resolved = this.resolveEnvironment(flags.env);
|
|
@@ -23,6 +23,7 @@ export type DiffTarget = {
|
|
|
23
23
|
type DiffSides = {
|
|
24
24
|
againstWp: undefined | WpClient;
|
|
25
25
|
leftLabel: string;
|
|
26
|
+
rightHeading: string;
|
|
26
27
|
rightLabel: string;
|
|
27
28
|
warn: (message: string) => void;
|
|
28
29
|
};
|
|
@@ -37,6 +38,7 @@ export declare abstract class DiffCommand extends LoopressCommand {
|
|
|
37
38
|
abstract run(): Promise<DiffJson>;
|
|
38
39
|
private computeResource;
|
|
39
40
|
private out;
|
|
41
|
+
private renderFailure;
|
|
40
42
|
private renderSection;
|
|
41
43
|
private wpForEnvironment;
|
|
42
44
|
}
|
package/dist/lib/diff-command.js
CHANGED
|
@@ -47,8 +47,16 @@ export class DiffCommand extends LoopressCommand {
|
|
|
47
47
|
// a tally, and the verdict, then returns the structured result. Exit code: 1 on drift, 2
|
|
48
48
|
// when a resource could not be compared (inconclusive), so the command doubles as a CI gate.
|
|
49
49
|
async report(targets, sides) {
|
|
50
|
-
this.out(`Comparing ${c('bold', sides.leftLabel)} ${c('dim', '→')} ${c('bold', sides.
|
|
50
|
+
this.out(`Comparing ${c('bold', sides.leftLabel)} ${c('dim', '→')} ${c('bold', sides.rightHeading)}\n`);
|
|
51
51
|
const results = await Promise.all(targets.map(async (target) => this.computeResource(target, sides)));
|
|
52
|
+
// Every resource failing the same way (site down, bad credentials) is one problem: say it
|
|
53
|
+
// once instead of once per resource.
|
|
54
|
+
const sharedError = sharedFailure(results.map((result) => result.json.error));
|
|
55
|
+
if (sharedError !== undefined) {
|
|
56
|
+
// Named, since some resources may have compared fine (one that needs no request).
|
|
57
|
+
const failedTitles = results.filter((result) => result.json.error !== undefined).map((result) => result.title);
|
|
58
|
+
this.out(c('red', `error (${failedTitles.join(', ')}): ${sharedError}`));
|
|
59
|
+
}
|
|
52
60
|
const resources = {};
|
|
53
61
|
let drift = false;
|
|
54
62
|
let failed = false;
|
|
@@ -60,8 +68,7 @@ export class DiffCommand extends LoopressCommand {
|
|
|
60
68
|
resources[result.resource] = result.json;
|
|
61
69
|
if (result.json.error !== undefined) {
|
|
62
70
|
failed = true;
|
|
63
|
-
|
|
64
|
-
this.out(`${c('bold', result.title)} ${errorLabel}`);
|
|
71
|
+
this.renderFailure(result.title, result.json.error, sharedError);
|
|
65
72
|
continue;
|
|
66
73
|
}
|
|
67
74
|
compared += 1;
|
|
@@ -91,6 +98,7 @@ export class DiffCommand extends LoopressCommand {
|
|
|
91
98
|
return {
|
|
92
99
|
againstWp: against ? this.wpForEnvironment(against) : undefined,
|
|
93
100
|
leftLabel: this.siteConfig.name,
|
|
101
|
+
rightHeading: against ?? 'local files',
|
|
94
102
|
rightLabel: against ?? 'local',
|
|
95
103
|
warn: (message) => {
|
|
96
104
|
this.warn(message);
|
|
@@ -120,6 +128,13 @@ export class DiffCommand extends LoopressCommand {
|
|
|
120
128
|
if (!this.jsonEnabled())
|
|
121
129
|
this.log(message);
|
|
122
130
|
}
|
|
131
|
+
renderFailure(title, error, sharedError) {
|
|
132
|
+
// Already printed once by `report` when every failure is the same.
|
|
133
|
+
if (sharedError !== undefined)
|
|
134
|
+
return;
|
|
135
|
+
const errorLabel = c('red', `error: ${error}`);
|
|
136
|
+
this.out(`${c('bold', title)} ${errorLabel}`);
|
|
137
|
+
}
|
|
123
138
|
renderSection(title, stateDiff) {
|
|
124
139
|
if (isEmptyDiff(stateDiff)) {
|
|
125
140
|
this.out(`${c('bold', title)} ${c('dim', 'in sync')}`);
|
|
@@ -180,3 +195,9 @@ function indentPatch(patch) {
|
|
|
180
195
|
.join('\n')
|
|
181
196
|
.trimEnd();
|
|
182
197
|
}
|
|
198
|
+
// The one error every failed resource shares, when more than one failed (see `report`).
|
|
199
|
+
function sharedFailure(errors) {
|
|
200
|
+
const failures = errors.filter((error) => error !== undefined);
|
|
201
|
+
const distinct = new Set(failures);
|
|
202
|
+
return failures.length > 1 && distinct.size === 1 ? failures[0] : undefined;
|
|
203
|
+
}
|
|
@@ -26,6 +26,7 @@ async function loadSnippet(path, file, onSkip) {
|
|
|
26
26
|
}
|
|
27
27
|
let id;
|
|
28
28
|
let name;
|
|
29
|
+
let description;
|
|
29
30
|
let type;
|
|
30
31
|
let isActive = false;
|
|
31
32
|
let tags = [];
|
|
@@ -40,6 +41,8 @@ async function loadSnippet(path, file, onSkip) {
|
|
|
40
41
|
// `||`, not `??`: an empty-string name in the sidecar JSON counts as absent too.
|
|
41
42
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
|
42
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;
|
|
43
46
|
type = parseType(meta.type) ?? undefined;
|
|
44
47
|
isActive = Boolean(meta.active);
|
|
45
48
|
tags = Array.isArray(meta.tags) ? meta.tags.map(String) : [];
|
|
@@ -58,6 +61,7 @@ async function loadSnippet(path, file, onSkip) {
|
|
|
58
61
|
return {
|
|
59
62
|
active: isActive,
|
|
60
63
|
code: content,
|
|
64
|
+
description,
|
|
61
65
|
id,
|
|
62
66
|
insertMethod: insertMethod ?? 'auto',
|
|
63
67
|
location: location ?? defaultLocationForType(resolvedType),
|
|
@@ -30,7 +30,15 @@ type RemotePhpFile = {
|
|
|
30
30
|
filename: string;
|
|
31
31
|
public?: boolean;
|
|
32
32
|
revision?: string;
|
|
33
|
+
runtimeError?: RuntimeError;
|
|
33
34
|
};
|
|
35
|
+
export type RuntimeError = {
|
|
36
|
+
at: string;
|
|
37
|
+
message: string;
|
|
38
|
+
method: string;
|
|
39
|
+
type: 'action' | 'cron' | 'filter';
|
|
40
|
+
};
|
|
41
|
+
export declare function formatRuntimeError(error: RuntimeError): string;
|
|
34
42
|
type PushResult = {
|
|
35
43
|
pruned: string[];
|
|
36
44
|
pushed: string[];
|
|
@@ -14,6 +14,9 @@ import { loadFiles as loadDirectoryFiles } from './load-files.js';
|
|
|
14
14
|
import { PushCommand } from './push-command.js';
|
|
15
15
|
import { getResourceStateProvider } from './resource-state.js';
|
|
16
16
|
import { isApplicative404 } from './wp-client.js';
|
|
17
|
+
export function formatRuntimeError(error) {
|
|
18
|
+
return `${error.method}() threw while running as ${error.type === 'action' ? 'an' : 'a'} ${error.type}, last at ${error.at}: ${error.message}`;
|
|
19
|
+
}
|
|
17
20
|
// Mirrors wordpress-plugin FileWriter::DECLARE_PATTERN / withGuard(): the server rejects both
|
|
18
21
|
// an absent declare(strict_types=1); and one that appears more than once (it needs a single
|
|
19
22
|
// unambiguous insertion point for the ABSPATH guard). Matching "exactly once" here, not just
|
|
@@ -262,6 +265,8 @@ export function resourceListCommand(spec) {
|
|
|
262
265
|
for (const file of files) {
|
|
263
266
|
const badges = [file.public ? '[PUBLIC]' : '', file.error ? `(${file.error})` : ''].filter(Boolean).join(' ');
|
|
264
267
|
this.log(badges ? ` ${file.filename} ${badges}` : ` ${file.filename}`);
|
|
268
|
+
if (file.runtimeError)
|
|
269
|
+
this.log(` ${formatRuntimeError(file.runtimeError)}`);
|
|
265
270
|
}
|
|
266
271
|
if (files.some((file) => file.public)) {
|
|
267
272
|
this.log('');
|
|
@@ -3,16 +3,17 @@ import { type ResourceState } from './diff-state.js';
|
|
|
3
3
|
import { type ResourceStateProvider } from './resource-state.js';
|
|
4
4
|
export declare abstract class PushCommand extends LoopressCommand {
|
|
5
5
|
static baseFlags: {
|
|
6
|
+
overwrite: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
6
7
|
'production-confirmed': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
7
8
|
env: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
9
|
};
|
|
9
10
|
protected failedCount: number;
|
|
10
11
|
private refusedByGuard;
|
|
11
|
-
protected captureBeforePushState(provider: ResourceStateProvider, dir: string): Promise<ResourceState | undefined>;
|
|
12
|
+
protected captureBeforePushState(provider: ResourceStateProvider, dir: string, writes?: (id: string) => boolean): Promise<ResourceState | undefined>;
|
|
12
13
|
catch(err: Error): Promise<void>;
|
|
13
14
|
protected guardProductionPush(): Promise<void>;
|
|
14
15
|
init(): Promise<void>;
|
|
15
|
-
protected previewRemote(provider: ResourceStateProvider, dir: string): Promise<
|
|
16
|
+
protected previewRemote(provider: ResourceStateProvider, dir: string): Promise<ResourceState>;
|
|
16
17
|
protected recordDeployment(status: 'failure' | 'success'): Promise<void>;
|
|
17
18
|
protected recordSuccess(): Promise<void>;
|
|
18
19
|
protected reportPushToSite(): Promise<void>;
|
|
@@ -23,4 +24,5 @@ export declare abstract class PushCommand extends LoopressCommand {
|
|
|
23
24
|
output: string;
|
|
24
25
|
}) => Promise<void>): Promise<void>;
|
|
25
26
|
protected writeAfterPushSnapshot(provider: ResourceStateProvider, dir: string, beforeState: ResourceState | undefined): Promise<void>;
|
|
27
|
+
private guardOutsideEdits;
|
|
26
28
|
}
|
package/dist/lib/push-command.js
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
1
2
|
import { Flags } from '@oclif/core';
|
|
2
3
|
import got from 'got';
|
|
3
4
|
import { Listr } from 'listr2';
|
|
4
5
|
import { resolve } from 'node:path';
|
|
6
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
7
|
import { authManager } from '../config/auth.manager.js';
|
|
6
8
|
import { API_URL } from './api-client.js';
|
|
7
9
|
import { LoopressCommand } from './base.js';
|
|
8
10
|
import { guardProductionPush } from './guard-production-push.js';
|
|
9
|
-
import {
|
|
11
|
+
import { isInteractive } from './interactive.js';
|
|
12
|
+
import { readSnapshot, recordToState, writeSnapshot } from './snapshot-store.js';
|
|
10
13
|
import { withoutSpinner, withSpinner } from './spinner.js';
|
|
11
14
|
export class PushCommand extends LoopressCommand {
|
|
12
15
|
// Passed by an aggregate command (`lps theme push`) that already confirmed production once,
|
|
13
16
|
// so its steps skip that prompt while still asking their own confirmations (uninstalls).
|
|
14
17
|
static baseFlags = {
|
|
15
18
|
...LoopressCommand.baseFlags,
|
|
19
|
+
overwrite: Flags.boolean({ description: 'Push even over changes made on the site outside Loopress since your last push' }),
|
|
16
20
|
'production-confirmed': Flags.boolean({ hidden: true }),
|
|
17
21
|
};
|
|
18
22
|
failedCount = 0;
|
|
@@ -26,13 +30,17 @@ export class PushCommand extends LoopressCommand {
|
|
|
26
30
|
// `writeAfterPushSnapshot` there is no restore point to complete.
|
|
27
31
|
//
|
|
28
32
|
// On a dry run, the same read is the preview's check against the site (see previewRemote).
|
|
29
|
-
|
|
33
|
+
//
|
|
34
|
+
// `writes` narrows the outside-edit check to the ids this push actually writes, for a push that
|
|
35
|
+
// skips some local items (`lps page push <slug>`, readonly options, `lps acf push --type`).
|
|
36
|
+
async captureBeforePushState(provider, dir, writes) {
|
|
30
37
|
if (this.dryRun) {
|
|
31
|
-
await this.previewRemote(provider, dir);
|
|
38
|
+
await this.guardOutsideEdits(provider, dir, await this.previewRemote(provider, dir), writes);
|
|
32
39
|
return undefined;
|
|
33
40
|
}
|
|
41
|
+
let remote;
|
|
34
42
|
try {
|
|
35
|
-
|
|
43
|
+
remote = await provider.remote(this.wp, (message) => {
|
|
36
44
|
this.warn(message);
|
|
37
45
|
}, dir);
|
|
38
46
|
}
|
|
@@ -40,6 +48,8 @@ export class PushCommand extends LoopressCommand {
|
|
|
40
48
|
this.warn(`Could not read remote state for a rollback snapshot: ${error.message}`);
|
|
41
49
|
return undefined;
|
|
42
50
|
}
|
|
51
|
+
await this.guardOutsideEdits(provider, dir, remote, writes);
|
|
52
|
+
return remote;
|
|
43
53
|
}
|
|
44
54
|
async catch(err) {
|
|
45
55
|
if (!this.dryRun && !this.refusedByGuard && this.siteConfig) {
|
|
@@ -68,7 +78,7 @@ export class PushCommand extends LoopressCommand {
|
|
|
68
78
|
// pre-apply check built on it, see what the real push will hit.
|
|
69
79
|
async previewRemote(provider, dir) {
|
|
70
80
|
try {
|
|
71
|
-
await provider.remote(this.wp, () => { }, dir);
|
|
81
|
+
return await provider.remote(this.wp, () => { }, dir);
|
|
72
82
|
}
|
|
73
83
|
catch (error) {
|
|
74
84
|
this.error(`The site would refuse this push: ${error.message}`);
|
|
@@ -165,4 +175,54 @@ export class PushCommand extends LoopressCommand {
|
|
|
165
175
|
this.warn(`Could not save a rollback snapshot after this push: ${error.message}`);
|
|
166
176
|
}
|
|
167
177
|
}
|
|
178
|
+
// Refuses to silently overwrite a change made on the site outside Loopress (wp-admin, another
|
|
179
|
+
// tool) since this machine's last push. Flags an id only when both hold:
|
|
180
|
+
// - the site no longer matches what that push left (the latest snapshot's `afterState`, the
|
|
181
|
+
// same reference rollback's drift check uses), and
|
|
182
|
+
// - this push would write a different value over it (local differs from the site), so a
|
|
183
|
+
// `pull` that already brought the change into the local files is no reason to stop.
|
|
184
|
+
// Only ids present locally count: a push is additive, it never touches a site-only id. And
|
|
185
|
+
// only ids the site still has with readable content: pushing over a deletion (`lps <resource>
|
|
186
|
+
// rm`, or wp-admin) loses nothing, and a file the site lists without its content (too large
|
|
187
|
+
// to send) can't be compared, so it would otherwise block every push.
|
|
188
|
+
// Ceiling: snapshots live in git-ignored `.loopress/`, so only the machine that pushed last
|
|
189
|
+
// is protected; a record kept on the site would cover teammates and CI.
|
|
190
|
+
async guardOutsideEdits(provider, dir, remote, writes) {
|
|
191
|
+
if (this.overwrite)
|
|
192
|
+
return;
|
|
193
|
+
let expected;
|
|
194
|
+
let local;
|
|
195
|
+
let pushedAt;
|
|
196
|
+
try {
|
|
197
|
+
const snapshot = await readSnapshot(resolve(process.cwd(), this.rootDir), provider.resource, this.siteConfig.name);
|
|
198
|
+
expected = recordToState(snapshot.afterState);
|
|
199
|
+
pushedAt = snapshot.createdAt;
|
|
200
|
+
local = await provider.local(dir, () => { });
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// No earlier push from this machine, or unreadable local files the push reports itself.
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const overwritten = [...local.keys()].filter((id) => (writes?.(id) ?? true) &&
|
|
207
|
+
remote.get(id) !== undefined &&
|
|
208
|
+
!sameEntry(remote, expected, id) &&
|
|
209
|
+
!sameEntry(local, remote, id));
|
|
210
|
+
if (overwritten.length === 0)
|
|
211
|
+
return;
|
|
212
|
+
const message = `Changed on ${this.siteConfig.url} outside Loopress since your last push (${pushedAt}), this push would overwrite: ${overwritten.join(', ')}. ` +
|
|
213
|
+
`Review with \`lps diff --only ${provider.resource}\`, bring the site's version into your files, or pass --overwrite.`;
|
|
214
|
+
// A dry run only previews: say what the real push would stop on.
|
|
215
|
+
if (this.dryRun) {
|
|
216
|
+
this.warn(message);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (!isInteractive())
|
|
220
|
+
this.error(message);
|
|
221
|
+
this.warn(message);
|
|
222
|
+
if (!(await confirm({ default: false, message: 'Overwrite those changes?' })))
|
|
223
|
+
this.error('Aborted.');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function sameEntry(left, right, id) {
|
|
227
|
+
return left.has(id) === right.has(id) && isDeepStrictEqual(left.get(id), right.get(id));
|
|
168
228
|
}
|
|
@@ -31,14 +31,14 @@ const ACF_VOLATILE_KEYS = ['modified'];
|
|
|
31
31
|
// A WPForms form is a WP post; `modified`/`modified_gmt` move on every edit.
|
|
32
32
|
const FORM_VOLATILE_KEYS = ['modified', 'modified_gmt'];
|
|
33
33
|
// ---- snippets -------------------------------------------------------------------------------
|
|
34
|
-
// `description` is dropped:
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
34
|
+
// `description` is dropped: a sidecar without one gets "Imported from <path>" on push, so
|
|
35
|
+
// comparing it would flag every such snippet as drifted. `id` becomes the map key. A PHP
|
|
36
|
+
// snippet's `code` has its leading `<?php` stripped on both sides: `snippet pull` re-adds it
|
|
37
|
+
// for editor friendliness, WordPress stores only the body. Other types keep it, it's content.
|
|
38
38
|
function canonicalSnippet(snippet) {
|
|
39
39
|
return {
|
|
40
40
|
active: snippet.active,
|
|
41
|
-
code: stripPhpOpeningTag(snippet.code),
|
|
41
|
+
code: snippet.type === 'php' ? stripPhpOpeningTag(snippet.code) : snippet.code,
|
|
42
42
|
insertMethod: snippet.insertMethod,
|
|
43
43
|
location: snippet.location,
|
|
44
44
|
name: snippet.name,
|
|
@@ -126,8 +126,15 @@ export function resourceRollbackCommand(resource, options) {
|
|
|
126
126
|
// commands/snippet/push.ts's `static args`), not a --path flag. --env/--yes: the
|
|
127
127
|
// production guard and the drift confirmation above already covered what the delegated
|
|
128
128
|
// push's own guard would ask again, same reasoning as the top-level `lps push`
|
|
129
|
-
// delegating to each resource's push command in commands/push.ts.
|
|
130
|
-
|
|
129
|
+
// delegating to each resource's push command in commands/push.ts. --overwrite: the drift
|
|
130
|
+
// check above is the same question as the push's outside-edit check, already answered.
|
|
131
|
+
await this.config.runCommand(`${command.replaceAll(' ', ':')}:push`, [
|
|
132
|
+
tmpDir,
|
|
133
|
+
'--env',
|
|
134
|
+
this.siteConfig.name,
|
|
135
|
+
'--yes',
|
|
136
|
+
'--overwrite',
|
|
137
|
+
]);
|
|
131
138
|
}
|
|
132
139
|
finally {
|
|
133
140
|
// A cleanup failure here must never hide a real error from the push above: warn about
|
|
@@ -178,12 +178,18 @@ function checkLoopressJson(localConfig, problems) {
|
|
|
178
178
|
if (plugins === undefined)
|
|
179
179
|
return;
|
|
180
180
|
if (typeof plugins !== 'object' || plugins === null || Array.isArray(plugins)) {
|
|
181
|
-
problems.push({ file: 'loopress.json', message: '"plugins" must be an object of slug -> version string' });
|
|
181
|
+
problems.push({ file: 'loopress.json', message: '"plugins" must be an object of slug -> version string or {version, active}' });
|
|
182
182
|
return;
|
|
183
183
|
}
|
|
184
184
|
for (const [slug, version] of Object.entries(plugins)) {
|
|
185
|
-
|
|
186
|
-
|
|
185
|
+
// The object form is what `plugin pull` writes for a plugin installed but inactive (see PluginPin).
|
|
186
|
+
const isPin = typeof version === 'string' ||
|
|
187
|
+
(typeof version === 'object' &&
|
|
188
|
+
version !== null &&
|
|
189
|
+
typeof version.version === 'string' &&
|
|
190
|
+
['boolean', 'undefined'].includes(typeof version.active));
|
|
191
|
+
if (!isPin)
|
|
192
|
+
problems.push({ file: 'loopress.json', message: `plugin "${slug}" must map to a version string or {version, active}` });
|
|
187
193
|
}
|
|
188
194
|
}
|
|
189
195
|
async function checkSnippets(dir, problems) {
|
package/dist/lib/wp-client.js
CHANGED
|
@@ -159,8 +159,21 @@ export function formatWpError(error, url, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
|
159
159
|
if (err.name === 'TimeoutError') {
|
|
160
160
|
return `Request timed out after ${timeoutMs / 1000}s on ${url}. Is the site reachable?`;
|
|
161
161
|
}
|
|
162
|
-
|
|
162
|
+
const reason = err.code ? NETWORK_ERROR_HINTS[err.code] : undefined;
|
|
163
|
+
// The site, not the endpoint: every resource fails the same way when the site is down, so
|
|
164
|
+
// `lps diff` can print it once (see DiffCommand.report).
|
|
165
|
+
if (reason)
|
|
166
|
+
return `Could not reach ${new URL(url).origin}: ${reason}. Run \`lps doctor\` to check the environment.`;
|
|
167
|
+
// Node leaves `message` empty on some network errors (an AggregateError over IPv4 + IPv6):
|
|
168
|
+
// fall back to the error code so the reason is never blank.
|
|
169
|
+
return `Request to ${url} failed: ${err.message?.length ? err.message : (err.code ?? String(error))}`;
|
|
163
170
|
}
|
|
171
|
+
const NETWORK_ERROR_HINTS = {
|
|
172
|
+
ECONNREFUSED: 'connection refused, the site may be down',
|
|
173
|
+
ECONNRESET: 'connection reset by the server',
|
|
174
|
+
ENOTFOUND: 'host not found, check the URL',
|
|
175
|
+
ETIMEDOUT: 'connection timed out',
|
|
176
|
+
};
|
|
164
177
|
function extractLoopressError(body) {
|
|
165
178
|
if (!body)
|
|
166
179
|
return undefined;
|
package/dist/types/snippet.d.ts
CHANGED