@loopress/cli 0.26.0 → 0.28.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 +275 -177
- package/dist/commands/app/pull.js +17 -4
- package/dist/commands/app/push.d.ts +3 -0
- package/dist/commands/app/push.js +29 -5
- package/dist/commands/composer/push.js +1 -19
- package/dist/commands/diff.js +1 -1
- package/dist/commands/init.js +7 -1
- package/dist/commands/page/push.js +2 -2
- package/dist/commands/plugin/add.d.ts +8 -1
- package/dist/commands/plugin/add.js +10 -5
- package/dist/commands/plugin/audit.js +4 -5
- package/dist/commands/plugin/pull.d.ts +3 -1
- package/dist/commands/plugin/pull.js +17 -9
- package/dist/commands/plugin/push.d.ts +5 -1
- package/dist/commands/plugin/push.js +90 -37
- package/dist/commands/plugin/status.d.ts +1 -0
- package/dist/commands/plugin/status.js +21 -2
- package/dist/commands/project/config.js +12 -8
- package/dist/commands/promote.d.ts +2 -0
- package/dist/commands/promote.js +70 -8
- package/dist/commands/pull.js +8 -4
- package/dist/commands/push.d.ts +1 -0
- package/dist/commands/push.js +42 -4
- package/dist/commands/template/diff.d.ts +10 -0
- package/dist/commands/template/diff.js +13 -0
- package/dist/commands/template/list.d.ts +13 -0
- package/dist/commands/template/list.js +23 -0
- package/dist/commands/template/push.d.ts +20 -0
- package/dist/commands/template/push.js +42 -0
- package/dist/commands/theme/add.d.ts +8 -1
- package/dist/commands/theme/add.js +4 -2
- package/dist/commands/theme/pull.d.ts +3 -1
- package/dist/commands/theme/pull.js +10 -4
- package/dist/commands/theme/style/diff.d.ts +2 -0
- package/dist/commands/{theme-styles → theme/style}/diff.js +2 -1
- package/dist/commands/{theme-styles → theme/style}/pull.d.ts +1 -1
- package/dist/commands/{theme-styles → theme/style}/pull.js +3 -3
- package/dist/commands/{theme-styles → theme/style}/push.d.ts +1 -1
- package/dist/commands/{theme-styles → theme/style}/push.js +5 -5
- package/dist/commands/theme/style/rollback.d.ts +2 -0
- package/dist/commands/theme/style/rollback.js +6 -0
- package/dist/lib/api-client.js +26 -1
- package/dist/lib/app-build.d.ts +5 -0
- package/dist/lib/app-build.js +69 -0
- package/dist/lib/app-manifest.d.ts +4 -0
- package/dist/lib/app-manifest.js +8 -0
- package/dist/lib/base.js +11 -2
- package/dist/lib/bootstrap-full-install.js +45 -14
- package/dist/lib/dev-watch.d.ts +1 -1
- package/dist/lib/dev-watch.js +4 -1
- package/dist/lib/diff-command.d.ts +1 -0
- package/dist/lib/diff-command.js +6 -4
- package/dist/lib/json-delegation.d.ts +1 -0
- package/dist/lib/json-delegation.js +18 -0
- package/dist/lib/load-snippets.js +1 -1
- package/dist/lib/php-files-command.js +4 -3
- package/dist/lib/readdir-tolerant.js +15 -5
- package/dist/lib/resource-state.js +43 -1
- package/dist/lib/rollback-command.d.ts +1 -0
- package/dist/lib/rollback-command.js +8 -6
- package/dist/lib/rotate-app-password.js +2 -2
- package/dist/lib/snapshot-store.js +14 -0
- package/dist/lib/validate-local.js +34 -7
- package/dist/lib/wp-client.d.ts +4 -1
- package/dist/lib/wp-client.js +56 -3
- package/dist/types/plugin.d.ts +8 -1
- package/dist/types/plugin.js +4 -1
- package/dist/types/project-config.generated.d.ts +19 -2
- package/dist/utils/composer.d.ts +14 -0
- package/dist/utils/composer.js +136 -0
- package/dist/utils/loopress-config.d.ts +1 -0
- package/dist/utils/loopress-config.js +14 -0
- package/dist/utils/page-format.d.ts +12 -0
- package/dist/utils/page-format.js +41 -26
- package/dist/utils/plugins.d.ts +9 -4
- package/dist/utils/plugins.js +16 -7
- package/dist/utils/resource-dirs.d.ts +2 -0
- package/dist/utils/resource-dirs.js +4 -0
- package/dist/utils/seo-format.d.ts +2 -2
- package/dist/utils/template-format.d.ts +30 -0
- package/dist/utils/template-format.js +36 -0
- package/oclif.manifest.json +536 -320
- package/package.json +7 -4
- package/schemas/project-config.schema.json +25 -4
- package/dist/commands/theme-styles/diff.d.ts +0 -2
- package/dist/commands/theme-styles/rollback.d.ts +0 -2
- package/dist/commands/theme-styles/rollback.js +0 -5
|
@@ -1,86 +1,104 @@
|
|
|
1
1
|
import { Flags } from '@oclif/core';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import { confirmUninstall } from '../../lib/interactive.js';
|
|
5
6
|
import { PushCommand } from '../../lib/push-command.js';
|
|
6
7
|
import { isNotFoundError } from '../../lib/wp-client.js';
|
|
7
|
-
import {
|
|
8
|
+
import { pinActive, pinVersion, pinVersions, } from '../../types/plugin.js';
|
|
9
|
+
import { toIntent, wpackagistRequire } from '../../utils/composer.js';
|
|
10
|
+
import { isDowngrade, parseCollisions, SYNC_TIMEOUT_MS, } from '../../utils/plugin-sync.js';
|
|
8
11
|
import { diffPlugins, lockedWpackagistSlugs, parseInstalledPlugins } from '../../utils/plugins.js';
|
|
9
|
-
|
|
12
|
+
import { isExactVersion } from '../../utils/version.js';
|
|
13
|
+
const IN_SYNC = {
|
|
14
|
+
activated: [],
|
|
15
|
+
deactivated: [],
|
|
16
|
+
installed: [],
|
|
17
|
+
pinned: [],
|
|
18
|
+
pruned: [],
|
|
19
|
+
removed: [],
|
|
20
|
+
status: 'in-sync',
|
|
21
|
+
};
|
|
10
22
|
export default class Push extends PushCommand {
|
|
11
|
-
static description = 'Install plugins on WordPress to match loopress.json,
|
|
23
|
+
static description = 'Install plugins on WordPress to match loopress.json (or composer.json), via Composer + WPackagist';
|
|
12
24
|
static enableJsonFlag = true;
|
|
13
25
|
static examples = ['$ lps plugin push', '$ lps plugin push --dry-run', '$ lps plugin push --force --prune'];
|
|
14
26
|
static flags = {
|
|
15
27
|
...PushCommand.dryRunFlag,
|
|
16
28
|
...PushCommand.yesFlag,
|
|
29
|
+
activate: Flags.boolean({
|
|
30
|
+
default: false,
|
|
31
|
+
description: 'composer.json projects only: activate every plugin it declares. Without it, plugins keep their current state and new ones stay inactive',
|
|
32
|
+
}),
|
|
17
33
|
force: Flags.boolean({
|
|
18
34
|
default: false,
|
|
19
35
|
description: 'Allow downgrades and let Loopress take over plugins installed outside it (replaces their files)',
|
|
20
36
|
}),
|
|
21
37
|
prune: Flags.boolean({
|
|
22
38
|
default: false,
|
|
23
|
-
description: 'Deactivate plugins that are active on the site but absent from loopress.json',
|
|
39
|
+
description: 'Deactivate plugins that are active on the site but absent from loopress.json (or composer.json)',
|
|
24
40
|
}),
|
|
25
41
|
};
|
|
26
42
|
async run() {
|
|
27
43
|
const { flags } = await this.parse(Push);
|
|
28
|
-
const { force, prune } = flags;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return { ...IN_SYNC, status: 'composer-managed' };
|
|
32
|
-
}
|
|
33
|
-
const manifest = this.localConfig.plugins ?? {};
|
|
34
|
-
if (Object.keys(manifest).length === 0) {
|
|
35
|
-
this.error('No plugins found in loopress.json. Run `lps plugin pull` or `lps plugin add <slug>` first.');
|
|
36
|
-
}
|
|
44
|
+
const { activate, force, prune } = flags;
|
|
45
|
+
const source = await this.loadSource(activate);
|
|
46
|
+
const fromComposer = source.file === 'composer.json';
|
|
37
47
|
this.log(`Pushing plugins to ${this.siteConfig.url}`);
|
|
48
|
+
if (fromComposer) {
|
|
49
|
+
this.log(`From composer.json, pushed whole (${source.packageCount} packages): themes and libraries it declares are synced too.`);
|
|
50
|
+
}
|
|
38
51
|
const raw = await this.wp.get('wp/v2/plugins');
|
|
39
52
|
const installed = parseInstalledPlugins(raw);
|
|
40
53
|
const managed = lockedWpackagistSlugs(await this.fetchInstanceLock(), 'plugin');
|
|
41
|
-
const diff = diffPlugins(manifest, installed, managed);
|
|
42
|
-
|
|
54
|
+
const diff = diffPlugins(source.manifest, installed, managed);
|
|
55
|
+
// composer.json records no active state: leave inactive plugins alone unless --activate.
|
|
56
|
+
const skippedActivations = fromComposer && !activate ? diff.toActivate : [];
|
|
57
|
+
if (fromComposer && !activate)
|
|
58
|
+
diff.toActivate = [];
|
|
59
|
+
this.guardForce(diff, force, source.file);
|
|
43
60
|
const toPrune = prune ? diff.untrackedActive : [];
|
|
44
|
-
// A "latest" pin never shows as drift, but its newest upstream
|
|
45
|
-
// since the last push, so a push must still run `composer update
|
|
46
|
-
const
|
|
47
|
-
if (!
|
|
61
|
+
// A "latest" pin (or a Composer constraint) never shows as drift, but its newest upstream
|
|
62
|
+
// release may have moved since the last push, so a push must still run `composer update`.
|
|
63
|
+
const hasFloatingPin = Object.values(source.manifest).some((pin) => !isExactVersion(pinVersion(pin)));
|
|
64
|
+
if (!hasFloatingPin && isNoop(diff, toPrune)) {
|
|
48
65
|
this.log('Everything is already in sync.');
|
|
66
|
+
this.logStillInactive(skippedActivations);
|
|
49
67
|
return { ...IN_SYNC };
|
|
50
68
|
}
|
|
51
69
|
if (isNoop(diff, toPrune))
|
|
52
|
-
this.log('Refreshing plugins pinned to
|
|
70
|
+
this.log('Refreshing plugins not pinned to an exact version to their newest releases.');
|
|
53
71
|
logPlan(this, diff, toPrune, force);
|
|
54
72
|
if (this.dryRun) {
|
|
55
73
|
return this.result(diff, { includeCollisions: true, pruned: toPrune, removed: diff.toRemove, status: 'dry-run' });
|
|
56
74
|
}
|
|
57
75
|
if (!(await confirmUninstall(diff.toRemove, this.yes)))
|
|
58
76
|
this.error('Aborted.');
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
// being uninstalled) plus the ones the manifest wants active but that are inactive today.
|
|
62
|
-
const toReactivate = [...deactivated.filter((p) => !diff.toRemove.includes(p.slug)), ...diff.toActivate];
|
|
77
|
+
// Plugins switched off for the file swap come back on, unless loopress.json wants them inactive.
|
|
78
|
+
const deactivated = (await this.deactivateEndangered(installed, diff, force)).filter((p) => !diff.toRemove.includes(p.slug) && (fromComposer || pinActive(source.manifest[p.slug])));
|
|
63
79
|
let response;
|
|
64
80
|
try {
|
|
65
|
-
response = await this.sync(
|
|
81
|
+
response = await this.sync(source.intent, force);
|
|
66
82
|
}
|
|
67
83
|
catch (error) {
|
|
68
84
|
// The file swap never happened, so restore the plugins we defensively deactivated
|
|
69
85
|
// instead of leaving the site with them switched off.
|
|
70
|
-
await this.activate(deactivated
|
|
86
|
+
await this.activate(deactivated);
|
|
71
87
|
throw error;
|
|
72
88
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
await this.
|
|
89
|
+
const activateNew = (slug) => (fromComposer ? activate : pinActive(source.manifest[slug]));
|
|
90
|
+
const newlyInstalled = await this.filesForNewlyInstalled(diff.toInstall);
|
|
91
|
+
const toActivate = [...deactivated, ...diff.toActivate, ...newlyInstalled.filter((p) => activateNew(p.slug))];
|
|
92
|
+
const toDeactivate = [...diff.toDeactivate, ...installed.filter((p) => toPrune.includes(p.slug))];
|
|
93
|
+
await this.deactivate(toDeactivate);
|
|
94
|
+
await this.activate(toActivate);
|
|
78
95
|
if (response.output.trim())
|
|
79
96
|
this.log(response.output.trim());
|
|
80
97
|
this.log('Plugins synced.');
|
|
98
|
+
this.logStillInactive([...skippedActivations, ...newlyInstalled.filter((p) => !activateNew(p.slug))], fromComposer);
|
|
81
99
|
await this.recordSuccess();
|
|
82
100
|
return this.result(diff, {
|
|
83
|
-
activated:
|
|
101
|
+
activated: toActivate.map((p) => p.slug),
|
|
84
102
|
includeCollisions: force,
|
|
85
103
|
pruned: toPrune,
|
|
86
104
|
removed: response.removed ?? diff.toRemove,
|
|
@@ -140,26 +158,59 @@ export default class Push extends PushCommand {
|
|
|
140
158
|
return file ? [{ file, slug }] : [];
|
|
141
159
|
});
|
|
142
160
|
}
|
|
143
|
-
guardForce(diff, force) {
|
|
161
|
+
guardForce(diff, force, file) {
|
|
144
162
|
if (force)
|
|
145
163
|
return;
|
|
146
164
|
if (diff.collisions.length > 0) {
|
|
147
165
|
const list = diff.collisions.map((c) => `${c.slug} (${c.installedVersion})`).join(', ');
|
|
148
166
|
this.error(`${diff.collisions.length} plugin(s) are already installed outside Loopress: ${list}. ` +
|
|
149
167
|
'Re-run with --force to let Loopress manage them (this replaces their files with the WPackagist build, ' +
|
|
150
|
-
|
|
168
|
+
`local modifications are lost), or remove them from ${file}.`);
|
|
151
169
|
}
|
|
152
170
|
const downgrades = diff.toPin.filter((p) => isDowngrade(p.from, p.to));
|
|
153
171
|
if (downgrades.length > 0) {
|
|
154
172
|
const list = downgrades.map((p) => `${p.slug} ${p.from} to ${p.to}`).join(', ');
|
|
155
173
|
this.error(`Refusing to downgrade: ${list}. A downgrade only replaces plugin files, it does not undo database ` +
|
|
156
|
-
|
|
174
|
+
`migrations the newer version ran, which can break the site. Re-run with --force, or bump the version in ${file}.`);
|
|
157
175
|
}
|
|
158
176
|
}
|
|
177
|
+
async loadSource(activate) {
|
|
178
|
+
const composerJsonPath = join(process.cwd(), this.rootDir, 'composer.json');
|
|
179
|
+
if (existsSync(composerJsonPath)) {
|
|
180
|
+
const composerJson = JSON.parse(await readFile(composerJsonPath, 'utf8'));
|
|
181
|
+
const manifest = wpackagistRequire(composerJson, 'plugin');
|
|
182
|
+
if (Object.keys(manifest).length === 0) {
|
|
183
|
+
this.error('No wpackagist-plugin/* package in composer.json. Run `lps plugin pull` first, or `lps composer push` to push it as is.');
|
|
184
|
+
}
|
|
185
|
+
const require = composerJson.require ?? {};
|
|
186
|
+
return { file: 'composer.json', intent: toIntent(require), manifest, packageCount: Object.keys(require).length };
|
|
187
|
+
}
|
|
188
|
+
if (activate) {
|
|
189
|
+
this.error('--activate only applies to projects with a composer.json: loopress.json already records whether each plugin is active.');
|
|
190
|
+
}
|
|
191
|
+
const manifest = this.localConfig.plugins ?? {};
|
|
192
|
+
if (Object.keys(manifest).length === 0) {
|
|
193
|
+
this.error('No plugins found in loopress.json. Run `lps plugin pull` or `lps plugin add <slug>` first.');
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
file: 'loopress.json',
|
|
197
|
+
intent: { plugins: pinVersions(manifest) },
|
|
198
|
+
manifest,
|
|
199
|
+
packageCount: Object.keys(manifest).length,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
// composer.json projects: plugins this push left inactive, and how to change that.
|
|
203
|
+
logStillInactive(plugins, fromComposer = true) {
|
|
204
|
+
if (!fromComposer || plugins.length === 0)
|
|
205
|
+
return;
|
|
206
|
+
this.log(`${plugins.length} plugin(s) from composer.json are inactive: ${plugins.map((p) => p.slug).join(', ')}.`);
|
|
207
|
+
this.log('Re-run with --activate to activate them, or activate them in wp-admin.');
|
|
208
|
+
}
|
|
159
209
|
result(diff, opts) {
|
|
160
210
|
const collisionSlugs = opts.includeCollisions ? diff.collisions.map((c) => c.slug) : [];
|
|
161
211
|
return {
|
|
162
212
|
activated: opts.activated ?? diff.toActivate.map((a) => a.slug),
|
|
213
|
+
deactivated: diff.toDeactivate.map((a) => a.slug),
|
|
163
214
|
installed: [...diff.toInstall.map((a) => a.slug), ...collisionSlugs],
|
|
164
215
|
pinned: diff.toPin.map((p) => p.slug),
|
|
165
216
|
pruned: opts.pruned,
|
|
@@ -167,9 +218,9 @@ export default class Push extends PushCommand {
|
|
|
167
218
|
status: opts.status,
|
|
168
219
|
};
|
|
169
220
|
}
|
|
170
|
-
async sync(
|
|
221
|
+
async sync(intent, force) {
|
|
171
222
|
try {
|
|
172
|
-
return await this.wp.post('loopress/v1/composer/sync', { force, intent
|
|
223
|
+
return await this.wp.post('loopress/v1/composer/sync', { force, intent, lock: null }, { timeoutMs: SYNC_TIMEOUT_MS });
|
|
173
224
|
}
|
|
174
225
|
catch (error) {
|
|
175
226
|
const collisions = parseCollisions(error);
|
|
@@ -184,6 +235,7 @@ function isNoop(diff, toPrune) {
|
|
|
184
235
|
return (diff.toInstall.length === 0 &&
|
|
185
236
|
diff.toPin.length === 0 &&
|
|
186
237
|
diff.toActivate.length === 0 &&
|
|
238
|
+
diff.toDeactivate.length === 0 &&
|
|
187
239
|
diff.toRemove.length === 0 &&
|
|
188
240
|
diff.collisions.length === 0 &&
|
|
189
241
|
toPrune.length === 0);
|
|
@@ -201,6 +253,7 @@ function logPlan(cmd, diff, prune, force) {
|
|
|
201
253
|
section('To take over', diff.collisions.map((c) => `! ${c.slug} (installed outside Loopress)`));
|
|
202
254
|
section('To re-pin', diff.toPin.map((p) => `~ ${p.slug} ${p.from} to ${p.to}`));
|
|
203
255
|
section('To activate', diff.toActivate.map((a) => `↑ ${a.slug}`));
|
|
256
|
+
section('To deactivate', diff.toDeactivate.map((a) => `↓ ${a.slug}`));
|
|
204
257
|
section('To uninstall', diff.toRemove.map((s) => `- ${s}`));
|
|
205
258
|
section('To deactivate (--prune)', prune.map((s) => `⊘ ${s}`));
|
|
206
259
|
}
|
|
@@ -12,7 +12,14 @@ export default class Status extends LoopressCommand {
|
|
|
12
12
|
const { url } = this.siteConfig;
|
|
13
13
|
if (existsSync(join(process.cwd(), this.rootDir, 'composer.json'))) {
|
|
14
14
|
this.warn('This project has a composer.json, which is authoritative for plugins. Run `lps composer` commands instead.');
|
|
15
|
-
return {
|
|
15
|
+
return {
|
|
16
|
+
drift: false,
|
|
17
|
+
inactive: [],
|
|
18
|
+
missing: [],
|
|
19
|
+
status: 'composer-managed',
|
|
20
|
+
untrackedActive: [],
|
|
21
|
+
wrongVersion: [],
|
|
22
|
+
};
|
|
16
23
|
}
|
|
17
24
|
const manifest = this.localConfig.plugins ?? {};
|
|
18
25
|
const raw = await this.wp.get('wp/v2/plugins');
|
|
@@ -22,6 +29,7 @@ export default class Status extends LoopressCommand {
|
|
|
22
29
|
const missing = diff.toInstall.map((a) => a.slug);
|
|
23
30
|
const wrongVersion = diff.toPin.map((p) => ({ live: p.from, pinned: p.to, slug: p.slug }));
|
|
24
31
|
const inactive = diff.toActivate.map((a) => a.slug);
|
|
32
|
+
const unexpectedlyActive = diff.toDeactivate.map((a) => a.slug);
|
|
25
33
|
const { untrackedActive } = diff;
|
|
26
34
|
// `collisions` are also drift (Loopress doesn't own them yet); surface them under missing's
|
|
27
35
|
// banner so `plugin push --force` is the obvious next step.
|
|
@@ -29,6 +37,7 @@ export default class Status extends LoopressCommand {
|
|
|
29
37
|
const drift = missing.length > 0 ||
|
|
30
38
|
wrongVersion.length > 0 ||
|
|
31
39
|
inactive.length > 0 ||
|
|
40
|
+
unexpectedlyActive.length > 0 ||
|
|
32
41
|
diff.toRemove.length > 0 ||
|
|
33
42
|
collisions.length > 0;
|
|
34
43
|
if (!drift && untrackedActive.length === 0) {
|
|
@@ -43,13 +52,23 @@ export default class Status extends LoopressCommand {
|
|
|
43
52
|
this.log(`Version drift: ${w.slug} is ${w.live}, loopress.json pins ${w.pinned}`);
|
|
44
53
|
if (inactive.length > 0)
|
|
45
54
|
this.log(`Pinned but inactive: ${inactive.join(', ')}`);
|
|
55
|
+
if (unexpectedlyActive.length > 0)
|
|
56
|
+
this.log(`Pinned inactive but active: ${unexpectedlyActive.join(', ')}`);
|
|
46
57
|
if (diff.toRemove.length > 0)
|
|
47
58
|
this.log(`Managed by Loopress but dropped from loopress.json: ${diff.toRemove.join(', ')}`);
|
|
48
59
|
if (untrackedActive.length > 0)
|
|
49
60
|
this.log(`Active but untracked: ${untrackedActive.join(', ')}`);
|
|
50
61
|
if (drift)
|
|
51
62
|
this.exit(1);
|
|
52
|
-
return {
|
|
63
|
+
return {
|
|
64
|
+
drift,
|
|
65
|
+
inactive,
|
|
66
|
+
missing,
|
|
67
|
+
status: drift ? 'drift' : 'in-sync',
|
|
68
|
+
unexpectedlyActive,
|
|
69
|
+
untrackedActive,
|
|
70
|
+
wrongVersion,
|
|
71
|
+
};
|
|
53
72
|
}
|
|
54
73
|
async fetchInstanceLock() {
|
|
55
74
|
try {
|
|
@@ -5,7 +5,7 @@ import { bootstrapLoopressFull } from '../../lib/bootstrap-full-install.js';
|
|
|
5
5
|
import { isInteractive } from '../../lib/interactive.js';
|
|
6
6
|
import { isLoopressFullActive } from '../../lib/plugin-detection.js';
|
|
7
7
|
import { authorizeWithBrowser } from '../../lib/wp-authorize-flow.js';
|
|
8
|
-
import { WpClient } from '../../lib/wp-client.js';
|
|
8
|
+
import { isLocalEnvironment, WpClient } from '../../lib/wp-client.js';
|
|
9
9
|
import { diagnoseWpSite } from '../../lib/wp-site-diagnostic.js';
|
|
10
10
|
const NEW_PROJECT = '__new__';
|
|
11
11
|
const AUTH_BROWSER = 'browser';
|
|
@@ -48,16 +48,20 @@ export default class Config extends Command {
|
|
|
48
48
|
const rawUrl = await input({
|
|
49
49
|
message: 'WordPress URL',
|
|
50
50
|
validate(value) {
|
|
51
|
+
let parsed;
|
|
51
52
|
try {
|
|
52
|
-
|
|
53
|
-
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
54
|
-
return 'URL must start with http:// or https://';
|
|
55
|
-
}
|
|
56
|
-
return true;
|
|
53
|
+
parsed = new URL(value);
|
|
57
54
|
}
|
|
58
55
|
catch {
|
|
59
56
|
return 'Invalid URL';
|
|
60
57
|
}
|
|
58
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
59
|
+
return 'URL must start with http:// or https://';
|
|
60
|
+
}
|
|
61
|
+
if (parsed.protocol === 'http:' && !isLocalEnvironment(envName)) {
|
|
62
|
+
return `Plain HTTP would send the Application Password in cleartext. Use HTTPS. Plain HTTP is allowed only for the "local" environment (this one is "${envName}").`;
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
61
65
|
},
|
|
62
66
|
});
|
|
63
67
|
let url = rawUrl;
|
|
@@ -88,10 +92,10 @@ export default class Config extends Command {
|
|
|
88
92
|
this.log('→ Run `lps project switch` to change the active project or environment');
|
|
89
93
|
}
|
|
90
94
|
async maybeBootstrapLoopressFull(env) {
|
|
91
|
-
const { token, url } = env;
|
|
95
|
+
const { name, token, url } = env;
|
|
92
96
|
if (!token)
|
|
93
97
|
return; // env was just built with one a few lines up; guard only narrows the type
|
|
94
|
-
const wp = new WpClient(url, token);
|
|
98
|
+
const wp = new WpClient(url, token, name);
|
|
95
99
|
// A detection failure (network hiccup, plugins REST disabled) is treated as "assume it's
|
|
96
100
|
// there" rather than surprising the user with an account-creating prompt they didn't ask
|
|
97
101
|
// for; `lps project config` can simply be re-run if it genuinely isn't installed.
|
|
@@ -17,8 +17,10 @@ export default class Promote extends Command {
|
|
|
17
17
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
18
18
|
};
|
|
19
19
|
run(): Promise<PromoteResult>;
|
|
20
|
+
private assertDirsInsideProject;
|
|
20
21
|
private confirmPromotion;
|
|
21
22
|
private delegateArgv;
|
|
23
|
+
private enterScratchCopy;
|
|
22
24
|
private requireEnvironment;
|
|
23
25
|
private resolveProject;
|
|
24
26
|
}
|
package/dist/commands/promote.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import { confirm } from '@inquirer/prompts';
|
|
2
2
|
import { Args, Command } from '@oclif/core';
|
|
3
|
+
// eslint-disable-next-line n/no-unsupported-features/node-builtins -- stable since 22.3, works unflagged on 22.0+
|
|
4
|
+
import { cp, mkdtemp, rm } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { basename, isAbsolute, join, relative, resolve } from 'node:path';
|
|
3
7
|
import { configManager } from '../config/project-config.manager.js';
|
|
4
8
|
import { LoopressCommand } from '../lib/base.js';
|
|
5
9
|
import { isInteractive } from '../lib/interactive.js';
|
|
10
|
+
import { stdoutToStderr } from '../lib/json-delegation.js';
|
|
6
11
|
import { readLocalConfig } from '../utils/loopress-config.js';
|
|
12
|
+
import { resolveResourceDir, RESOURCE_DIR_DEFAULTS } from '../utils/resource-dirs.js';
|
|
7
13
|
export default class Promote extends Command {
|
|
8
14
|
static args = {
|
|
9
15
|
from: Args.string({ description: 'Environment to copy the configuration from', required: true }),
|
|
10
16
|
to: Args.string({ description: 'Environment to copy the configuration to', required: true }),
|
|
11
17
|
};
|
|
12
|
-
static description = 'Copy every tracked resource from one environment to another by pulling from <from> then pushing to <to>. Local tracked files are overwritten with <from> in the process.';
|
|
18
|
+
static description = 'Copy every tracked resource from one environment to another by pulling from <from> then pushing to <to>. Local tracked files are overwritten with <from> in the process. With --dry-run, <from> is pulled into a throwaway copy of the project instead, and the push is only previewed.';
|
|
13
19
|
static enableJsonFlag = true;
|
|
14
20
|
static examples = ['$ lps promote staging production', '$ lps promote production staging --dry-run'];
|
|
15
21
|
static flags = {
|
|
@@ -28,19 +34,55 @@ export default class Promote extends Command {
|
|
|
28
34
|
}
|
|
29
35
|
if (!dryRun)
|
|
30
36
|
await this.confirmPromotion(from.name, to.name, to.url, yes);
|
|
31
|
-
|
|
37
|
+
// A dry-run pull writes nothing, so a dry-run push right after would preview the current
|
|
38
|
+
// local files instead of <from>. Instead, really pull into a throwaway copy of the project
|
|
39
|
+
// and dry-run the push from there: the preview is what a real promotion would push, and the
|
|
40
|
+
// user's own files stay untouched.
|
|
41
|
+
const originalCwd = process.cwd();
|
|
42
|
+
const scratch = dryRun ? await this.enterScratchCopy(originalCwd) : undefined;
|
|
43
|
+
// The delegated pull is real, so without this its auto-rotation could revoke a stale app
|
|
44
|
+
// password during what the user asked to be a preview (see LoopressCommand.maybeAutoRotate).
|
|
45
|
+
const previousNoRotate = process.env.LOOPRESS_NO_AUTO_ROTATE;
|
|
46
|
+
if (scratch)
|
|
47
|
+
process.env.LOOPRESS_NO_AUTO_ROTATE = '1';
|
|
32
48
|
try {
|
|
33
|
-
|
|
49
|
+
this.log(`\n=== Pulling from ${from.name} (${from.url}) ===`);
|
|
50
|
+
try {
|
|
51
|
+
await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('pull', this.delegateArgv(from.name, false)));
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
// A partial pull must never be pushed onward: stop before touching <to>.
|
|
55
|
+
this.error(`Pull from ${from.name} failed, ${to.name} left untouched: ${error.message}`);
|
|
56
|
+
}
|
|
57
|
+
this.log(`\n=== Pushing to ${to.name} (${to.url}) ===`);
|
|
58
|
+
await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand('push', this.delegateArgv(to.name, dryRun)));
|
|
34
59
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
60
|
+
finally {
|
|
61
|
+
if (scratch) {
|
|
62
|
+
if (previousNoRotate === undefined)
|
|
63
|
+
delete process.env.LOOPRESS_NO_AUTO_ROTATE;
|
|
64
|
+
else
|
|
65
|
+
process.env.LOOPRESS_NO_AUTO_ROTATE = previousNoRotate;
|
|
66
|
+
process.chdir(originalCwd);
|
|
67
|
+
await rm(scratch, { force: true, recursive: true });
|
|
68
|
+
}
|
|
38
69
|
}
|
|
39
|
-
this.log(`\n=== Pushing to ${to.name} (${to.url}) ===`);
|
|
40
|
-
await this.config.runCommand('push', this.delegateArgv(to.name, dryRun));
|
|
41
70
|
this.log(dryRun ? `\n[dry-run] ${from.name} would be promoted to ${to.name}.` : `\n${from.name} promoted to ${to.name}.`);
|
|
42
71
|
return { from: from.name, status: dryRun ? 'dry-run' : 'promoted', to: to.name };
|
|
43
72
|
}
|
|
73
|
+
// The scratch pull writes wherever loopress.json points. An absolute or `../` rootDir or
|
|
74
|
+
// resource dir would land outside the copy, in the user's real files, during a dry run.
|
|
75
|
+
async assertDirsInsideProject(projectDir) {
|
|
76
|
+
const localConfig = await readLocalConfig();
|
|
77
|
+
const dirs = [localConfig.rootDir ?? '.', ...Object.keys(RESOURCE_DIR_DEFAULTS).map((kind) => resolveResourceDir(kind, localConfig))];
|
|
78
|
+
const outside = dirs.find((dir) => {
|
|
79
|
+
const rel = relative(projectDir, resolve(projectDir, dir));
|
|
80
|
+
return rel.startsWith('..') || isAbsolute(rel);
|
|
81
|
+
});
|
|
82
|
+
if (outside !== undefined) {
|
|
83
|
+
this.error(`--dry-run can't preview safely: "${outside}" in loopress.json is outside the project. Run \`lps diff --env <from> --against <to>\` instead.`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
44
86
|
// Deliberately not `guardProductionPush`: promote overwrites local tracked files for every
|
|
45
87
|
// target, not just production, so it needs its own confirmation regardless of the name. That
|
|
46
88
|
// prompt also calls out production when relevant, making a second production-only guard
|
|
@@ -69,6 +111,26 @@ export default class Promote extends Command {
|
|
|
69
111
|
argv.push('--dry-run');
|
|
70
112
|
return argv;
|
|
71
113
|
}
|
|
114
|
+
// Copies the whole project (not just what `pull` rewrites): push also reads files pull never
|
|
115
|
+
// touches, and a real promotion pushes those as they are locally. node_modules and .git are
|
|
116
|
+
// never read by push. Returns the scratch root, cwd is left inside the copy.
|
|
117
|
+
async enterScratchCopy(projectDir) {
|
|
118
|
+
await this.assertDirsInsideProject(projectDir);
|
|
119
|
+
const scratch = await mkdtemp(join(tmpdir(), 'lps-promote-'));
|
|
120
|
+
try {
|
|
121
|
+
const copy = join(scratch, basename(projectDir));
|
|
122
|
+
await cp(projectDir, copy, {
|
|
123
|
+
filter: (source) => !['.git', 'node_modules'].includes(basename(source)),
|
|
124
|
+
recursive: true,
|
|
125
|
+
});
|
|
126
|
+
process.chdir(copy);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
await rm(scratch, { force: true, recursive: true });
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
return scratch;
|
|
133
|
+
}
|
|
72
134
|
requireEnvironment(environments, projectName, name) {
|
|
73
135
|
const env = environments[name];
|
|
74
136
|
if (!env) {
|
package/dist/commands/pull.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { LoopressCommand } from '../lib/base.js';
|
|
2
|
+
import { stdoutToStderr } from '../lib/json-delegation.js';
|
|
2
3
|
import { pluralize } from '../utils/pluralize.js';
|
|
3
|
-
// composer before plugins: `plugin:pull`
|
|
4
|
-
//
|
|
4
|
+
// composer before plugins: when a composer.json exists, `plugin:pull` merges the live plugins
|
|
5
|
+
// into it, so it must run on the freshly pulled one or `composer:pull` would overwrite its work. The rest write to independent
|
|
5
6
|
// directories and their order does not matter, it mirrors `lps push` for familiarity.
|
|
6
7
|
const PULL_TARGETS = [
|
|
7
8
|
{ commandId: 'composer:pull', label: 'composer' },
|
|
@@ -29,7 +30,7 @@ export default class Pull extends LoopressCommand {
|
|
|
29
30
|
for (const target of PULL_TARGETS) {
|
|
30
31
|
this.log(`\n→ Pulling ${target.label}...`);
|
|
31
32
|
try {
|
|
32
|
-
await this.config.runCommand(target.commandId, this.buildArgv(target));
|
|
33
|
+
await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand(target.commandId, this.buildArgv(target)));
|
|
33
34
|
this.log(`✓ ${target.label} pulled`);
|
|
34
35
|
results.push({ label: target.label, status: 'pulled' });
|
|
35
36
|
}
|
|
@@ -41,7 +42,10 @@ export default class Pull extends LoopressCommand {
|
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
if (this.failedCount > 0) {
|
|
44
|
-
|
|
45
|
+
// Each failure's own reason, not just the count: under --json the per-resource lines above
|
|
46
|
+
// never reach stdout, and this message is all an MCP caller or a script gets to see.
|
|
47
|
+
const failures = results.filter((result) => result.status === 'failed').map((result) => `${result.label}: ${result.error}`);
|
|
48
|
+
this.error(`${pluralize(this.failedCount, 'resource')} failed to pull. ${failures.join('; ')}`);
|
|
45
49
|
}
|
|
46
50
|
this.log('\nAll resources pulled.');
|
|
47
51
|
return { results };
|
package/dist/commands/push.d.ts
CHANGED
package/dist/commands/push.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
1
4
|
import { LoopressCommand } from '../lib/base.js';
|
|
2
5
|
import { guardProductionPush } from '../lib/guard-production-push.js';
|
|
6
|
+
import { stdoutToStderr } from '../lib/json-delegation.js';
|
|
7
|
+
import { wpackagistRequire } from '../utils/composer.js';
|
|
3
8
|
import { pluralize } from '../utils/pluralize.js';
|
|
4
9
|
// Dependency-ish order: code first (plugins, composer), then the ACF definitions content
|
|
5
10
|
// relies on, then content itself.
|
|
@@ -10,6 +15,8 @@ const PUSH_TARGETS = [
|
|
|
10
15
|
{ commandId: 'api:push', label: 'API routes' },
|
|
11
16
|
{ commandId: 'hook:push', label: 'hooks' },
|
|
12
17
|
{ commandId: 'form:push', label: 'forms' },
|
|
18
|
+
// Before pages: WordPress refuses a page whose `template` it doesn't know yet.
|
|
19
|
+
{ commandId: 'template:push', label: 'templates and parts' },
|
|
13
20
|
{ commandId: 'page:push', label: 'pages' },
|
|
14
21
|
{ commandId: 'seo:push', label: 'SEO' },
|
|
15
22
|
{ commandId: 'menu:push', label: 'menus' },
|
|
@@ -17,7 +24,7 @@ const PUSH_TARGETS = [
|
|
|
17
24
|
{ commandId: 'snippet:push', label: 'snippets' },
|
|
18
25
|
];
|
|
19
26
|
export default class Push extends LoopressCommand {
|
|
20
|
-
static description = 'Push all local content, plugins, composer dependencies, ACF, API routes, hooks, forms, pages, SEO, menus, options, and snippets, to WordPress';
|
|
27
|
+
static description = 'Push all local content, plugins, composer dependencies, ACF, API routes, hooks, forms, templates, pages, SEO, menus, options, and snippets, to WordPress';
|
|
21
28
|
static enableJsonFlag = true;
|
|
22
29
|
static examples = ['$ lps push', '$ lps push --env staging', '$ lps push --dry-run'];
|
|
23
30
|
static flags = {
|
|
@@ -29,10 +36,16 @@ export default class Push extends LoopressCommand {
|
|
|
29
36
|
await this.guardProductionPush();
|
|
30
37
|
const argv = this.buildArgv();
|
|
31
38
|
const results = [];
|
|
39
|
+
const composerPushedByPlugins = await this.composerDeclaresPlugins();
|
|
32
40
|
for (const target of PUSH_TARGETS) {
|
|
41
|
+
if (composerPushedByPlugins && target.commandId === 'composer:push') {
|
|
42
|
+
this.log('\n→ composer: already pushed whole by plugins');
|
|
43
|
+
results.push({ label: target.label, status: 'pushed' });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
33
46
|
this.log(`\n→ Pushing ${target.label}...`);
|
|
34
47
|
try {
|
|
35
|
-
await this.config.runCommand(target.commandId, argv);
|
|
48
|
+
await stdoutToStderr(this.jsonEnabled(), async () => this.config.runCommand(target.commandId, argv));
|
|
36
49
|
this.log(`✓ ${target.label} pushed`);
|
|
37
50
|
results.push({ label: target.label, status: 'pushed' });
|
|
38
51
|
}
|
|
@@ -44,7 +57,12 @@ export default class Push extends LoopressCommand {
|
|
|
44
57
|
}
|
|
45
58
|
}
|
|
46
59
|
if (this.failedCount > 0) {
|
|
47
|
-
|
|
60
|
+
// Each failure's own reason, not just the count: under --json the per-resource lines above
|
|
61
|
+
// never reach stdout, and this message is all an MCP caller or a script gets to see.
|
|
62
|
+
const failures = results
|
|
63
|
+
.filter((result) => result.status === 'failed')
|
|
64
|
+
.map((result) => `${result.label}: ${result.error}`);
|
|
65
|
+
this.error(`${pluralize(this.failedCount, 'resource')} failed to push. ${failures.join('; ')}`);
|
|
48
66
|
}
|
|
49
67
|
this.log('\nAll resources pushed.');
|
|
50
68
|
return { results };
|
|
@@ -58,9 +76,29 @@ export default class Push extends LoopressCommand {
|
|
|
58
76
|
argv.push('--dry-run');
|
|
59
77
|
return argv;
|
|
60
78
|
}
|
|
79
|
+
// A composer.json that declares plugins is pushed whole by `plugin:push` (with its activation
|
|
80
|
+
// safety), so `composer:push` would only run Composer on the server a second time.
|
|
81
|
+
async composerDeclaresPlugins() {
|
|
82
|
+
const path = join(process.cwd(), this.rootDir, 'composer.json');
|
|
83
|
+
if (!existsSync(path))
|
|
84
|
+
return false;
|
|
85
|
+
try {
|
|
86
|
+
const composerJson = JSON.parse(await readFile(path, 'utf8'));
|
|
87
|
+
return Object.keys(wpackagistRequire(composerJson, 'plugin')).length > 0;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// Unreadable: let both commands run and report the parse error themselves.
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
61
94
|
// Guards once here (shared with PushCommand, lib/push-command.ts) rather than once per
|
|
62
95
|
// delegated command.
|
|
63
96
|
async guardProductionPush() {
|
|
64
|
-
await guardProductionPush({
|
|
97
|
+
await guardProductionPush({
|
|
98
|
+
dryRun: this.dryRun,
|
|
99
|
+
error: (message) => this.error(message),
|
|
100
|
+
siteConfig: this.siteConfig,
|
|
101
|
+
yes: this.yes,
|
|
102
|
+
});
|
|
65
103
|
}
|
|
66
104
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { DiffCommand, type DiffJson } from '../../lib/diff-command.js';
|
|
2
|
+
export default class Diff extends DiffCommand {
|
|
3
|
+
static description: string;
|
|
4
|
+
static enableJsonFlag: boolean;
|
|
5
|
+
static examples: string[];
|
|
6
|
+
static flags: {
|
|
7
|
+
against: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
8
|
+
};
|
|
9
|
+
run(): Promise<DiffJson>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { DiffCommand } from '../../lib/diff-command.js';
|
|
2
|
+
import { getResourceStateProvider } from '../../lib/resource-state.js';
|
|
3
|
+
export default class Diff extends DiffCommand {
|
|
4
|
+
static description = 'Show what differs in block templates and parts between your local files and a WordPress environment, or between two environments. Templates and parts edited in the Site Editor show up too.';
|
|
5
|
+
static enableJsonFlag = true;
|
|
6
|
+
static examples = ['$ lps template diff', '$ lps template diff --env staging', '$ lps template diff --env staging --against production'];
|
|
7
|
+
static flags = { ...DiffCommand.againstFlag };
|
|
8
|
+
async run() {
|
|
9
|
+
const { flags } = await this.parse(Diff);
|
|
10
|
+
const sides = this.resolveSides(flags.against);
|
|
11
|
+
return this.report(['template', 'part'].map((resource) => this.providerTarget(getResourceStateProvider(resource), sides)), sides);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { LoopressCommand } from '../../lib/base.js';
|
|
2
|
+
import { type ChildTheme } from '../../utils/template-format.js';
|
|
3
|
+
type ListResult = Omit<ChildTheme, 'parts' | 'templates'> & {
|
|
4
|
+
parts: string[];
|
|
5
|
+
templates: string[];
|
|
6
|
+
};
|
|
7
|
+
export default class List extends LoopressCommand {
|
|
8
|
+
static description: string;
|
|
9
|
+
static enableJsonFlag: boolean;
|
|
10
|
+
static examples: string[];
|
|
11
|
+
run(): Promise<ListResult>;
|
|
12
|
+
}
|
|
13
|
+
export {};
|